Files
alpenwerk-hr/components/employees/panels/DatenAendernPanel.tsx
Maximilian Stubhan 9d754359e0 Enter the personnel number, tell the two kinds of company car apart, record who to call
Three requests from use, one of which changes the schema's mind about
something.

The personnel number is no longer issued. It was GENERATED ALWAYS AS
IDENTITY, which refuses a supplied value outright — but it has to match Loga
and Interflex, and a number this application invents is unknown there, so the
same person ends up with two. Identity dropped, entered everywhere instead:
in the wizard, in the import, and validated against a duplicate with a
message that names the number.

Worth stating plainly: the column had no unique constraint. The identity
prevented collisions as a side effect, and once the value comes from outside
that side effect is gone. The constraint is the point now, and it was
missing.

Company cars distinguish Verbrenner from Elektro, tied to has_dienstwagen by
a CHECK so "E-KFZ" cannot appear against someone without a car. The list
filters on it — with, without, only electric, only combustion — which is the
question the report was really about; it was answerable before only through
an export and manual work.

Emergency contact is name, phone and relationship. Relationship stays free
text: the examples given — Gattin/Gatte, Schwester/Bruder, Freund — are not
a list that closes without telling someone their arrangement does not count.
Name and phone are all-or-nothing, in the database and in both forms: a name
without a number helps nobody, a number without a name does not say who
answers.

Two mistakes of mine on the way, both caught by checks I had written into
the migrations rather than by me:

  - The first CHECK on the car type would have permitted exactly the case it
    was written against. `art in (…)` yields NULL rather than false when the
    column is null, and a CHECK counts NULL as satisfied. It needs an
    explicit `is not null` in front.
  - The constraint was added before the backfill, so it rejected every
    existing row with a car.

Existing cars are recorded as Verbrenner, which is an assumption — but a
visible one: "Elektro" appears nowhere nobody confirmed it.

hire_employee and change_employee_data both had to learn the new columns.
They name their columns one by one, and what is missing there is dropped in
silence — the interface would have collected the fields and thrown them
away, which is what happened to the email address this morning.

Verified against the live database, all rolled back: a hire without a number
is refused, a duplicate is refused naming it, a freely chosen one goes
through; E-KFZ plus contact arrive intact; a contact without a phone is
refused. A change records both, with before and after in the audit detail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:27:00 +02:00

292 lines
12 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 { UN_COUNTRIES } from "@/lib/countries";
import { fmtFullName, todayIso } from "@/lib/format";
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
import type { ContractType, Database, EmploymentType, GenderType } from "@/lib/supabase/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 [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));
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,
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 }));
}
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,
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 : "",
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>
<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} />
<TextField
label="Verhältnis"
dense
value={notfallVerhaeltnis}
onChange={setNotfallVerhaeltnis}
hint="Zum Beispiel Gattin, Bruder, Freundin."
/>
</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}
/>
<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>
);
}