Files
alpenwerk-hr/components/employees/panels/DatenAendernPanel.tsx
Andrei Laas c25c16e372 Kuendigungsschutz bekommt einen Personenkreis, die Behinderung eigene Felder
Anforderungen 5 und 5a aus dem Workshop. Migration 20260915120000.

Bisher gab es ein Kennzeichen und ein Enddatum. Das beantwortet "darf hier
ohne Weiteres beendet werden?" — nicht aber, *warum* jemand geschuetzt ist,
und davon haengt ab, wer zustimmen muss. Nachzusehen war das nur im
Papierakt, also dort, wo unter Zeitdruck niemand nachsieht.

Zwoelf Personenkreise als CHECK auf text und nicht als Aufzaehlungstyp: die
Liste ist Rechtslage und aendert sich mit dem Gesetz, ein Typ liesse einen
zurueckgenommenen Wert fuer immer stehen. Die Oberflaeche liest dieselbe
Liste aus lib/kuendigungsschutz.ts; ein Test liest die Migration und haelt
beide gegeneinander, damit die begruendete Doppelung keine stille wird.

Die begueenstigte Behinderung bekommt Kennzeichen, Grad, Beginn und Ende —
vier Spalten und nicht eine zusammengesetzte, weil in Excel danach
gefiltert und summiert wird. Datenbankseitig sind sie *nicht* an den
Personenkreis gekettet: eine solche Bedingung scheiterte genau dann, wenn
jemand den Kreis korrigiert und der Grad noch dransteht. Die Oberflaeche
stellt den Zusammenhang her.

Dazu zwei Dinge, die auf dem Weg auffielen:

  * Der Nachtlauf wendete bei einer auf spaeter datierten Aenderung nur
    Person und Vertrag an — die ganze Gruppe "role" fiel weg. Betriebsrat,
    Dienstwagen, Kollektivvertrag, Arbeitstage, Teilzeit und
    Kuendigungsschutz wurden erfasst, in der Historie vermerkt, protokolliert
    und am Stichtag nicht geschrieben. Sichtbar wurde das nie. Die neuen
    Felder haetten den Fehler geerbt; er ist jetzt fuer alle behoben.
  * Der Mitarbeiter-Export filterte ohne Stichtag ueber die Spalte `status`,
    mit Stichtag ueber die Ableitung. Der Export nach "Ausgetreten" liess
    damit genau die Leute aus, die gerade ausgetreten sind.
2026-09-15 22:47:22 +02:00

394 lines
18 KiB
TypeScript

