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:
@@ -60,6 +60,9 @@ export async function hireEmployee(payload: {
|
|||||||
emergency_contact_relation?: string;
|
emergency_contact_relation?: string;
|
||||||
is_laterale_fuehrung?: boolean;
|
is_laterale_fuehrung?: boolean;
|
||||||
is_c_level?: boolean;
|
is_c_level?: boolean;
|
||||||
|
has_kuendigungsschutz?: boolean;
|
||||||
|
/** Nur mit dem Kennzeichen zusammen — so verlangt es chk_kuendigungsschutz_bis. */
|
||||||
|
kuendigungsschutz_bis?: string | null;
|
||||||
}): Promise<ActionResult & { employeeId?: string }> {
|
}): Promise<ActionResult & { employeeId?: string }> {
|
||||||
// Einzige Mutation, deren Rückgabewert gebraucht wird: die neue
|
// Einzige Mutation, deren Rückgabewert gebraucht wird: die neue
|
||||||
// Personen-Kennung, damit die Oberfläche direkt auf die Akte springen kann.
|
// Personen-Kennung, damit die Oberfläche direkt auf die Akte springen kann.
|
||||||
|
|||||||
@@ -147,6 +147,8 @@ function employeeExportColumns(
|
|||||||
{ header: "Betriebsrat", get: (e) => e.is_betriebsrat },
|
{ header: "Betriebsrat", get: (e) => e.is_betriebsrat },
|
||||||
{ header: "Dienstwagen", get: (e) => e.has_dienstwagen },
|
{ header: "Dienstwagen", get: (e) => e.has_dienstwagen },
|
||||||
{ header: "Antriebsart", get: (e) => e.dienstwagen_art ?? "" },
|
{ header: "Antriebsart", get: (e) => e.dienstwagen_art ?? "" },
|
||||||
|
{ header: "Besonderer Kündigungsschutz", get: (e) => e.has_kuendigungsschutz },
|
||||||
|
{ header: "Kündigungsschutz bis", get: (e) => e.kuendigungsschutz_bis, kind: "date" },
|
||||||
{ header: "Notfallkontakt", get: (e) => e.emergency_contact_name ?? "" },
|
{ header: "Notfallkontakt", get: (e) => e.emergency_contact_name ?? "" },
|
||||||
{ header: "Notfallkontakt Telefon", get: (e) => e.emergency_contact_phone ?? "" },
|
{ header: "Notfallkontakt Telefon", get: (e) => e.emergency_contact_phone ?? "" },
|
||||||
{ header: "Notfallkontakt Verhältnis", get: (e) => e.emergency_contact_relation ?? "" },
|
{ header: "Notfallkontakt Verhältnis", get: (e) => e.emergency_contact_relation ?? "" },
|
||||||
|
|||||||
@@ -106,14 +106,20 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{canEditData && <ActionButton icon={Pencil} label="Daten ändern" onClick={() => setPanel("daten")} />}
|
{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
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setPanel("terminate")}
|
onClick={() => setPanel("terminate")}
|
||||||
className="!border-danger-solid !text-danger-solid hover:!bg-danger-bg"
|
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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{employee.status === "Ausgetreten" && (
|
{employee.status === "Ausgetreten" && (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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";
|
import type { CollectiveAgreement, DienstwagenArt, Weekday, WorkerType } from "@/lib/supabase/types";
|
||||||
|
|
||||||
const WEEKDAYS: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
const WEEKDAYS: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||||
@@ -22,6 +22,14 @@ export type RoleEmploymentValue = {
|
|||||||
dienstwagenArt: DienstwagenArt;
|
dienstwagenArt: DienstwagenArt;
|
||||||
isLateraleFuehrung: boolean;
|
isLateraleFuehrung: boolean;
|
||||||
isCLevel: 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
|
// 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 })} />
|
<input type="checkbox" checked={value.isCLevel} onChange={(e) => onChange({ isCLevel: e.target.checked })} />
|
||||||
C-Level
|
C-Level
|
||||||
</label>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ export function DatenAendernPanel({
|
|||||||
workDays: employee.work_days ?? ["Mo", "Di", "Mi", "Do", "Fr"],
|
workDays: employee.work_days ?? ["Mo", "Di", "Mi", "Do", "Fr"],
|
||||||
isBetriebsrat: employee.is_betriebsrat ?? false,
|
isBetriebsrat: employee.is_betriebsrat ?? false,
|
||||||
hasDienstwagen: employee.has_dienstwagen ?? false,
|
hasDienstwagen: employee.has_dienstwagen ?? false,
|
||||||
|
hasKuendigungsschutz: employee.has_kuendigungsschutz ?? false,
|
||||||
|
kuendigungsschutzBis: employee.kuendigungsschutz_bis ?? "",
|
||||||
dienstwagenArt: employee.dienstwagen_art ?? "Verbrenner",
|
dienstwagenArt: employee.dienstwagen_art ?? "Verbrenner",
|
||||||
isLateraleFuehrung: employee.is_laterale_fuehrung ?? false,
|
isLateraleFuehrung: employee.is_laterale_fuehrung ?? false,
|
||||||
isCLevel: employee.is_c_level ?? false,
|
isCLevel: employee.is_c_level ?? false,
|
||||||
@@ -145,6 +147,8 @@ export function DatenAendernPanel({
|
|||||||
is_betriebsrat: role.isBetriebsrat,
|
is_betriebsrat: role.isBetriebsrat,
|
||||||
has_dienstwagen: role.hasDienstwagen,
|
has_dienstwagen: role.hasDienstwagen,
|
||||||
dienstwagen_art: role.hasDienstwagen ? role.dienstwagenArt : "",
|
dienstwagen_art: role.hasDienstwagen ? role.dienstwagenArt : "",
|
||||||
|
has_kuendigungsschutz: role.hasKuendigungsschutz,
|
||||||
|
kuendigungsschutz_bis: role.hasKuendigungsschutz ? role.kuendigungsschutzBis : "",
|
||||||
is_laterale_fuehrung: role.isLateraleFuehrung,
|
is_laterale_fuehrung: role.isLateraleFuehrung,
|
||||||
is_c_level: role.isCLevel,
|
is_c_level: role.isCLevel,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { SelectField, TextField, TextareaField } from "@/components/ui/Field";
|
|||||||
import { SlideOver } from "@/components/ui/SlideOver";
|
import { SlideOver } from "@/components/ui/SlideOver";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
import type { Database } from "@/lib/supabase/types";
|
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"];
|
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||||
|
|
||||||
@@ -37,7 +37,10 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
|
|||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [exitDate, setExitDate] = useState("");
|
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 [note, setNote] = useState("");
|
||||||
const [checked, setChecked] = useState<boolean[]>(CHECKLIST_ITEMS.map(() => false));
|
const [checked, setChecked] = useState<boolean[]>(CHECKLIST_ITEMS.map(() => false));
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
@@ -79,7 +82,7 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
|
|||||||
<SlideOver
|
<SlideOver
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title="Austritt"
|
title={istNoShow ? "Nicht angetreten" : "Austritt"}
|
||||||
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
|
subtitle={`${fmtName(employee.first_name, employee.last_name)} · ${employee.job_title}`}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
@@ -93,6 +96,21 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<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 && (
|
{directReportCount > 0 && (
|
||||||
<div className="rounded bg-warning-bg px-3 py-2 text-sm text-warning-text">
|
<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.
|
{directReportCount} direkte Berichte werden automatisch der nächsthöheren Führungskraft zugeordnet.
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ export function VertragTab({ employee }: { employee: EmployeeRow }) {
|
|||||||
["Betriebsrat", employee.is_betriebsrat ? "Ja" : "Nein"],
|
["Betriebsrat", employee.is_betriebsrat ? "Ja" : "Nein"],
|
||||||
["Laterale Führung", employee.is_laterale_fuehrung ? "Ja" : "Nein"],
|
["Laterale Führung", employee.is_laterale_fuehrung ? "Ja" : "Nein"],
|
||||||
["C-Level", employee.is_c_level ? "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)]);
|
if (employee.exit_date) rows.push(["Austrittsdatum", fmtDate(employee.exit_date)]);
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
|||||||
// Null, sobald kein Dienstwagen da ist — der CHECK lässt die Angabe
|
// Null, sobald kein Dienstwagen da ist — der CHECK lässt die Angabe
|
||||||
// sonst nicht zu.
|
// sonst nicht zu.
|
||||||
dienstwagen_art: draft.hasDienstwagen ? draft.dienstwagenArt : null,
|
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_name: draft.emergencyContactName.trim() || undefined,
|
||||||
emergency_contact_phone: draft.emergencyContactPhone.trim() || undefined,
|
emergency_contact_phone: draft.emergencyContactPhone.trim() || undefined,
|
||||||
emergency_contact_relation: draft.emergencyContactRelation.trim() || undefined,
|
emergency_contact_relation: draft.emergencyContactRelation.trim() || undefined,
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
|
|||||||
draft.hasDienstwagen && `Dienstwagen (${dienstwagenLabel(draft.dienstwagenArt)})`,
|
draft.hasDienstwagen && `Dienstwagen (${dienstwagenLabel(draft.dienstwagenArt)})`,
|
||||||
draft.isLateraleFuehrung && "Laterale Führung",
|
draft.isLateraleFuehrung && "Laterale Führung",
|
||||||
draft.isCLevel && "C-Level",
|
draft.isCLevel && "C-Level",
|
||||||
|
draft.hasKuendigungsschutz &&
|
||||||
|
`Besonderer Kündigungsschutz${draft.kuendigungsschutzBis ? ` bis ${fmtDate(draft.kuendigungsschutzBis)}` : ""}`,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
const notfall = [draft.emergencyContactName, draft.emergencyContactRelation && `(${draft.emergencyContactRelation})`, draft.emergencyContactPhone]
|
const notfall = [draft.emergencyContactName, draft.emergencyContactRelation && `(${draft.emergencyContactRelation})`, draft.emergencyContactPhone]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ export type HireDraftData = {
|
|||||||
angehoerige: HireDraftAngehoerige[];
|
angehoerige: HireDraftAngehoerige[];
|
||||||
isLateraleFuehrung: boolean;
|
isLateraleFuehrung: boolean;
|
||||||
isCLevel: boolean;
|
isCLevel: boolean;
|
||||||
|
hasKuendigungsschutz: boolean;
|
||||||
|
/** Freiwillig — leer heisst „bis auf Weiteres". */
|
||||||
|
kuendigungsschutzBis: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EMPTY_HIRE_DRAFT: HireDraftData = {
|
export const EMPTY_HIRE_DRAFT: HireDraftData = {
|
||||||
@@ -90,4 +93,6 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
|
|||||||
angehoerige: [],
|
angehoerige: [],
|
||||||
isLateraleFuehrung: false,
|
isLateraleFuehrung: false,
|
||||||
isCLevel: false,
|
isCLevel: false,
|
||||||
|
hasKuendigungsschutz: false,
|
||||||
|
kuendigungsschutzBis: "",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -237,6 +237,10 @@ export async function laden(
|
|||||||
emergency_contact_relation: txt(w.emergency_contact_relation),
|
emergency_contact_relation: txt(w.emergency_contact_relation),
|
||||||
is_laterale_fuehrung: bool(w.is_laterale_fuehrung, false),
|
is_laterale_fuehrung: bool(w.is_laterale_fuehrung, false),
|
||||||
is_c_level: bool(w.is_c_level, false),
|
is_c_level: bool(w.is_c_level, false),
|
||||||
|
has_kuendigungsschutz: bool(w.has_kuendigungsschutz, false),
|
||||||
|
// Ohne Schutz kein Enddatum — chk_kuendigungsschutz_bis weist es sonst
|
||||||
|
// ab, und die ganze Datei scheiterte an einer einzelnen Zeile.
|
||||||
|
kuendigungsschutz_bis: bool(w.has_kuendigungsschutz, false) ? (txt(w.kuendigungsschutz_bis) ?? null) : null,
|
||||||
entry_date: eintritt,
|
entry_date: eintritt,
|
||||||
exit_date: austritt,
|
exit_date: austritt,
|
||||||
exit_reason: txt(w.exit_reason),
|
exit_reason: txt(w.exit_reason),
|
||||||
|
|||||||
@@ -391,6 +391,22 @@ export const BLATT_PERSONEN: BlattSchema = {
|
|||||||
},
|
},
|
||||||
{ name: "Laterale Führung", ziel: "is_laterale_fuehrung", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
|
{ name: "Laterale Führung", ziel: "is_laterale_fuehrung", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
|
||||||
{ name: "C-Level", ziel: "is_c_level", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
|
{ name: "C-Level", ziel: "is_c_level", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
|
||||||
|
{
|
||||||
|
name: "Besonderer Kündigungsschutz",
|
||||||
|
ziel: "has_kuendigungsschutz",
|
||||||
|
pflicht: false,
|
||||||
|
typ: { art: "janein" },
|
||||||
|
hinweis: "Leer = nein.",
|
||||||
|
beispiel: "nein",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Kündigungsschutz bis",
|
||||||
|
ziel: "kuendigungsschutz_bis",
|
||||||
|
pflicht: false,
|
||||||
|
typ: { art: "datum" },
|
||||||
|
hinweis: "Nur mit gesetztem Schutz; freiwillig.",
|
||||||
|
beispiel: "",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Abwesenheit ab",
|
name: "Abwesenheit ab",
|
||||||
ziel: "karenz_start_date",
|
ziel: "karenz_start_date",
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export const JANEIN_KRITERIEN: readonly JaNeinKriterium[] = [
|
|||||||
{ key: "betriebsrat", label: "Betriebsrat" },
|
{ key: "betriebsrat", label: "Betriebsrat" },
|
||||||
{ key: "lateral", label: "Laterale Führung" },
|
{ key: "lateral", label: "Laterale Führung" },
|
||||||
{ key: "clevel", label: "C-Level" },
|
{ key: "clevel", label: "C-Level" },
|
||||||
|
{ key: "kuendigungsschutz", label: "Besonderer Kündigungsschutz" },
|
||||||
{ key: "dependents", label: "Angehörige erfasst" },
|
{ key: "dependents", label: "Angehörige erfasst" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -79,6 +80,7 @@ export const ZEITRAUM_KRITERIEN: readonly ZeitraumKriterium[] = [
|
|||||||
{ key: "entry", label: "Eintritt" },
|
{ key: "entry", label: "Eintritt" },
|
||||||
{ key: "exit", label: "Austritt" },
|
{ key: "exit", label: "Austritt" },
|
||||||
{ key: "birth", label: "Geburtsdatum" },
|
{ key: "birth", label: "Geburtsdatum" },
|
||||||
|
{ key: "schutz", label: "Kündigungsschutz bis" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Criteria = {
|
export type Criteria = {
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export function applyCriteria<O>(
|
|||||||
if (j.betriebsrat) q = q.where("is_betriebsrat", "=", j.betriebsrat === "ja");
|
if (j.betriebsrat) q = q.where("is_betriebsrat", "=", j.betriebsrat === "ja");
|
||||||
if (j.lateral) q = q.where("is_laterale_fuehrung", "=", j.lateral === "ja");
|
if (j.lateral) q = q.where("is_laterale_fuehrung", "=", j.lateral === "ja");
|
||||||
if (j.clevel) q = q.where("is_c_level", "=", j.clevel === "ja");
|
if (j.clevel) q = q.where("is_c_level", "=", j.clevel === "ja");
|
||||||
|
if (j.kuendigungsschutz) q = q.where("has_kuendigungsschutz", "=", j.kuendigungsschutz === "ja");
|
||||||
|
|
||||||
// Ein Austrittszeitraum schliesst alle ohne Austritt aus — ein Vergleich
|
// Ein Austrittszeitraum schliesst alle ohne Austritt aus — ein Vergleich
|
||||||
// mit NULL ist nicht wahr, und das ist hier genau die richtige Bedeutung.
|
// mit NULL ist nicht wahr, und das ist hier genau die richtige Bedeutung.
|
||||||
@@ -79,6 +80,8 @@ export function applyCriteria<O>(
|
|||||||
if (c.zeitraum.exit?.bis) q = q.where("exit_date", "<=", c.zeitraum.exit.bis);
|
if (c.zeitraum.exit?.bis) q = q.where("exit_date", "<=", c.zeitraum.exit.bis);
|
||||||
if (c.zeitraum.birth?.von) q = q.where("birth_date", ">=", c.zeitraum.birth.von);
|
if (c.zeitraum.birth?.von) q = q.where("birth_date", ">=", c.zeitraum.birth.von);
|
||||||
if (c.zeitraum.birth?.bis) q = q.where("birth_date", "<=", c.zeitraum.birth.bis);
|
if (c.zeitraum.birth?.bis) q = q.where("birth_date", "<=", c.zeitraum.birth.bis);
|
||||||
|
if (c.zeitraum.schutz?.von) q = q.where("kuendigungsschutz_bis", ">=", c.zeitraum.schutz.von);
|
||||||
|
if (c.zeitraum.schutz?.bis) q = q.where("kuendigungsschutz_bis", "<=", c.zeitraum.schutz.bis);
|
||||||
|
|
||||||
if (c.stundenVon !== undefined) q = q.where("weekly_hours", ">=", c.stundenVon);
|
if (c.stundenVon !== undefined) q = q.where("weekly_hours", ">=", c.stundenVon);
|
||||||
if (c.stundenBis !== undefined) q = q.where("weekly_hours", "<=", c.stundenBis);
|
if (c.stundenBis !== undefined) q = q.where("weekly_hours", "<=", c.stundenBis);
|
||||||
|
|||||||
@@ -172,6 +172,10 @@ export type Database = {
|
|||||||
has_dienstwagen: boolean;
|
has_dienstwagen: boolean;
|
||||||
/** Null genau dann, wenn kein Dienstwagen vorhanden ist — chk_dienstwagen_art. */
|
/** Null genau dann, wenn kein Dienstwagen vorhanden ist — chk_dienstwagen_art. */
|
||||||
dienstwagen_art: DienstwagenArt | null;
|
dienstwagen_art: DienstwagenArt | null;
|
||||||
|
/** Besonderer Kündigungsschutz — löst beim Austritt eine Warnung aus. */
|
||||||
|
has_kuendigungsschutz: boolean;
|
||||||
|
/** Ende des Schutzes, falls bekannt. Nur mit dem Kennzeichen zusammen erlaubt. */
|
||||||
|
kuendigungsschutz_bis: string | null;
|
||||||
/** Nur gemeinsam gesetzt oder gemeinsam leer — chk_emergency_contact. */
|
/** Nur gemeinsam gesetzt oder gemeinsam leer — chk_emergency_contact. */
|
||||||
emergency_contact_name: string | null;
|
emergency_contact_name: string | null;
|
||||||
emergency_contact_phone: string | null;
|
emergency_contact_phone: string | null;
|
||||||
@@ -219,6 +223,8 @@ export type Database = {
|
|||||||
is_betriebsrat?: boolean;
|
is_betriebsrat?: boolean;
|
||||||
has_dienstwagen?: boolean;
|
has_dienstwagen?: boolean;
|
||||||
dienstwagen_art?: DienstwagenArt | null;
|
dienstwagen_art?: DienstwagenArt | null;
|
||||||
|
has_kuendigungsschutz?: boolean;
|
||||||
|
kuendigungsschutz_bis?: string | null;
|
||||||
emergency_contact_name?: string | null;
|
emergency_contact_name?: string | null;
|
||||||
emergency_contact_phone?: string | null;
|
emergency_contact_phone?: string | null;
|
||||||
emergency_contact_relation?: string | null;
|
emergency_contact_relation?: string | null;
|
||||||
|
|||||||
357
supabase/migrations/20260814120000_kuendigungsschutz.sql
Normal file
357
supabase/migrations/20260814120000_kuendigungsschutz.sql
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
-- Besonderer Kündigungsschutz.
|
||||||
|
--
|
||||||
|
-- Betriebsratsmitglieder, Schwangere, Eltern in Karenz, begünstigte
|
||||||
|
-- Behinderte, Lehrlinge, Präsenzdiener — für sie gelten eigene Regeln, bevor
|
||||||
|
-- ein Dienstverhältnis beendet werden darf. Das Werkzeug entscheidet das
|
||||||
|
-- nicht, aber es soll niemanden einen Austritt erfassen lassen, ohne es zu
|
||||||
|
-- erwähnen.
|
||||||
|
--
|
||||||
|
-- Zwei Spalten, wie beim Dienstwagen: ein Kennzeichen und eine Angabe, die
|
||||||
|
-- nur mit ihm zusammen Sinn ergibt. Das Datum ist **freiwillig** — bei einem
|
||||||
|
-- Betriebsratsmandat steht das Ende oft fest, bei einer Schwangerschaft
|
||||||
|
-- nicht, und ein Pflichtfeld zwänge dann zu einer erfundenen Zahl.
|
||||||
|
--
|
||||||
|
-- Die Bedingung sagt nur, was ohne das Kennzeichen nicht sein darf. Sie
|
||||||
|
-- verlangt umgekehrt kein Datum.
|
||||||
|
|
||||||
|
alter table employees
|
||||||
|
add column if not exists has_kuendigungsschutz boolean not null default false,
|
||||||
|
add column if not exists kuendigungsschutz_bis date;
|
||||||
|
|
||||||
|
comment on column employees.has_kuendigungsschutz is
|
||||||
|
'Besonderer Kündigungsschutz — Betriebsrat, Mutterschutz, Karenz, begünstigte Behinderung, Lehrverhältnis. Löst beim Austritt eine Warnung aus.';
|
||||||
|
comment on column employees.kuendigungsschutz_bis is
|
||||||
|
'Ende des Schutzes, falls bekannt. Freiwillig: bei einer Schwangerschaft steht es nicht fest, bei einem Mandat schon.';
|
||||||
|
|
||||||
|
alter table employees drop constraint if exists chk_kuendigungsschutz_bis;
|
||||||
|
alter table employees add constraint chk_kuendigungsschutz_bis
|
||||||
|
check (has_kuendigungsschutz or kuendigungsschutz_bis is null);
|
||||||
|
|
||||||
|
comment on constraint chk_kuendigungsschutz_bis on employees is
|
||||||
|
'Ein Enddatum ohne Schutz wäre ein Rest, den niemand mehr deuten kann. has_kuendigungsschutz ist NOT NULL, deshalb genügt hier die einfache Oder-Form — anders als bei chk_dienstwagen_art, wo eine nullbare Spalte die Bedingung sonst durchrutschen liesse.';
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.app_feld_karte()
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE sql
|
||||||
|
IMMUTABLE
|
||||||
|
SET search_path TO 'public', 'pg_temp'
|
||||||
|
AS $function$
|
||||||
|
select jsonb_build_object(
|
||||||
|
'Vorname', jsonb_build_array('first_name', 'text', 'person'),
|
||||||
|
'Nachname', jsonb_build_array('last_name', 'text', 'person'),
|
||||||
|
'Geschlecht', jsonb_build_array('gender', 'gender_type', 'person'),
|
||||||
|
'Geburtsdatum', jsonb_build_array('birth_date', 'date', 'person'),
|
||||||
|
'SV-Nummer', jsonb_build_array('sv_nummer', 'text', 'person'),
|
||||||
|
'Staatsbürgerschaft', jsonb_build_array('nationality', 'text', 'person'),
|
||||||
|
'Adresse', jsonb_build_array('address', 'text', 'person'),
|
||||||
|
'Postleitzahl', jsonb_build_array('postal_code', 'text', 'person'),
|
||||||
|
'Ort', jsonb_build_array('city', 'text', 'person'),
|
||||||
|
'Land', jsonb_build_array('address_country', 'text', 'person'),
|
||||||
|
'E-Mail', jsonb_build_array('email', 'text', 'person'),
|
||||||
|
'Telefon', jsonb_build_array('phone', 'text', 'person'),
|
||||||
|
'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text', 'person'),
|
||||||
|
'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text', 'person'),
|
||||||
|
'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text', 'person'),
|
||||||
|
'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste', 'person'),
|
||||||
|
'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste', 'person'),
|
||||||
|
'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type', 'contract'),
|
||||||
|
'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric', 'contract'),
|
||||||
|
'Vertragsart', jsonb_build_array('contract_type', 'contract_type', 'contract'),
|
||||||
|
'Befristet bis', jsonb_build_array('contract_end_date', 'date', 'contract'),
|
||||||
|
'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type', 'role'),
|
||||||
|
'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement', 'role'),
|
||||||
|
'Arbeitstage', jsonb_build_array('work_days', 'liste', 'role'),
|
||||||
|
'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean', 'role'),
|
||||||
|
'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean', 'role'),
|
||||||
|
'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean', 'role'),
|
||||||
|
'C-Level', jsonb_build_array('is_c_level', 'boolean', 'role'),
|
||||||
|
'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text', 'role'),
|
||||||
|
'Besonderer Kündigungsschutz', jsonb_build_array('has_kuendigungsschutz', 'boolean', 'role'),
|
||||||
|
'Kündigungsschutz bis', jsonb_build_array('kuendigungsschutz_bis', 'date', 'role')
|
||||||
|
);
|
||||||
|
$function$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.hire_employee(payload jsonb)
|
||||||
|
RETURNS uuid
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SET search_path TO 'public', 'pg_temp'
|
||||||
|
AS $function$
|
||||||
|
declare
|
||||||
|
v_id uuid;
|
||||||
|
v_position_id uuid := (payload->>'position_id')::uuid;
|
||||||
|
v_entry date := (payload->>'entry_date')::date;
|
||||||
|
v_besetzt uuid;
|
||||||
|
begin
|
||||||
|
perform require_hr_admin();
|
||||||
|
|
||||||
|
if v_position_id is null then
|
||||||
|
raise exception 'Es muss eine Planstelle angegeben werden.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if payload->>'personnel_number' is null or btrim(payload->>'personnel_number') = '' then
|
||||||
|
raise exception 'Es muss eine Personalnummer angegeben werden.';
|
||||||
|
end if;
|
||||||
|
if exists (select 1 from employees where personnel_number = (payload->>'personnel_number')::int) then
|
||||||
|
raise exception 'Die Personalnummer % ist bereits vergeben.', payload->>'personnel_number';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
declare
|
||||||
|
v_ab date;
|
||||||
|
v_bis date;
|
||||||
|
begin
|
||||||
|
select valid_from, valid_to into v_ab, v_bis from om_positions where id = v_position_id;
|
||||||
|
if v_ab is null then
|
||||||
|
raise exception 'Die Planstelle existiert nicht.';
|
||||||
|
end if;
|
||||||
|
if v_entry < v_ab then
|
||||||
|
raise exception 'Die Planstelle gilt erst ab %. Ein Eintritt am % ist darauf nicht möglich.', v_ab, v_entry;
|
||||||
|
end if;
|
||||||
|
if v_bis is not null and v_entry >= v_bis then
|
||||||
|
raise exception 'Die Planstelle gilt nur bis %. Ein Eintritt am % ist darauf nicht möglich.', v_bis, v_entry;
|
||||||
|
end if;
|
||||||
|
end;
|
||||||
|
|
||||||
|
select pa.employee_id into v_besetzt
|
||||||
|
from position_assignments pa
|
||||||
|
where pa.position_id = v_position_id
|
||||||
|
and (pa.valid_to is null or pa.valid_to > v_entry);
|
||||||
|
if v_besetzt is not null then
|
||||||
|
raise exception 'Diese Planstelle ist bereits besetzt.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into employees (
|
||||||
|
personnel_number, first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
|
||||||
|
address, postal_code, city, address_country, location_id, job_title,
|
||||||
|
employment_type, weekly_hours, contract_type, contract_end_date, paygrade,
|
||||||
|
source, status, entry_date, title_prefix, title_suffix,
|
||||||
|
worker_type, collective_agreement, work_days,
|
||||||
|
is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level,
|
||||||
|
has_kuendigungsschutz, kuendigungsschutz_bis,
|
||||||
|
dienstwagen_art, emergency_contact_name, emergency_contact_phone, emergency_contact_relation
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
(payload->>'personnel_number')::int, payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
|
||||||
|
(payload->>'birth_date')::date, payload->>'sv_nummer',
|
||||||
|
coalesce(payload->>'nationality', 'Österreich'), payload->>'email', payload->>'phone',
|
||||||
|
payload->>'address', payload->>'postal_code', payload->>'city',
|
||||||
|
coalesce(payload->>'address_country', 'Österreich'),
|
||||||
|
(payload->>'location_id')::uuid,
|
||||||
|
(select j.title from om_positions p join jobs j on j.id = p.job_id where p.id = v_position_id),
|
||||||
|
coalesce((payload->>'employment_type')::employment_type, 'Vollzeit'),
|
||||||
|
coalesce((payload->>'weekly_hours')::numeric, 38.5),
|
||||||
|
coalesce((payload->>'contract_type')::contract_type, 'unbefristet'),
|
||||||
|
nullif(payload->>'contract_end_date', '')::date,
|
||||||
|
coalesce((payload->>'paygrade')::paygrade_type, 'B'),
|
||||||
|
coalesce((payload->>'source')::source_type, 'Extern'),
|
||||||
|
case when v_entry > current_date then 'Geplant' else 'Aktiv' end::employment_status,
|
||||||
|
v_entry,
|
||||||
|
coalesce(array(select jsonb_array_elements_text(payload->'title_prefix')), '{}'),
|
||||||
|
coalesce(array(select jsonb_array_elements_text(payload->'title_suffix')), '{}'),
|
||||||
|
coalesce((payload->>'worker_type')::worker_type, 'Angestellte:r'),
|
||||||
|
coalesce((payload->>'collective_agreement')::collective_agreement, 'Süßwaren'),
|
||||||
|
coalesce(nullif(array(select jsonb_array_elements_text(payload->'work_days'))::text[], '{}'), '{Mo,Di,Mi,Do,Fr}'),
|
||||||
|
coalesce((payload->>'is_betriebsrat')::boolean, false),
|
||||||
|
coalesce((payload->>'has_dienstwagen')::boolean, false),
|
||||||
|
coalesce((payload->>'is_laterale_fuehrung')::boolean, false),
|
||||||
|
coalesce((payload->>'is_c_level')::boolean, false),
|
||||||
|
coalesce((payload->>'has_kuendigungsschutz')::boolean, false),
|
||||||
|
-- Das Datum nur, wenn der Schutz überhaupt gesetzt ist: sonst bliebe
|
||||||
|
-- ein Enddatum ohne Schutz stehen, und chk_kuendigungsschutz_bis
|
||||||
|
-- würde es zu Recht abweisen.
|
||||||
|
case when coalesce((payload->>'has_kuendigungsschutz')::boolean, false)
|
||||||
|
then nullif(payload->>'kuendigungsschutz_bis', '')::date else null end,
|
||||||
|
case when coalesce((payload->>'has_dienstwagen')::boolean, false) then coalesce(nullif(payload->>'dienstwagen_art', ''), 'Verbrenner') else null end,
|
||||||
|
nullif(payload->>'emergency_contact_name', ''),
|
||||||
|
nullif(payload->>'emergency_contact_phone', ''),
|
||||||
|
nullif(payload->>'emergency_contact_relation', '')
|
||||||
|
)
|
||||||
|
returning id into v_id;
|
||||||
|
|
||||||
|
insert into position_assignments (position_id, employee_id, valid_from)
|
||||||
|
values (v_position_id, v_id, v_entry);
|
||||||
|
|
||||||
|
insert into employee_history (employee_id, event_date, event_type, description)
|
||||||
|
values (v_id, v_entry, 'Eintritt', 'Eintritt auf Planstelle ' ||
|
||||||
|
(select position_number from om_positions where id = v_position_id));
|
||||||
|
|
||||||
|
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||||
|
values (app_current_user_id(), current_actor_name(), 'Neueinstellung',
|
||||||
|
(payload->>'first_name') || ' ' || (payload->>'last_name'), v_id, 'Eintritt am ' || v_entry);
|
||||||
|
|
||||||
|
return v_id;
|
||||||
|
end;
|
||||||
|
$function$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.change_employee_data(payload jsonb)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SET search_path TO 'public', 'pg_temp'
|
||||||
|
AS $function$
|
||||||
|
declare
|
||||||
|
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||||
|
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
|
||||||
|
v_old employees%rowtype;
|
||||||
|
v_name text;
|
||||||
|
v_person_changes jsonb := '[]'::jsonb;
|
||||||
|
v_contract_changes jsonb := '[]'::jsonb;
|
||||||
|
v_person jsonb := payload->'person';
|
||||||
|
v_contract jsonb := payload->'contract';
|
||||||
|
v_role jsonb := payload->'role';
|
||||||
|
v_immediate boolean;
|
||||||
|
v_new_work_days text[];
|
||||||
|
v_new_title_prefix text[];
|
||||||
|
v_new_title_suffix text[];
|
||||||
|
v_pending_id uuid;
|
||||||
|
begin
|
||||||
|
perform require_hr_admin();
|
||||||
|
select * into v_old from employees where id = v_employee_id;
|
||||||
|
v_name := v_old.first_name || ' ' || v_old.last_name;
|
||||||
|
v_immediate := v_effective_date <= current_date;
|
||||||
|
|
||||||
|
-- Der `?`-Test bleibt: ein fehlender Schlüssel heisst „nicht übermittelt",
|
||||||
|
-- nicht „geleert". Ohne ihn würde jedes nicht gesendete Feld als Änderung
|
||||||
|
-- auf null gemeldet.
|
||||||
|
if v_person ? 'first_name' then v_person_changes := app_aenderung(v_person_changes, 'Vorname', v_old.first_name, v_person->>'first_name'); end if;
|
||||||
|
if v_person ? 'last_name' then v_person_changes := app_aenderung(v_person_changes, 'Nachname', v_old.last_name, v_person->>'last_name'); end if;
|
||||||
|
if v_person ? 'gender' then v_person_changes := app_aenderung(v_person_changes, 'Geschlecht', v_old.gender::text, v_person->>'gender'); end if;
|
||||||
|
-- Datumswerte über ::date::text vergleichen, damit „2026-8-3" und
|
||||||
|
-- „2026-08-03" nicht als Änderung gelten.
|
||||||
|
if v_person ? 'birth_date' then v_person_changes := app_aenderung(v_person_changes, 'Geburtsdatum', v_old.birth_date::text, (nullif(v_person->>'birth_date','')::date)::text); end if;
|
||||||
|
if v_person ? 'sv_nummer' then v_person_changes := app_aenderung(v_person_changes, 'SV-Nummer', v_old.sv_nummer, v_person->>'sv_nummer'); end if;
|
||||||
|
if v_person ? 'nationality' then v_person_changes := app_aenderung(v_person_changes, 'Staatsbürgerschaft', v_old.nationality, v_person->>'nationality'); end if;
|
||||||
|
if v_person ? 'address' then v_person_changes := app_aenderung(v_person_changes, 'Adresse', v_old.address, v_person->>'address'); end if;
|
||||||
|
if v_person ? 'postal_code' then v_person_changes := app_aenderung(v_person_changes, 'Postleitzahl', v_old.postal_code, v_person->>'postal_code'); end if;
|
||||||
|
if v_person ? 'city' then v_person_changes := app_aenderung(v_person_changes, 'Ort', v_old.city, v_person->>'city'); end if;
|
||||||
|
if v_person ? 'address_country' then v_person_changes := app_aenderung(v_person_changes, 'Land', v_old.address_country, v_person->>'address_country'); end if;
|
||||||
|
if v_person ? 'email' then v_person_changes := app_aenderung(v_person_changes, 'E-Mail', v_old.email, v_person->>'email'); end if;
|
||||||
|
if v_person ? 'phone' then v_person_changes := app_aenderung(v_person_changes, 'Telefon', v_old.phone, v_person->>'phone'); end if;
|
||||||
|
if v_person ? 'emergency_contact_name' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt', v_old.emergency_contact_name, v_person->>'emergency_contact_name'); end if;
|
||||||
|
if v_person ? 'emergency_contact_phone' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Telefon', v_old.emergency_contact_phone, v_person->>'emergency_contact_phone'); end if;
|
||||||
|
if v_person ? 'emergency_contact_relation' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Verhältnis', v_old.emergency_contact_relation, v_person->>'emergency_contact_relation'); end if;
|
||||||
|
|
||||||
|
if v_person ? 'title_prefix' then
|
||||||
|
v_new_title_prefix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_prefix') elem), '{}');
|
||||||
|
v_person_changes := app_aenderung(v_person_changes, 'Titel (vorangestellt)',
|
||||||
|
array_to_string(v_old.title_prefix, ', '), array_to_string(v_new_title_prefix, ', '));
|
||||||
|
end if;
|
||||||
|
if v_person ? 'title_suffix' then
|
||||||
|
v_new_title_suffix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_suffix') elem), '{}');
|
||||||
|
v_person_changes := app_aenderung(v_person_changes, 'Titel (nachgestellt)',
|
||||||
|
array_to_string(v_old.title_suffix, ', '), array_to_string(v_new_title_suffix, ', '));
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_contract ? 'employment_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_contract->>'employment_type'); end if;
|
||||||
|
-- Über ::numeric::text, damit „38.50" und „38.5" gleich zählen.
|
||||||
|
if v_contract ? 'weekly_hours' then v_contract_changes := app_aenderung(v_contract_changes, 'Wochenstunden', v_old.weekly_hours::text, (nullif(v_contract->>'weekly_hours','')::numeric)::text); end if;
|
||||||
|
if v_contract ? 'contract_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Vertragsart', v_old.contract_type::text, v_contract->>'contract_type'); end if;
|
||||||
|
if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if;
|
||||||
|
|
||||||
|
if v_role ? 'worker_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Angestellte:r/Arbeiter:in', v_old.worker_type::text, v_role->>'worker_type'); end if;
|
||||||
|
if v_role ? 'collective_agreement' then v_contract_changes := app_aenderung(v_contract_changes, 'Kollektivvertrag', v_old.collective_agreement::text, v_role->>'collective_agreement'); end if;
|
||||||
|
if v_role ? 'work_days' then
|
||||||
|
v_new_work_days := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}');
|
||||||
|
v_contract_changes := app_aenderung(v_contract_changes, 'Arbeitstage',
|
||||||
|
array_to_string(v_old.work_days, ', '), array_to_string(v_new_work_days, ', '));
|
||||||
|
end if;
|
||||||
|
if v_role ? 'is_betriebsrat' then v_contract_changes := app_aenderung(v_contract_changes, 'Betriebsrat', v_old.is_betriebsrat::text, v_role->>'is_betriebsrat'); end if;
|
||||||
|
if v_role ? 'has_dienstwagen' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen', v_old.has_dienstwagen::text, v_role->>'has_dienstwagen'); end if;
|
||||||
|
if v_role ? 'is_laterale_fuehrung' then v_contract_changes := app_aenderung(v_contract_changes, 'Laterale Führung', v_old.is_laterale_fuehrung::text, v_role->>'is_laterale_fuehrung'); end if;
|
||||||
|
if v_role ? 'is_c_level' then v_contract_changes := app_aenderung(v_contract_changes, 'C-Level', v_old.is_c_level::text, v_role->>'is_c_level'); end if;
|
||||||
|
if v_role ? 'has_kuendigungsschutz' then v_contract_changes := app_aenderung(v_contract_changes, 'Besonderer Kündigungsschutz', v_old.has_kuendigungsschutz::text, v_role->>'has_kuendigungsschutz'); end if;
|
||||||
|
if v_role ? 'kuendigungsschutz_bis' then v_contract_changes := app_aenderung(v_contract_changes, 'Kündigungsschutz bis', v_old.kuendigungsschutz_bis::text, (nullif(v_role->>'kuendigungsschutz_bis','')::date)::text); end if;
|
||||||
|
if v_role ? 'dienstwagen_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen Antrieb', v_old.dienstwagen_art, nullif(v_role->>'dienstwagen_art', '')); end if;
|
||||||
|
|
||||||
|
if v_immediate then
|
||||||
|
update employees set
|
||||||
|
first_name = coalesce(v_person->>'first_name', first_name),
|
||||||
|
last_name = coalesce(v_person->>'last_name', last_name),
|
||||||
|
gender = coalesce((v_person->>'gender')::gender_type, gender),
|
||||||
|
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
|
||||||
|
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
|
||||||
|
nationality = coalesce(v_person->>'nationality', nationality),
|
||||||
|
address = coalesce(v_person->>'address', address),
|
||||||
|
postal_code = coalesce(v_person->>'postal_code', postal_code),
|
||||||
|
city = coalesce(v_person->>'city', city),
|
||||||
|
address_country = coalesce(v_person->>'address_country', address_country),
|
||||||
|
email = coalesce(v_person->>'email', email),
|
||||||
|
phone = coalesce(v_person->>'phone', phone),
|
||||||
|
emergency_contact_name = case when v_person ? 'emergency_contact_name' then nullif(v_person->>'emergency_contact_name', '') else emergency_contact_name end,
|
||||||
|
emergency_contact_phone = case when v_person ? 'emergency_contact_phone' then nullif(v_person->>'emergency_contact_phone', '') else emergency_contact_phone end,
|
||||||
|
emergency_contact_relation = case when v_person ? 'emergency_contact_relation' then nullif(v_person->>'emergency_contact_relation', '') else emergency_contact_relation end,
|
||||||
|
title_prefix = case when v_person ? 'title_prefix' then v_new_title_prefix else title_prefix end,
|
||||||
|
title_suffix = case when v_person ? 'title_suffix' then v_new_title_suffix else title_suffix end,
|
||||||
|
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
|
||||||
|
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
|
||||||
|
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
|
||||||
|
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end,
|
||||||
|
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
|
||||||
|
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
|
||||||
|
work_days = case when v_role ? 'work_days' then v_new_work_days else work_days end,
|
||||||
|
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
|
||||||
|
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
|
||||||
|
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
|
||||||
|
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
|
||||||
|
has_kuendigungsschutz = coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz),
|
||||||
|
-- Fällt der Schutz weg, fällt das Datum mit. Andernfalls bliebe ein
|
||||||
|
-- Enddatum ohne Schutz stehen — die Bedingung verbietet das, und der
|
||||||
|
-- Vorgang schlüge fehl, statt das Offensichtliche zu tun.
|
||||||
|
kuendigungsschutz_bis = case
|
||||||
|
when coalesce((v_role->>'has_kuendigungsschutz')::boolean, has_kuendigungsschutz) then
|
||||||
|
case when v_role ? 'kuendigungsschutz_bis'
|
||||||
|
then nullif(v_role->>'kuendigungsschutz_bis','')::date
|
||||||
|
else kuendigungsschutz_bis end
|
||||||
|
else null
|
||||||
|
end,
|
||||||
|
dienstwagen_art = case
|
||||||
|
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
|
||||||
|
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
|
||||||
|
else null
|
||||||
|
end
|
||||||
|
where id = v_employee_id;
|
||||||
|
elsif jsonb_array_length(v_person_changes) > 0 or jsonb_array_length(v_contract_changes) > 0 then
|
||||||
|
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
|
||||||
|
values (v_employee_id, 'contract_change', v_effective_date, payload)
|
||||||
|
returning id into v_pending_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if jsonb_array_length(v_person_changes) > 0 then
|
||||||
|
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
|
||||||
|
values (v_employee_id, v_effective_date, 'Stammdatenänderung',
|
||||||
|
'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes, v_pending_id);
|
||||||
|
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||||
|
values (app_current_user_id(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id,
|
||||||
|
app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if jsonb_array_length(v_contract_changes) > 0 then
|
||||||
|
insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id)
|
||||||
|
values (v_employee_id, v_effective_date, 'Vertragsänderung',
|
||||||
|
'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes, v_pending_id);
|
||||||
|
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
|
||||||
|
values (app_current_user_id(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id,
|
||||||
|
app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
|
||||||
|
end if;
|
||||||
|
end;
|
||||||
|
$function$;
|
||||||
|
|
||||||
|
|
||||||
|
-- Selbstprüfung.
|
||||||
|
do $$
|
||||||
|
declare
|
||||||
|
v_hire text := pg_get_functiondef('public.hire_employee(jsonb)'::regprocedure);
|
||||||
|
v_chg text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure);
|
||||||
|
begin
|
||||||
|
if v_hire not like '%has_kuendigungsschutz%' then
|
||||||
|
raise exception 'hire_employee nimmt den Kündigungsschutz nicht entgegen';
|
||||||
|
end if;
|
||||||
|
if v_chg not like '%Besonderer Kündigungsschutz%' then
|
||||||
|
raise exception 'change_employee_data protokolliert den Kündigungsschutz nicht';
|
||||||
|
end if;
|
||||||
|
if not (app_feld_karte() ? 'Besonderer Kündigungsschutz' and app_feld_karte() ? 'Kündigungsschutz bis') then
|
||||||
|
raise exception 'Die Feldtabelle kennt den Kündigungsschutz nicht — dann liesse sich ein Eintrag dazu nicht berichtigen';
|
||||||
|
end if;
|
||||||
|
if app_feld_karte()->'Kündigungsschutz bis'->>2 <> 'role' then
|
||||||
|
raise exception 'Die Gruppe im payload stimmt nicht';
|
||||||
|
end if;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
@@ -57,6 +57,8 @@ function mitarbeiter(teil: Partial<EmployeeRow> = {}): EmployeeRow {
|
|||||||
is_betriebsrat: false,
|
is_betriebsrat: false,
|
||||||
has_dienstwagen: false,
|
has_dienstwagen: false,
|
||||||
dienstwagen_art: null,
|
dienstwagen_art: null,
|
||||||
|
has_kuendigungsschutz: false,
|
||||||
|
kuendigungsschutz_bis: null,
|
||||||
is_laterale_fuehrung: false,
|
is_laterale_fuehrung: false,
|
||||||
is_c_level: false,
|
is_c_level: false,
|
||||||
title_prefix: [],
|
title_prefix: [],
|
||||||
@@ -85,7 +87,7 @@ describe("TerminatePanel — Nichtantritt", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("lässt das Datum bei einem gewöhnlichen Austritt frei", () => {
|
it("lässt das Datum bei einem gewöhnlichen Austritt frei", () => {
|
||||||
zeige();
|
zeige({ status: "Aktiv" });
|
||||||
const feld = screen.getByLabelText(/Austrittsdatum/);
|
const feld = screen.getByLabelText(/Austrittsdatum/);
|
||||||
expect(feld).toBeEnabled();
|
expect(feld).toBeEnabled();
|
||||||
expect(feld).toHaveValue("");
|
expect(feld).toHaveValue("");
|
||||||
@@ -104,7 +106,7 @@ describe("TerminatePanel — Nichtantritt", () => {
|
|||||||
it("schickt den Eintrittstag, auch wenn vorher ein anderes Datum dastand", async () => {
|
it("schickt den Eintrittstag, auch wenn vorher ein anderes Datum dastand", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { terminateEmployee } = await import("@/actions/employees");
|
const { terminateEmployee } = await import("@/actions/employees");
|
||||||
zeige();
|
zeige({ status: "Aktiv" });
|
||||||
|
|
||||||
// Erst ein Datum eintippen, dann auf No Show wechseln.
|
// Erst ein Datum eintippen, dann auf No Show wechseln.
|
||||||
await user.type(screen.getByLabelText(/Austrittsdatum/), "2026-12-31");
|
await user.type(screen.getByLabelText(/Austrittsdatum/), "2026-12-31");
|
||||||
@@ -118,7 +120,7 @@ describe("TerminatePanel — Nichtantritt", () => {
|
|||||||
|
|
||||||
it("blendet die Offboarding-Checkliste aus — ausgegeben wurde nie etwas", async () => {
|
it("blendet die Offboarding-Checkliste aus — ausgegeben wurde nie etwas", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
zeige();
|
zeige({ status: "Aktiv" });
|
||||||
expect(screen.getByText("Offboarding-Checkliste")).toBeVisible();
|
expect(screen.getByText("Offboarding-Checkliste")).toBeVisible();
|
||||||
await user.selectOptions(screen.getByLabelText("Beendigungsart"), "No Show");
|
await user.selectOptions(screen.getByLabelText("Beendigungsart"), "No Show");
|
||||||
expect(screen.getByText("Offboarding-Checkliste")).not.toBeVisible();
|
expect(screen.getByText("Offboarding-Checkliste")).not.toBeVisible();
|
||||||
@@ -131,3 +133,45 @@ describe("TerminatePanel — Nichtantritt", () => {
|
|||||||
expect(screen.getByText(/Planstelle wird wieder frei/)).toBeInTheDocument();
|
expect(screen.getByText(/Planstelle wird wieder frei/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Besonderer Kündigungsschutz: Betriebsrat, Mutterschutz, Karenz, begünstigte
|
||||||
|
// Behinderung, Lehre. Die Anwendung entscheidet nicht, ob eine Beendigung
|
||||||
|
// zulässig ist — sie darf es aber auch nicht verschweigen. Im Vertragsblatt
|
||||||
|
// nachzusehen ist genau der Schritt, den man unter Zeitdruck auslässt.
|
||||||
|
describe("TerminatePanel — Kündigungsschutz", () => {
|
||||||
|
it("warnt mit dem Enddatum, wenn eines erfasst ist", () => {
|
||||||
|
zeige({ has_kuendigungsschutz: true, kuendigungsschutz_bis: "2027-06-30" });
|
||||||
|
expect(screen.getByText(/Achtung: besonderer Kündigungsschutz bis 30\.06\.2027/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warnt auch ohne Enddatum, und sagt dass keines erfasst ist", () => {
|
||||||
|
zeige({ has_kuendigungsschutz: true, kuendigungsschutz_bis: null });
|
||||||
|
expect(screen.getByText(/Ende nicht erfasst/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schweigt, wo kein Schutz besteht", () => {
|
||||||
|
zeige();
|
||||||
|
expect(screen.queryByText(/besonderer Kündigungsschutz/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warnt auch bei einem Nichtantritt — der Schutz gilt ab Vertragsbeginn", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
zeige({ has_kuendigungsschutz: true, kuendigungsschutz_bis: "2027-06-30" });
|
||||||
|
await user.selectOptions(screen.getByLabelText("Beendigungsart"), "No Show");
|
||||||
|
expect(screen.getByText(/Achtung: besonderer Kündigungsschutz/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TerminatePanel — geplanter Eintritt", () => {
|
||||||
|
it("schlägt bei einem geplanten Eintritt No Show vor", () => {
|
||||||
|
zeige({ status: "Geplant" });
|
||||||
|
expect(screen.getByLabelText("Beendigungsart")).toHaveValue("No Show");
|
||||||
|
expect(screen.getByRole("heading", { name: "Nicht angetreten" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bleibt bei einer aktiven Person beim gewöhnlichen Austritt", () => {
|
||||||
|
zeige({ status: "Aktiv" });
|
||||||
|
expect(screen.getByLabelText("Beendigungsart")).toHaveValue("Einvernehmliche Auflösung");
|
||||||
|
expect(screen.getByRole("heading", { name: "Austritt" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ function mitarbeiter(teil: Partial<EmployeeRow> = {}): EmployeeRow {
|
|||||||
is_betriebsrat: false,
|
is_betriebsrat: false,
|
||||||
has_dienstwagen: false,
|
has_dienstwagen: false,
|
||||||
dienstwagen_art: null,
|
dienstwagen_art: null,
|
||||||
|
has_kuendigungsschutz: false,
|
||||||
|
kuendigungsschutz_bis: null,
|
||||||
is_laterale_fuehrung: false,
|
is_laterale_fuehrung: false,
|
||||||
is_c_level: false,
|
is_c_level: false,
|
||||||
title_prefix: [],
|
title_prefix: [],
|
||||||
@@ -102,3 +104,22 @@ describe("VertragTab — Merkmale", () => {
|
|||||||
expect(wertZu("Laterale Führung")).toBe("Nein");
|
expect(wertZu("Laterale Führung")).toBe("Nein");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("VertragTab — Kündigungsschutz", () => {
|
||||||
|
it("nennt das Enddatum statt eines blossen Ja", () => {
|
||||||
|
// „Ja" allein liesse offen, ob der Schutz noch läuft — und genau danach
|
||||||
|
// fragt jemand, der auf dieses Blatt schaut.
|
||||||
|
render(<VertragTab employee={mitarbeiter({ has_kuendigungsschutz: true, kuendigungsschutz_bis: "2027-06-30" })} />);
|
||||||
|
expect(wertZu("Besonderer Kündigungsschutz")).toBe("bis 30.06.2027");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sagt es auch, wenn das Ende offen ist", () => {
|
||||||
|
render(<VertragTab employee={mitarbeiter({ has_kuendigungsschutz: true, kuendigungsschutz_bis: null })} />);
|
||||||
|
expect(wertZu("Besonderer Kündigungsschutz")).toBe("Ja (Ende offen)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("steht ohne Schutz auf Nein", () => {
|
||||||
|
render(<VertragTab employee={mitarbeiter()} />);
|
||||||
|
expect(wertZu("Besonderer Kündigungsschutz")).toBe("Nein");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user