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>
This commit is contained in:
@@ -104,6 +104,22 @@ export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersPr
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* „Wer hat einen Dienstwagen" ist eine wiederkehrende Frage — als
|
||||
Filter beantwortet sie sich mit einem Klick, statt über einen
|
||||
Export mit Nachbearbeitung. E-KFZ getrennt, weil das die Frage
|
||||
hinter der Frage ist. */}
|
||||
<select
|
||||
aria-label="Nach Dienstwagen filtern"
|
||||
defaultValue={searchParams.get("dienstwagen") ?? ""}
|
||||
onChange={(e) => updateParam("dienstwagen", e.target.value)}
|
||||
className={FILTER_SELECT_CLASS}
|
||||
>
|
||||
<option value="">Dienstwagen: alle</option>
|
||||
<option value="ja">Mit Dienstwagen</option>
|
||||
<option value="elektro">Nur E-KFZ</option>
|
||||
<option value="verbrenner">Nur Verbrenner</option>
|
||||
<option value="nein">Ohne Dienstwagen</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SelectField } from "@/components/ui/Field";
|
||||
import type { CollectiveAgreement, 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"];
|
||||
|
||||
@@ -11,6 +11,15 @@ export type RoleEmploymentValue = {
|
||||
workDays: Weekday[];
|
||||
isBetriebsrat: boolean;
|
||||
hasDienstwagen: boolean;
|
||||
/**
|
||||
* Nur bedeutsam, solange hasDienstwagen gesetzt ist.
|
||||
*
|
||||
* Der Wert bleibt beim Abwählen stehen, statt zurückgesetzt zu werden —
|
||||
* wer versehentlich klickt und zurückklickt, findet seine Angabe wieder.
|
||||
* Beim Speichern setzen die Aufrufer ihn auf null, wie es der CHECK
|
||||
* verlangt.
|
||||
*/
|
||||
dienstwagenArt: DienstwagenArt;
|
||||
isLateraleFuehrung: boolean;
|
||||
isCLevel: boolean;
|
||||
};
|
||||
@@ -78,6 +87,21 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen
|
||||
<input type="checkbox" checked={value.hasDienstwagen} onChange={(e) => onChange({ hasDienstwagen: e.target.checked })} />
|
||||
Dienstwagen
|
||||
</label>
|
||||
{/* Nur sichtbar, wenn es einen gibt: eine Antriebsart ohne Fahrzeug
|
||||
ist keine Angabe, sondern eine Frage ohne Gegenstand — und die
|
||||
Datenbank weist sie ab. */}
|
||||
{value.hasDienstwagen && (
|
||||
<SelectField
|
||||
label="Antriebsart"
|
||||
dense
|
||||
value={value.dienstwagenArt}
|
||||
onChange={(v) => onChange({ dienstwagenArt: v as DienstwagenArt })}
|
||||
options={[
|
||||
{ value: "Verbrenner", label: "Verbrenner" },
|
||||
{ value: "Elektro", label: "Elektro (E-KFZ)" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm text-ink-body">
|
||||
<input type="checkbox" checked={value.isLateraleFuehrung} onChange={(e) => onChange({ isLateraleFuehrung: e.target.checked })} />
|
||||
Laterale Führung
|
||||
|
||||
@@ -58,6 +58,9 @@ export function DatenAendernPanel({
|
||||
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));
|
||||
@@ -70,6 +73,7 @@ export function DatenAendernPanel({
|
||||
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,
|
||||
});
|
||||
@@ -99,6 +103,12 @@ export function DatenAendernPanel({
|
||||
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,
|
||||
@@ -118,6 +128,9 @@ export function DatenAendernPanel({
|
||||
address_country: addressCountry,
|
||||
email,
|
||||
phone,
|
||||
emergency_contact_name: notfallName.trim(),
|
||||
emergency_contact_phone: notfallTelefon.trim(),
|
||||
emergency_contact_relation: notfallVerhaeltnis.trim(),
|
||||
},
|
||||
contract: {
|
||||
employment_type: employmentType,
|
||||
@@ -131,6 +144,7 @@ export function DatenAendernPanel({
|
||||
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,
|
||||
},
|
||||
@@ -208,6 +222,23 @@ export function DatenAendernPanel({
|
||||
<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>
|
||||
|
||||
@@ -27,6 +27,21 @@ export function StammdatenTab({ employee, location, dependents }: { employee: Em
|
||||
["Adresse", formatAddress(employee)],
|
||||
["Land", employee.address_country ?? "–"],
|
||||
["Geschlecht", employee.gender === "m" ? "männlich" : "weiblich"],
|
||||
// Der Notfallkontakt steht als eigene Zeile und nicht in einer Zeile mit
|
||||
// dem Telefon der Person: im Ernstfall greift jemand danach, und dann
|
||||
// darf keine Verwechslung möglich sein.
|
||||
[
|
||||
"Notfallkontakt",
|
||||
employee.emergency_contact_name
|
||||
? [
|
||||
employee.emergency_contact_name,
|
||||
employee.emergency_contact_relation ? `(${employee.emergency_contact_relation})` : "",
|
||||
employee.emergency_contact_phone,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
: "–",
|
||||
],
|
||||
];
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
@@ -61,8 +61,18 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
// E-Mail gehört zu den Pflichtfeldern, weil die Spalte NOT NULL ist. Ohne
|
||||
// die Prüfung hier bricht erst die Datenbank ab — am Ende des vierten
|
||||
// Schritts, nach allen Eingaben.
|
||||
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId && draft.email.trim()) &&
|
||||
svNummerOk,
|
||||
Boolean(
|
||||
draft.personnelNumber.trim() &&
|
||||
draft.firstName &&
|
||||
draft.lastName &&
|
||||
draft.birthDate &&
|
||||
draft.locationId &&
|
||||
draft.email.trim()
|
||||
) &&
|
||||
svNummerOk &&
|
||||
// Name und Nummer des Notfallkontakts gehören zusammen — die Datenbank
|
||||
// weist eines ohne das andere ab (chk_emergency_contact).
|
||||
Boolean(draft.emergencyContactName.trim()) === Boolean(draft.emergencyContactPhone.trim()),
|
||||
Boolean(draft.positionId && draft.besetzung),
|
||||
Boolean(draft.entryDate && draft.workDays.length > 0),
|
||||
true,
|
||||
@@ -83,6 +93,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
if (!selectedPosition || !draft.besetzung) return;
|
||||
setSubmitting(true);
|
||||
const result = await hireEmployee({
|
||||
personnel_number: Number(draft.personnelNumber),
|
||||
first_name: draft.firstName,
|
||||
last_name: draft.lastName,
|
||||
title_prefix: draft.titlePrefix,
|
||||
@@ -106,6 +117,12 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
work_days: draft.workDays,
|
||||
is_betriebsrat: draft.isBetriebsrat,
|
||||
has_dienstwagen: draft.hasDienstwagen,
|
||||
// Null, sobald kein Dienstwagen da ist — der CHECK lässt die Angabe
|
||||
// sonst nicht zu.
|
||||
dienstwagen_art: draft.hasDienstwagen ? draft.dienstwagenArt : null,
|
||||
emergency_contact_name: draft.emergencyContactName.trim() || undefined,
|
||||
emergency_contact_phone: draft.emergencyContactPhone.trim() || undefined,
|
||||
emergency_contact_relation: draft.emergencyContactRelation.trim() || undefined,
|
||||
is_laterale_fuehrung: draft.isLateraleFuehrung,
|
||||
is_c_level: draft.isCLevel,
|
||||
});
|
||||
|
||||
@@ -12,6 +12,14 @@ type StepPersonProps = {
|
||||
export function StepPerson({ draft, update, locations }: StepPersonProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<TextField
|
||||
label="Personalnummer"
|
||||
required
|
||||
inputMode="numeric"
|
||||
value={draft.personnelNumber}
|
||||
onChange={(personnelNumber) => update({ personnelNumber: personnelNumber.replace(/\D/g, "") })}
|
||||
hint="Muss mit Loga und Interflex übereinstimmen. Wird nicht automatisch vergeben."
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<TextField label="Vorname" required value={draft.firstName} onChange={(firstName) => update({ firstName })} />
|
||||
<TextField label="Nachname" required value={draft.lastName} onChange={(lastName) => update({ lastName })} />
|
||||
@@ -40,6 +48,34 @@ export function StepPerson({ draft, update, locations }: StepPersonProps) {
|
||||
<TextField label="E-Mail" required type="email" value={draft.email} onChange={(email) => update({ email })} />
|
||||
<TextField label="Telefon" type="tel" value={draft.phone} onChange={(phone) => update({ phone })} />
|
||||
</div>
|
||||
{/* Daten einer dritten Person — nur für den Notfall erhoben. Name und
|
||||
Nummer gehören zusammen: eines allein nützt im Ernstfall nichts,
|
||||
und die Datenbank weist es ab. */}
|
||||
<fieldset className="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="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<TextField
|
||||
label="Name"
|
||||
value={draft.emergencyContactName}
|
||||
onChange={(emergencyContactName) => update({ emergencyContactName })}
|
||||
/>
|
||||
<TextField
|
||||
label="Telefon"
|
||||
type="tel"
|
||||
value={draft.emergencyContactPhone}
|
||||
onChange={(emergencyContactPhone) => update({ emergencyContactPhone })}
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
label="Verhältnis"
|
||||
value={draft.emergencyContactRelation}
|
||||
onChange={(emergencyContactRelation) => update({ emergencyContactRelation })}
|
||||
hint="Zum Beispiel Gattin, Bruder, Freundin. Freitext — die Aufzählung liesse sich nicht schliessen."
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<SelectField
|
||||
label="Standort"
|
||||
required
|
||||
|
||||
@@ -21,17 +21,23 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
|
||||
const location = locations.find((l) => l.id === draft.locationId);
|
||||
const flags = [
|
||||
draft.isBetriebsrat && "Betriebsrat",
|
||||
draft.hasDienstwagen && "Dienstwagen",
|
||||
draft.hasDienstwagen && `Dienstwagen (${draft.dienstwagenArt === "Elektro" ? "E-KFZ" : "Verbrenner"})`,
|
||||
draft.isLateraleFuehrung && "Laterale Führung",
|
||||
draft.isCLevel && "C-Level",
|
||||
].filter(Boolean);
|
||||
const notfall = [draft.emergencyContactName, draft.emergencyContactRelation && `(${draft.emergencyContactRelation})`, draft.emergencyContactPhone]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const rows: [string, string][] = [
|
||||
["Personalnummer", draft.personnelNumber || "–"],
|
||||
["Name", fmtFullName(draft.firstName, draft.lastName, draft.titlePrefix, draft.titleSuffix)],
|
||||
["Geschlecht", draft.gender === "m" ? "männlich" : "weiblich"],
|
||||
["Geburtsdatum", fmtDate(draft.birthDate)],
|
||||
["SV-Nummer", draft.svNummer || "–"],
|
||||
["E-Mail (privat)", draft.email || "–"],
|
||||
["Telefon", draft.phone || "–"],
|
||||
["Notfallkontakt", notfall || "–"],
|
||||
["Standort", location ? `${location.name} (${location.country})` : "–"],
|
||||
["Position", selectedPosition ? `${selectedPosition.title} (${selectedPosition.position_number})` : "–"],
|
||||
["Organisationseinheit", selectedPosition?.orgLabel ?? "–"],
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import type { CollectiveAgreement, ContractType, EmploymentType, GenderType, PaygradeType, Weekday, WorkerType } from "@/lib/supabase/types";
|
||||
import type { CollectiveAgreement, ContractType, DienstwagenArt, EmploymentType, GenderType, PaygradeType, Weekday, WorkerType } from "@/lib/supabase/types";
|
||||
|
||||
// The spec's hire wizard field list (§4.4) omits Geschlecht and Standort even
|
||||
// though both are NOT NULL on employees — added here (defaults keep them
|
||||
// effectively "free" for the user, same treatment as the karenz-start gap).
|
||||
export type HireDraftData = {
|
||||
/**
|
||||
* Eingabe, nicht Vergabe.
|
||||
*
|
||||
* Muss mit Loga und Interflex übereinstimmen — als Zeichenkette geführt,
|
||||
* weil ein leeres Zahlenfeld sonst als 0 im Entwurf landet.
|
||||
*/
|
||||
personnelNumber: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
titlePrefix: string[];
|
||||
@@ -27,11 +34,17 @@ export type HireDraftData = {
|
||||
workDays: Weekday[];
|
||||
isBetriebsrat: boolean;
|
||||
hasDienstwagen: boolean;
|
||||
/** Nur ausgewertet, wenn hasDienstwagen gesetzt ist — so will es der CHECK. */
|
||||
dienstwagenArt: DienstwagenArt;
|
||||
emergencyContactName: string;
|
||||
emergencyContactPhone: string;
|
||||
emergencyContactRelation: string;
|
||||
isLateraleFuehrung: boolean;
|
||||
isCLevel: boolean;
|
||||
};
|
||||
|
||||
export const EMPTY_HIRE_DRAFT: HireDraftData = {
|
||||
personnelNumber: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
titlePrefix: [],
|
||||
@@ -55,6 +68,10 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
|
||||
workDays: ["Mo", "Di", "Mi", "Do", "Fr"],
|
||||
isBetriebsrat: false,
|
||||
hasDienstwagen: false,
|
||||
dienstwagenArt: "Verbrenner",
|
||||
emergencyContactName: "",
|
||||
emergencyContactPhone: "",
|
||||
emergencyContactRelation: "",
|
||||
isLateraleFuehrung: false,
|
||||
isCLevel: false,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user