"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { changeEmployeeData } from "@/actions/employees";
import { AngehoerigeSection } from "@/components/employees/AngehoerigeSection";
import { RoleEmploymentFields, type RoleEmploymentValue } from "@/components/employees/RoleEmploymentFields";
import { SvNummerField } from "@/components/employees/SvNummerField";
import { TitleFields, type TitleValue } from "@/components/employees/TitleFields";
import { Button } from "@/components/ui/Button";
import { CountryPicker } from "@/components/ui/CountryPicker";
import { Field, SelectField, TextField } from "@/components/ui/Field";
import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast";
import { brauchtAufenthaltstitel, UN_COUNTRIES } from "@/lib/countries";
import { STUNDEN_GRUENDE } from "@/lib/absence";
import { GRUND_BEGUENSTIGT_BEHINDERT } from "@/lib/kuendigungsschutz";
import { fmtFullName, todayIso } from "@/lib/format";
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
import { EMERGENCY_RELATIONS, type ContractType, type Database, type EmploymentType, type GenderType } from "@/lib/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
export function DatenAendernPanel({
open,
onClose,
employee,
dependents,
locationCountry,
}: {
open: boolean;
onClose: () => void;
employee: EmployeeRow;
dependents: Dependent[];
locationCountry: string | null | undefined;
}) {
const { showToast } = useToast();
const router = useRouter();
const [pending, setPending] = useState(false);
// The DB trigger would reject a bad number anyway; catching it here keeps
// the whole effective-dated change from being thrown away on submit.
const [effectiveDate, setEffectiveDate] = useState(todayIso);
const [firstName, setFirstName] = useState(employee.first_name);
const [lastName, setLastName] = useState(employee.last_name);
const [titles, setTitles] = useState<TitleValue>({ titlePrefix: employee.title_prefix ?? [], titleSuffix: employee.title_suffix ?? [] });
function updateTitles(patch: Partial<TitleValue>) {
setTitles((prev) => ({ ...prev, ...patch }));
}
const [gender, setGender] = useState<GenderType>(employee.gender);
const [birthDate, setBirthDate] = useState(employee.birth_date);
const [svNummer, setSvNummer] = useState(employee.sv_nummer ?? "");
const svNummerOk =
!svNummer.trim() || !requiresAustrianSvnr(locationCountry) || isValidSvnr(svNummer, birthDate || null);
const [nationality, setNationality] = useState(employee.nationality);
const [hatTitel, setHatTitel] = useState(employee.hat_aufenthaltstitel ?? false);
const [titelBis, setTitelBis] = useState(employee.aufenthaltstitel_bis ?? "");
// Die Staatsbürgerschaft entscheidet, ob die Frage überhaupt gestellt wird.
const titelNoetig = brauchtAufenthaltstitel(nationality);
const [address, setAddress] = useState(employee.address ?? "");
const [postalCode, setPostalCode] = useState(employee.postal_code ?? "");
const [city, setCity] = useState(employee.city ?? "");
const [addressCountry, setAddressCountry] = useState(employee.address_country ?? "Österreich");
const [email, setEmail] = useState(employee.email ?? "");
const [phone, setPhone] = useState(employee.phone ?? "");
const [notfallName, setNotfallName] = useState(employee.emergency_contact_name ?? "");
const [notfallTelefon, setNotfallTelefon] = useState(employee.emergency_contact_phone ?? "");
const [notfallVerhaeltnis, setNotfallVerhaeltnis] = useState(employee.emergency_contact_relation ?? "");
const [employmentType, setEmploymentType] = useState<EmploymentType>(employee.employment_type);
const [weeklyHours, setWeeklyHours] = useState(String(employee.weekly_hours));
// Der Grund wird erst gefragt, wenn sich die Stunden tatsächlich ändern —
// sonst stünde bei jeder Adressänderung eine Frage im Weg, die niemand
// gestellt hat.
const [stundenGrund, setStundenGrund] = useState<string>(STUNDEN_GRUENDE[0]);
const stundenGeaendert = Number(weeklyHours) !== Number(employee.weekly_hours);
const [teilzeitBis, setTeilzeitBis] = useState(employee.teilzeit_bis ?? "");
const [contractType, setContractType] = useState<ContractType>(employee.contract_type);
const [contractEndDate, setContractEndDate] = useState(employee.contract_end_date ?? "");
const [role, setRole] = useState<RoleEmploymentValue>({
workerType: employee.worker_type ?? "Angestellte:r",
collectiveAgreement: employee.collective_agreement ?? "Handel",
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 ?? "",
kuendigungsschutzGrund: employee.kuendigungsschutz_grund ?? "",
kuendigungsschutzAb: employee.kuendigungsschutz_ab ?? "",
istBeguenstigtBehindert: employee.ist_beguenstigt_behindert ?? false,
// Als Text und nicht als Zahl: ein leeres Zahlenfeld wäre 0, und 0
// bedeutet hier „kein Bescheid", nicht „Grad null".
behinderungGrad: employee.behinderung_grad === null ? "" : String(employee.behinderung_grad),
behinderungAb: employee.behinderung_ab ?? "",
behinderungBis: employee.behinderung_bis ?? "",
dienstwagenArt: employee.dienstwagen_art ?? "Verbrenner",
isLateraleFuehrung: employee.is_laterale_fuehrung ?? false,
isCLevel: employee.is_c_level ?? false,
});
function updateRole(patch: Partial<RoleEmploymentValue>) {
setRole((prev) => ({ ...prev, ...patch }));
}
// Dieselbe Regel wie in RoleEmploymentFields: der Personenkreis zieht das
// Kennzeichen mit. Hier noch einmal, weil nicht das Formular speichert,
// sondern diese Seite — und was gezeigt wird, muss auch abgeschickt werden.
const istBehindert = role.istBeguenstigtBehindert || role.kuendigungsschutzGrund === GRUND_BEGUENSTIGT_BEHINDERT;
function handleEmploymentTypeChange(value: EmploymentType) {
setEmploymentType(value);
if (value === "Vollzeit") setWeeklyHours("38.5");
}
async function handleSubmit() {
if (employmentType === "Teilzeit" && (Number(weeklyHours) <= 0 || Number(weeklyHours) >= 38.5)) {
showToast("Wochenstunden bei Teilzeit müssen zwischen 0 und 38,5 liegen.", "error");
return;
}
if (contractType === "befristet" && !contractEndDate) {
showToast("Bei befristetem Vertrag ist ein Enddatum erforderlich.", "error");
return;
}
if (role.workDays.length === 0) {
showToast("Mindestens ein Arbeitstag muss ausgewählt sein.", "error");
return;
}
if (!effectiveDate) {
showToast("Bitte ein Wirksam-ab-Datum angeben.", "error");
return;
}
// Name und Nummer gehören zusammen — die Datenbank weist eines ohne das
// andere ab, und die Meldung dort erklärt es nicht.
if (Boolean(notfallName.trim()) !== Boolean(notfallTelefon.trim())) {
showToast("Beim Notfallkontakt braucht es Name und Telefonnummer — oder beides leer.", "error");
return;
}
setPending(true);
const result = await changeEmployeeData({
employee_id: employee.id,
effective_date: effectiveDate,
person: {
first_name: firstName,
last_name: lastName,
title_prefix: titles.titlePrefix,
title_suffix: titles.titleSuffix,
gender,
birth_date: birthDate,
sv_nummer: svNummer,
nationality,
// Wechselt die Staatsbürgerschaft in den Freizügigkeitsraum, fällt
// der Titel weg — sonst bliebe er als Rest an einer Person hängen,
// die ihn nicht mehr braucht. Die Datenbank prüft diese Kopplung
// bewusst nicht (siehe Migration), also gehört sie hierher.
hat_aufenthaltstitel: titelNoetig ? hatTitel : false,
aufenthaltstitel_bis: titelNoetig && hatTitel ? titelBis : "",
address,
postal_code: postalCode,
city,
address_country: addressCountry,
email,
phone,
emergency_contact_name: notfallName.trim(),
emergency_contact_phone: notfallTelefon.trim(),
emergency_contact_relation: notfallVerhaeltnis.trim(),
},
contract: {
employment_type: employmentType,
weekly_hours: Number(weeklyHours),
contract_type: contractType,
contract_end_date: contractType === "befristet" ? contractEndDate : "",
},
role: {
worker_type: role.workerType,
collective_agreement: role.collectiveAgreement,
work_days: role.workDays,
is_betriebsrat: role.isBetriebsrat,
has_dienstwagen: role.hasDienstwagen,
dienstwagen_art: role.hasDienstwagen ? role.dienstwagenArt : "",
has_kuendigungsschutz: role.hasKuendigungsschutz,
// Leer statt des Wertes, wenn das Kennzeichen weg ist: die Bedingung
// in der Datenbank duldet keinen Rest ohne Bezug, und die Funktion
// räumt ihn auf demselben Weg weg wie beim Dienstwagen.
kuendigungsschutz_bis: role.hasKuendigungsschutz ? role.kuendigungsschutzBis : "",
kuendigungsschutz_grund: role.hasKuendigungsschutz ? role.kuendigungsschutzGrund : "",
kuendigungsschutz_ab: role.hasKuendigungsschutz ? role.kuendigungsschutzAb : "",
ist_beguenstigt_behindert: istBehindert,
behinderung_grad: istBehindert ? role.behinderungGrad : "",
behinderung_ab: istBehindert ? role.behinderungAb : "",
behinderung_bis: istBehindert ? role.behinderungBis : "",
// Die Teilzeitvariante wird nur mitgeschickt, wenn sich die Stunden
// tatsächlich ändern. Sonst schriebe jede Adressänderung den Wert
// erneut — und setzte ihn bei „Vertragliche Stundenänderung" sogar
// zurück, obwohl niemand die Stunden angefasst hat.
...(stundenGeaendert
? {
teilzeit_art: stundenGrund === "Vertragliche Stundenänderung" ? "" : stundenGrund,
teilzeit_bis: stundenGrund === "Vertragliche Stundenänderung" ? "" : teilzeitBis,
}
: {}),
is_laterale_fuehrung: role.isLateraleFuehrung,
is_c_level: role.isCLevel,
},
});
setPending(false);
if (result.success) {
showToast("Daten aktualisiert.");
router.refresh();
onClose();
} else {
showToast(result.error ?? "Fehler beim Speichern.", "error");
}
}
return (
<SlideOver
open={open}
onClose={onClose}
title="Daten ändern"
subtitle={`${fmtFullName(employee.first_name, employee.last_name, employee.title_prefix, employee.title_suffix)} · ${employee.job_title}`}
footer={
<>
<Button variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button
onClick={handleSubmit}
pending={pending}
disabled={!svNummerOk}
title={svNummerOk ? undefined : "Die SV-Nummer ist ungültig."}
>
Speichern
</Button>
</>
}
>
<div className="flex flex-col gap-6">
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
<div>
<h3 className="mb-3 text-sm font-bold text-ink">Person</h3>
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField label="Vorname" dense value={firstName} onChange={setFirstName} />
<TextField label="Nachname" dense value={lastName} onChange={setLastName} />
</div>
<TitleFields value={titles} onChange={updateTitles} />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<SelectField
label="Geschlecht"
dense
value={gender}
onChange={(v) => setGender(v as GenderType)}
options={[
{ value: "m", label: "männlich" },
{ value: "w", label: "weiblich" },
]}
/>
<TextField label="Geburtsdatum" dense type="date" value={birthDate} onChange={setBirthDate} />
</div>
<SvNummerField value={svNummer} onChange={setSvNummer} locationCountry={locationCountry} birthDate={birthDate || null} dense />
<Field label="Staatsbürgerschaft" dense>
{(p) => (
<CountryPicker {...p} value={nationality} onChange={setNationality} countries={UN_COUNTRIES} placeholder="Staatsbürgerschaft suchen…" />
)}
</Field>
{/* Nur wo er verlangt ist. Bei Freizügigkeit — EU, EWR, Schweiz —
wäre die Frage gegenstandslos, und ein „Nein" im Formular
sähe aus wie eine Auskunft. */}
{titelNoetig && (
<>
<SelectField
label="Aufenthaltstitel"
dense
value={hatTitel ? "ja" : "nein"}
onChange={(v) => setHatTitel(v === "ja")}
options={[
{ value: "nein", label: "Nein" },
{ value: "ja", label: "Ja" },
]}
hint="Bei Staatsbürgerschaften ausserhalb von EU, EWR und Schweiz."
/>
{hatTitel && (
<TextField
label="Aufenthaltstitel gültig bis"
dense
type="date"
value={titelBis}
onChange={setTitelBis}
hint="Optional. Leer lassen, wenn unbefristet."
/>
)}
</>
)}
<TextField label="Adresse (Straße und Hausnummer)" dense value={address} onChange={setAddress} />
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-3">
<TextField label="Postleitzahl" dense inputMode="numeric" value={postalCode} onChange={setPostalCode} />
<TextField label="Ort" dense value={city} onChange={setCity} />
</div>
<Field label="Land" dense>
{(p) => <CountryPicker {...p} value={addressCountry} onChange={setAddressCountry} countries={UN_COUNTRIES} placeholder="Land suchen…" />}
</Field>
<TextField label="E-Mail" dense type="email" value={email} onChange={setEmail} />
<TextField label="Telefon" dense type="tel" value={phone} onChange={setPhone} />
</div>
{/* Eigener Block: im Ernstfall greift jemand danach, und dann darf
er nicht zwischen den Adressfeldern der Person untergehen. */}
<fieldset className="mt-4 rounded-md border border-border-subtle p-3">
<legend className="px-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">Notfallkontakt</legend>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField label="Name" dense value={notfallName} onChange={setNotfallName} />
<TextField label="Telefon" dense type="tel" value={notfallTelefon} onChange={setNotfallTelefon} />
<SelectField
label="Verhältnis"
dense
value={notfallVerhaeltnis}
onChange={setNotfallVerhaeltnis}
placeholder="Bitte wählen…"
options={EMERGENCY_RELATIONS.map((r) => ({ value: r, label: r }))}
/>
</div>
</fieldset>
</div>
<div>
<h3 className="mb-3 text-sm font-bold text-ink">Vertrag</h3>
<div className="flex flex-col gap-3">
<SelectField
label="Beschäftigungsausmaß"
dense
value={employmentType}
onChange={(v) => handleEmploymentTypeChange(v as EmploymentType)}
options={[
{ value: "Vollzeit", label: "Vollzeit" },
{ value: "Teilzeit", label: "Teilzeit" },
]}
/>
<TextField
label="Wochenstunden"
dense
type="number"
step="0.5"
value={weeklyHours}
disabled={employmentType === "Vollzeit"}
onChange={setWeeklyHours}
/>
{stundenGeaendert && (
<SelectField
label="Grund der Stundenänderung"
dense
value={stundenGrund}
onChange={setStundenGrund}
options={STUNDEN_GRUENDE.map((g) => ({ value: g, label: g }))}
hint="Steht danach am Profil und lässt sich auswerten."
/>
)}
{stundenGeaendert && stundenGrund !== "Vertragliche Stundenänderung" && (
<TextField
label="Teilzeit bis"
dense
type="date"
value={teilzeitBis}
onChange={setTeilzeitBis}
hint="Optional. Leer lassen, solange das Ende nicht feststeht."
/>
)}
<SelectField
label="Vertragsart"
dense
value={contractType}
onChange={(v) => setContractType(v as ContractType)}
options={[
{ value: "unbefristet", label: "unbefristet" },
{ value: "befristet", label: "befristet" },
]}
/>
{contractType === "befristet" && (
<TextField label="Befristet bis" required dense type="date" value={contractEndDate} onChange={setContractEndDate} />
)}
</div>
</div>
<div>
<h3 className="mb-3 text-sm font-bold text-ink">Rolle & Anstellung</h3>
<RoleEmploymentFields value={role} onChange={updateRole} />
</div>
<AngehoerigeSection employeeId={employee.id} dependents={dependents} effectiveDate={effectiveDate} />
</div>
</SlideOver>
);
}