Ask for the optional things separately, and stop claiming numbers are issued

A round of interface corrections from use, plus one schema change behind
them.

The private email address is now optional. It was NOT NULL — the wrong
default for a private detail: someone without one had to invent one, and
invented data in a personnel file is worse than missing data. Both fields
are relabelled to say whose they are, "Private E-Mail" and "Private
Telefonnummer", because the company address does not exist until the person
starts. Uniqueness stays; several NULLs coexist in a Postgres unique index,
which is exactly what is wanted.

The summary step still promised that "Personalnummer und
Firmen-E-Mail-Adresse werden automatisch vergeben". Neither is true any
more. Removed rather than reworded — the step lists what was entered, and a
banner claiming otherwise is worse than no banner.

Dependents move into the wizard as step three, optional. They can only be
attached after the hire, because add_employee_dependent needs an id that
does not exist while the form is open, so they are collected in the draft
and written afterwards. That puts them outside the transaction the person is
created in: if one fails the person still exists, so the message names who
is missing instead of failing silently, and the SV number is checked in the
step rather than after.

The emergency contact gets its own step, second to last, and its
relationship is a dropdown of the common ones rather than free text —
otherwise "Gattin", "Ehefrau" and "Frau" end up side by side and nothing can
be counted. "Sonstige" is there because a closed list would otherwise be
presumptuous.

On the master-data tab it now sits below the dependents rather than above:
both are people around the employee, and this is the one you reach for in a
hurry.

Returning from a long absence: the choice read "unverändert", which made you
open the file to find out what you were agreeing to. It now reads "Wie vor
Abwesenheit (38,5 h)" with the hours actually worked, and the alternative is
"Reduziert" — whose hours field starts empty on purpose. A number already
filled in gets confirmed rather than read off the agreement it comes from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 21:47:04 +02:00
parent 4eba557121
commit c23df08648
17 changed files with 387 additions and 96 deletions

View File

