Bei "Befoerdern" steht jetzt die Wahl: selbe Planstelle oder eine neue.
Bei "neu" wird sie aus den freien gewaehlt, dieselbe Auswahl wie die
Zielplanstelle bei der Versetzung, und die Stelle schlaegt ihre
Taetigkeit als neue Bezeichnung vor -- nur als Vorschlag, denn
job_title darf von der Planstelle abweichen.
Die Zielstelle muss frei sein, mit derselben Meldung wie bei der
Versetzung. Ausgenommen ist die eigene: darauf sitzt die Person schon,
und "bereits besetzt" waere eine Falschaussage ueber sie selbst.
Dabei ist ein aelterer Fehler aufgefallen. transfer_employee legt fuer
ein kuenftiges Datum einen Eintrag in pending_org_changes mit
target_position_id an. Der Nachtlauf hat die Besetzung dort zur
Umstellung auf Planstellen (20260727120200) umgehaengt; zuletzt stand in
diesem Zweig nur noch
update employees set job_title = coalesce(payload->>'new_title', ...)
also die Fassung von *vor* jener Umstellung -- und new_title kommt in
diesem payload gar nicht vor. Eine auf spaeter datierte Versetzung wurde
am Stichtag auf "applied" gesetzt und bewegte niemanden: die Person blieb
auf ihrer alten Planstelle, im Organigramm unveraendert, ohne Meldung.
Eine der Migrationen dazwischen hat die Funktion aus einer alten Vorlage
neu geschrieben -- dieselbe Falle wie bei der Gruppe `role` und bei der
Anmerkung zum Austritt.
Beide Zweige haengen die Besetzung jetzt um, und die Selbstpruefung
zaehlt nach, dass es zwei sind. Nebenbei bekommt promote_employee den
festen search_path und app_current_user_id() statt auth.uid().
414 lines
19 KiB
TypeScript
414 lines
19 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 { MITARBEITERART_STANDARD } from "@/lib/mitarbeiterart";
|
|
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 [companyEmail, setCompanyEmail] = useState(employee.company_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",
|
|
mitarbeiterart: employee.mitarbeiterart ?? MITARBEITERART_STANDARD,
|
|
// Hier gesetzt und im Assistenten nicht: dort steht die Besetzungsart im
|
|
// Schritt „Position". Ihr Wechsel von extern auf intern ist eine
|
|
// Übernahme und wird als eigenes Ereignis protokolliert.
|
|
source: employee.source ?? "Extern",
|
|
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,
|
|
// Getrimmt und als leere Zeichenkette durchgereicht: die Datenbank
|
|
// macht daraus null (nullif), damit sich eine Angabe auch wieder
|
|
// entfernen lässt. Ungetrimmt käme ein Feld, in dem nur ein
|
|
// Leerzeichen stehen geblieben ist, als Inhalt in der Spalte an.
|
|
email: email.trim(),
|
|
company_email: companyEmail.trim(),
|
|
phone: phone.trim(),
|
|
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,
|
|
mitarbeiterart: role.mitarbeiterart,
|
|
source: role.source,
|
|
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 (privat)" dense type="email" value={email} onChange={setEmail} />
|
|
<TextField label="Firmen-E-Mail" dense type="email" value={companyEmail} onChange={setCompanyEmail} />
|
|
<TextField label="Telefon" dense type="tel" value={phone} onChange={setPhone} />
|
|
</div>
|
|
|
|
</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} />
|
|
|
|
{/* Unterhalb der Angehörigen und ganz am Schluss — dieselbe Reihenfolge
|
|
wie im Reiter „Stammdaten". Aufgebaut wie „Person", „Vertrag" und
|
|
„Rolle & Anstellung" darüber: eine Überschrift, darunter die
|
|
Felder. Hier stand ein umrahmtes fieldset mit eigener
|
|
Beschriftung; das hob den Abschnitt gegenüber allen anderen
|
|
heraus, ohne dass er wichtiger wäre. */}
|
|
<div>
|
|
<h3 className="mb-3 text-sm font-bold text-ink">Notfallkontakt</h3>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</SlideOver>
|
|
);
|
|
}
|