@@ -2,20 +2,22 @@
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { hireEmployee } from "@/actions/employees";
import { addEmployeeDependent, hireEmployee } from "@/actions/employees";
import { deleteHireDraft, saveHireDraft } from "@/actions/hireDrafts";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import type { OpenPositionResolved } from "@/lib/positions";
import { isValidSvnr, requiresAustrianSvnr } from "@/lib/svnr";
import { StepAngehoerige } from "./StepAngehoerige";
import { StepNotfallkontakt } from "./StepNotfallkontakt";
import { StepPerson } from "./StepPerson";
import { StepPosition } from "./StepPosition";
import { StepSummary } from "./StepSummary";
import { StepVertrag } from "./StepVertrag";
import { EMPTY_HIRE_DRAFT, type HireDraftData } from "./types";
const STEP_LABELS = ["Person", "Position", "Vertrag", "Zusammenfassung"];
const STEP_LABELS = ["Person", "Position", "Angehörige", "Vertrag", "Notfallkontakt", "Zusammenfassung"];
type HireWizardProps = {
open: boolean;
@@ -61,23 +63,31 @@ 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.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()),
// Die private E-Mail-Adresse steht bewusst nicht mehr darunter: sie ist
// freiwillig, seit die Spalte NULL zulässt.
Boolean(draft.personnelNumber.trim() && draft.firstName && draft.lastName && draft.birthDate && draft.locationId) &&
svNummerOk,
Boolean(draft.positionId && draft.besetzung),
// Angehörige: freiwillig — aber eine begonnene Zeile muss vollständig
// sein, sonst scheitert sie erst nach dem Anlegen der Person, und die
// steht dann schon in der Datenbank.
draft.angehoerige.every(
(a) =>
a.firstName.trim() &&
a.lastName.trim() &&
a.birthDate &&
(!a.svNummer.trim() || isValidSvnr(a.svNummer, a.birthDate || null))
),
Boolean(draft.entryDate && draft.workDays.length > 0),
// Notfallkontakt: freiwillig, aber Name und Nummer nur gemeinsam — die
// Datenbank weist eines ohne das andere ab (chk_emergency_contact).
Boolean(draft.emergencyContactName.trim()) === Boolean(draft.emergencyContactPhone.trim()),
true,
][step];
/** Der letzte Schritt; von hier wird angelegt statt weitergeblättert. */
const letzterSchritt = STEP_LABELS.length - 1;
async function handleSaveDraft() {
const result = await saveHireDraft({ id: draftId, step, data: draft });
if (result.success) {
@@ -101,7 +111,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
gender: draft.gender,
birth_date: draft.birthDate,
sv_nummer: draft.svNummer || undefined,
email: draft.email.trim(),
email: draft.email.trim() || undefined,
phone: draft.phone || undefined,
position_id: draft.positionId,
location_id: draft.locationId,
@@ -126,14 +136,45 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
is_laterale_fuehrung: draft.isLateraleFuehrung,
is_c_level: draft.isCLevel,
});
setSubmitting(false);
if (result.success) {
showToast(`${draft.firstName} ${draft.lastName} wurde eingestellt.`);
if (draftId) await deleteHireDraft(draftId);
router.refresh();
onClose();
} else {
if (!result.success || !result.employeeId) {
setSubmitting(false);
showToast(result.error ?? "Fehler beim Anlegen.", "error");
return;
}
// Angehörige erst jetzt: add_employee_dependent braucht die Kennung, und
// die entsteht mit der Einstellung.
//
// Damit hängen sie ausserhalb der Transaktion, in der die Person
// entsteht. Scheitert eine, ist die Person trotzdem angelegt — deshalb
// wird nicht stillschweigend weitergemacht, sondern genau gesagt, wer
// fehlt. Nachtragen geht in der Personalakte.
const gescheitert: string[] = [];
for (const a of draft.angehoerige) {
const r = await addEmployeeDependent({
employee_id: result.employeeId,
first_name: a.firstName.trim(),
last_name: a.lastName.trim(),
relationship: a.relationship,
birth_date: a.birthDate,
sv_nummer: a.svNummer.trim() || undefined,
effective_date: draft.entryDate,
});
if (!r.success) gescheitert.push(`${a.firstName} ${a.lastName}`.trim());
}
setSubmitting(false);
if (draftId) await deleteHireDraft(draftId);
router.refresh();
onClose();
if (gescheitert.length > 0) {
showToast(
`${draft.firstName} ${draft.lastName} wurde eingestellt, aber ${gescheitert.join(", ")} konnte nicht als Angehörige:r angelegt werden — bitte in der Personalakte nachtragen.`,
"error"
);
} else {
showToast(`${draft.firstName} ${draft.lastName} wurde eingestellt.`);
}
}
@@ -159,12 +200,12 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
Zurück
</Button>
)}
{step < 3 && (
{step < letzterSchritt && (
<Button onClick={() => setStep((s) => s + 1)} disabled={!stepValid}>
Weiter
</Button>
)}
{step === 3 && (
{step === letzterSchritt && (
<Button onClick={handleSubmit} pending={submitting}>
Anlegen
</Button>
@@ -198,8 +239,10 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
{step === 0 && <StepPerson draft={draft} update={update} locations={locations} />}
{step === 1 && <StepPosition draft={draft} update={update} openPositions={openPositions} />}
{step === 2 && <StepVertrag draft={draft} update={update} />}
{step === 3 && <StepSummary draft={draft} selectedPosition={selectedPosition} locations={locations} />}
{step === 2 && <StepAngehoerige draft={draft} update={update} />}
{step === 3 && <StepVertrag draft={draft} update={update} />}
{step === 4 && <StepNotfallkontakt draft={draft} update={update} />}
{step === 5 && <StepSummary draft={draft} selectedPosition={selectedPosition} locations={locations} />}
</Modal>
);
}