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

@@ -30,14 +30,13 @@ export async function hireEmployee(payload: {
birth_date: string;
sv_nummer?: string;
/**
* Pflicht, weil employees.email NOT NULL ist.
* Die **private** Adresse, freiwillig.
*
* Der Assistent hat die Adresse immer erhoben und in der Zusammenfassung
* angezeigt — sie fehlte nur in dieser Signatur und wurde deshalb
* stillschweigend verworfen. Jede Einstellung scheiterte danach an der
* Spaltenbedingung.
* Sie war einmal Pflicht, weil die Spalte NOT NULL war — für eine private
* Angabe die falsche Vorgabe: wer keine hat, musste eine erfinden. Bleibt
* eindeutig, wenn angegeben.
*/
email: string;
email?: string;
phone?: string;
position_id?: string;
team_id?: string;

View File

@@ -121,8 +121,8 @@ function employeeExportColumns(
{ header: "Postleitzahl", get: (e) => e.postal_code },
{ header: "Ort", get: (e) => e.city },
{ header: "Wohnsitzland", get: (e) => e.address_country },
{ header: "E-Mail", get: (e) => e.email },
{ header: "Telefon", get: (e) => e.phone },
{ header: "Private E-Mail", get: (e) => e.email },
{ header: "Private Telefonnummer", get: (e) => e.phone },
{ header: "Bereich", get: (e) => (e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "") : "") },
{ header: "Abteilung", get: (e) => (e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "") },
{ header: "Team", get: (e) => (e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : "") },

View File

@@ -15,7 +15,7 @@ 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";
import { EMERGENCY_RELATIONS, type ContractType, type Database, type EmploymentType, type GenderType } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
@@ -56,7 +56,7 @@ export function DatenAendernPanel({
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 [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 ?? "");
@@ -230,12 +230,13 @@ export function DatenAendernPanel({
<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
<SelectField
label="Verhältnis"
dense
value={notfallVerhaeltnis}
onChange={setNotfallVerhaeltnis}
hint="Zum Beispiel Gattin, Bruder, Freundin."
placeholder="Bitte wählen…"
options={EMERGENCY_RELATIONS.map((r) => ({ value: r, label: r }))}
/>
</div>
</fieldset>

View File

@@ -33,7 +33,13 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
const [returnDate, setReturnDate] = useState("");
const [employmentMode, setEmploymentMode] = useState<EmploymentMode>("unverändert");
const [weeklyHours, setWeeklyHours] = useState("20");
// Die Stunden, die vor der Abwesenheit galten — mit Komma, wie man sie
// hierzulande schreibt.
const stundenText = String(employee.weekly_hours).replace(".", ",");
// Absichtlich leer statt vorbelegt: eine Zahl, die schon dasteht, wird
// bestätigt statt erfasst. Die reduzierten Stunden stehen in einer
// Vereinbarung, und die muss jemand ablesen.
const [weeklyHours, setWeeklyHours] = useState("");
const diffDays =
isOnKarenz && employee.karenz_return_date && newReturnDate
@@ -89,10 +95,16 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
showToast("Bitte Rückkehrdatum angeben.", "error");
return;
}
if (employmentMode === "Teilzeit" && (Number(weeklyHours) <= 0 || Number(weeklyHours) >= 38.5)) {
if (employmentMode === "Teilzeit") {
if (!weeklyHours.trim()) {
showToast("Bitte die reduzierten Wochenstunden erfassen.", "error");
return;
}
if (Number(weeklyHours) <= 0 || Number(weeklyHours) >= 38.5) {
showToast("Wochenstunden müssen zwischen 0 und 38,5 liegen.", "error");
return;
}
}
setPending(true);
const result = await recordKarenzReturn({
employee_id: employee.id,
@@ -191,20 +203,23 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
value={employmentMode}
onChange={(v) => setEmploymentMode(v as EmploymentMode)}
options={[
{ value: "unverändert", label: "unverändert" },
{ value: "Vollzeit", label: "Vollzeit (38,5h)" },
{ value: "Teilzeit", label: "Teilzeit-Elternteilzeit" },
// Die Stunden, die zuletzt gearbeitet wurden, stehen in der
// Beschriftung. „unverändert" allein zwang dazu, in der
// Akte nachzusehen, worauf man sich da einlässt.
{ value: "unverändert", label: `Wie vor Abwesenheit (${stundenText} h)` },
{ value: "Teilzeit", label: "Reduziert" },
]}
/>
{employmentMode === "Teilzeit" && (
<TextField
label="Wochenstunden"
label="Reduzierte Wochenstunden"
required
type="number"
step="0.5"
max="38"
value={weeklyHours}
onChange={setWeeklyHours}
placeholder={`weniger als ${stundenText}`}
hint="Muss unter 38,5 liegen."
/>
)}

View File

@@ -35,8 +35,8 @@ export function StammdatenTab({ employee, location, dependents }: { employee: Em
["Postleitzahl", employee.postal_code ?? "–"],
["Ort", employee.city ?? "–"],
["Land", employee.address_country ?? "–"],
["E-Mail", employee.email],
["Telefon", employee.phone ?? "–"],
["Private E-Mail", employee.email ?? "–"],
["Private Telefonnummer", employee.phone ?? "–"],
// Der Standort ist keine Angabe zur Person, sondern die Betriebsstätte —
// er steht deshalb am Ende und nicht zwischen Adresse und Land, wo man
// ihn für den Wohnort halten könnte.
@@ -53,11 +53,13 @@ export function StammdatenTab({ employee, location, dependents }: { employee: Em
))}
</dl>
{/* Eigener Abschnitt statt einer Zelle im Raster.
Der Notfallkontakt sind drei Angaben — Name, Verhältnis, Nummer —,
und in eine Zelle gepresst standen sie als eine Zeile Fliesstext da.
<AngehoerigeSection employeeId={employee.id} dependents={dependents} />
{/* Eigener Abschnitt statt einer Zelle im Raster, und unterhalb der
Angehörigen: beides sind Personen im Umfeld, und der Notfallkontakt
ist die Ausnahme davon — deshalb steht er zuletzt, nicht dazwischen.
Im Ernstfall greift jemand in Eile danach; dann muss die Nummer
sofort zu finden sein und nicht zwischen Adresse und Geschlecht. */}
sofort zu finden sein und wählbar. */}
<div className="border-t border-border pt-6">
<h3 className="mb-3 text-xs font-bold uppercase tracking-wide text-brand-700">Notfallkontakt</h3>
{employee.emergency_contact_name ? (
@@ -90,8 +92,6 @@ export function StammdatenTab({ employee, location, dependents }: { employee: Em
<p className="text-sm text-ink-muted">Kein Notfallkontakt hinterlegt.</p>
)}
</div>
<AngehoerigeSection employeeId={employee.id} dependents={dependents} />
</div>
);
}

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,
});
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 (result.success) {
showToast(`${draft.firstName} ${draft.lastName} wurde eingestellt.`);
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(result.error ?? "Fehler beim Anlegen.", "error");
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>
);
}

View File

@@ -0,0 +1,113 @@
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { SelectField, TextField } from "@/components/ui/Field";
import { fmtDate } from "@/lib/format";
import { formatSvnr, svnrErrorMessage, validateSvnr } from "@/lib/svnr";
import type { RelationshipType } from "@/lib/supabase/types";
import type { HireDraftAngehoerige, HireDraftData } from "./types";
const VERHAELTNIS: RelationshipType[] = ["Ehepartner:in", "Lebenspartner:in", "Kind", "Sonstige"];
// Angehörige im Assistenten, obwohl es die Person noch nicht gibt.
//
// Sie werden hier gesammelt und erst nach dem Anlegen angehängt — die
// Datenbankfunktion braucht eine Kennung, und die entsteht mit der
// Einstellung. Der Preis dafür steht in HireWizard: schlägt eine der
// Ergänzungen fehl, ist die Person trotzdem angelegt, und die Meldung sagt
// das dann auch.
//
// Freiwillig: die meisten Einstellungen kommen ohne aus, und wer später
// etwas nachträgt, findet denselben Dialog in der Personalakte.
export function StepAngehoerige({
draft,
update,
}: {
draft: HireDraftData;
update: (patch: Partial<HireDraftData>) => void;
}) {
const liste = draft.angehoerige;
function setze(index: number, patch: Partial<HireDraftAngehoerige>) {
update({ angehoerige: liste.map((a, i) => (i === index ? { ...a, ...patch } : a)) });
}
function hinzufuegen() {
update({
angehoerige: [...liste, { firstName: "", lastName: draft.lastName, relationship: "Kind", birthDate: "", svNummer: "" }],
});
}
return (
<div className="flex flex-col gap-4">
<p className="max-w-prose text-sm text-ink-muted">
Angehörige sind freiwillig und lassen sich jederzeit in der Personalakte nachtragen. Der Nachname ist mit dem
der einzustellenden Person vorbelegt — überschreibbar.
</p>
{liste.length === 0 ? (
<p className="text-sm text-ink-muted">Keine Angehörigen erfasst.</p>
) : (
<div className="flex flex-col gap-3">
{liste.map((a, i) => {
// Dieselbe Prüfung wie bei der Person selbst: Prüfziffer und
// Geburtsdatum müssen zusammenpassen. Hier schon, damit der
// Fehler nicht erst nach dem Anlegen auftaucht — dann existiert
// die Person bereits und die Angehörige fehlt.
const svFehler = a.svNummer.trim() ? validateSvnr(a.svNummer, a.birthDate || null) : null;
return (
<fieldset key={i} className="rounded-md border border-border p-3">
<legend className="flex items-center gap-2 px-1 text-xs font-semibold uppercase tracking-wide text-ink-muted">
{a.firstName || a.lastName ? `${a.firstName} ${a.lastName}`.trim() : `Angehörige:r ${i + 1}`}
{a.birthDate && <span className="font-normal normal-case">· {fmtDate(a.birthDate)}</span>}
</legend>
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<TextField label="Vorname" dense required value={a.firstName} onChange={(v) => setze(i, { firstName: v })} />
<TextField label="Nachname" dense required value={a.lastName} onChange={(v) => setze(i, { lastName: v })} />
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<SelectField
label="Verhältnis"
dense
required
value={a.relationship}
onChange={(v) => setze(i, { relationship: v as RelationshipType })}
options={VERHAELTNIS.map((r) => ({ value: r, label: r }))}
/>
<TextField label="Geburtsdatum" dense required type="date" value={a.birthDate} onChange={(v) => setze(i, { birthDate: v })} />
</div>
<TextField
label="SV-Nummer"
dense
value={a.svNummer}
onChange={(v) => setze(i, { svNummer: v })}
error={svFehler ? svnrErrorMessage(svFehler) : undefined}
hint={!svFehler && a.svNummer.trim() ? formatSvnr(a.svNummer) : undefined}
/>
</div>
<div className="mt-2 flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => update({ angehoerige: liste.filter((_, j) => j !== i) })}
className="!px-1 text-danger-text hover:!bg-transparent hover:underline"
>
<Trash2 className="h-3.5 w-3.5" /> Entfernen
</Button>
</div>
</fieldset>
);
})}
</div>
)}
<div>
<Button variant="secondary" size="sm" onClick={hinzufuegen}>
<Plus className="h-4 w-4" /> Angehörige:n hinzufügen
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { SelectField, TextField } from "@/components/ui/Field";
import { EMERGENCY_RELATIONS } from "@/lib/supabase/types";
import type { HireDraftData } from "./types";
// Eigener Schritt, kurz vor der Zusammenfassung.
//
// Zuerst stand das zwischen den Stammdaten — dort ging es unter, obwohl es
// die einzige Angabe im ganzen Assistenten ist, die eine dritte Person
// betrifft und im Ernstfall gebraucht wird.
//
// Die Angabe bleibt freiwillig. Wer sie macht, braucht Name und Nummer
// zusammen; das prüft der Assistent, bevor er weiterlässt, und die Datenbank
// noch einmal (chk_emergency_contact).
export function StepNotfallkontakt({
draft,
update,
}: {
draft: HireDraftData;
update: (patch: Partial<HireDraftData>) => void;
}) {
const angefangen = Boolean(draft.emergencyContactName.trim() || draft.emergencyContactPhone.trim());
const unvollstaendig = angefangen && !(draft.emergencyContactName.trim() && draft.emergencyContactPhone.trim());
return (
<div className="flex flex-col gap-4">
<p className="max-w-prose text-sm text-ink-muted">
Wen sollen wir verständigen, wenn etwas passiert? Die Angabe ist freiwillig — Name und Telefonnummer gehören
aber zusammen, eines allein hilft im Ernstfall nicht.
</p>
<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>
<SelectField
label="Verhältnis"
value={draft.emergencyContactRelation}
onChange={(emergencyContactRelation) => update({ emergencyContactRelation })}
placeholder="Bitte wählen…"
options={EMERGENCY_RELATIONS.map((r) => ({ value: r, label: r }))}
/>
{unvollstaendig && (
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-3 py-2 text-sm text-danger-text">
Name und Telefonnummer werden beide gebraucht — oder beide leer lassen.
</p>
)}
</div>
);
}

View File

@@ -45,37 +45,12 @@ export function StepPerson({ draft, update, locations }: StepPersonProps) {
birthDate={draft.birthDate || null}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<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 })} />
{/* Die private Adresse, nicht die Firmenadresse — die entsteht erst
mit dem Eintritt. Freiwillig: wer keine hat oder keine angeben
will, soll nicht gezwungen sein, eine zu erfinden. */}
<TextField label="Private E-Mail" type="email" value={draft.email} onChange={(email) => update({ email })} />
<TextField label="Private Telefonnummer" 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

View File

@@ -63,9 +63,6 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
</div>
))}
</dl>
<p className="rounded bg-info-bg px-3 py-2 text-sm text-info-text">
Personalnummer und Firmen-E-Mail-Adresse werden automatisch vergeben.
</p>
</div>
);
}

View File

@@ -1,8 +1,22 @@
import type { CollectiveAgreement, ContractType, DienstwagenArt, EmploymentType, GenderType, PaygradeType, Weekday, WorkerType } from "@/lib/supabase/types";
import type { CollectiveAgreement, ContractType, DienstwagenArt, EmploymentType, GenderType, PaygradeType, RelationshipType, 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).
/**
* Angehörige:r, wie sie im Assistenten gesammelt wird.
*
* Eigener Typ statt der Zeile aus der Datenbank: es gibt weder eine Kennung
* noch eine Person, an der sie hängt — beides entsteht erst mit dem Anlegen.
*/
export type HireDraftAngehoerige = {
firstName: string;
lastName: string;
relationship: RelationshipType;
birthDate: string;
svNummer: string;
};
export type HireDraftData = {
/**
* Eingabe, nicht Vergabe.
@@ -39,6 +53,7 @@ export type HireDraftData = {
emergencyContactName: string;
emergencyContactPhone: string;
emergencyContactRelation: string;
angehoerige: HireDraftAngehoerige[];
isLateraleFuehrung: boolean;
isCLevel: boolean;
};
@@ -72,6 +87,7 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
emergencyContactName: "",
emergencyContactPhone: "",
emergencyContactRelation: "",
angehoerige: [],
isLateraleFuehrung: false,
isCLevel: false,
};

View File

@@ -68,7 +68,9 @@ export async function bestandLaden(tx: Tx): Promise<Bestand> {
jobCodes: new Map(jobs.map((j) => [j.code, j.id])),
planstellen: new Map(stellen.map((p) => [p.position_number, { id: p.id, besetzt: besetzt.has(p.id) }])),
personalnummern: new Map(personen.map((e) => [e.personnel_number, e.id])),
emails: new Set(personen.map((e) => e.email.toLowerCase())),
// Ohne Adresse gibt es nichts zu vergleichen — seit sie freiwillig ist,
// kann sie fehlen.
emails: new Set(personen.filter((e) => e.email).map((e) => e.email!.toLowerCase())),
svNummern: new Set(personen.filter((e) => e.sv_nummer).map((e) => normalizeSvnr(e.sv_nummer!))),
};
}

View File

@@ -224,8 +224,15 @@ export const BLATT_PERSONEN: BlattSchema = {
hinweis: "Leer = Österreich.",
beispiel: "Österreich",
},
{ name: "E-Mail", ziel: "email", pflicht: true, typ: { art: "text" }, hinweis: "Eindeutig.", beispiel: "s.aigner@example.at" },
{ name: "Telefon", ziel: "phone", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "+43 660 1234567" },
{
name: "Private E-Mail",
ziel: "email",
pflicht: false,
typ: { art: "text" },
hinweis: "Freiwillig. Eindeutig, wenn angegeben.",
beispiel: "s.aigner@example.at",
},
{ name: "Private Telefonnummer", ziel: "phone", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "+43 660 1234567" },
{ name: "Adresse", ziel: "address", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Hauptstraße 1" },
{ name: "PLZ", ziel: "postal_code", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "1010" },
{ name: "Ort", ziel: "city", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Wien" },

View File

@@ -277,9 +277,9 @@ export function pruefe(blaetter: ImportSheet[], bestand: Bestand = LEERER_BESTAN
const email = s(w.email)?.toLowerCase();
if (email) {
if (emails.has(email)) melde("Personen", z.zeile, "E-Mail", "Kommt bereits vor.", email);
if (emails.has(email)) melde("Personen", z.zeile, "Private E-Mail", "Kommt bereits vor.", email);
emails.add(email);
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) melde("Personen", z.zeile, "E-Mail", "Sieht nicht wie eine Adresse aus.", email);
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) melde("Personen", z.zeile, "Private E-Mail", "Sieht nicht wie eine Adresse aus.", email);
}
const geburt = s(w.birth_date);

View File

@@ -22,6 +22,31 @@ export type Weekday = "Mo" | "Di" | "Mi" | "Do" | "Fr" | "Sa" | "So";
*/
export type DienstwagenArt = "Verbrenner" | "Elektro";
/**
* Verhältnis zum Notfallkontakt.
*
* Auswahlliste statt Freitext, damit sich danach auswerten lässt und nicht
* „Gattin", „Ehefrau" und „Frau" nebeneinander stehen. „Sonstige" ist der
* Ausweg für alles, was hier fehlt — ohne ihn wäre die Liste eine Anmassung.
*
* In der Datenbank bleibt die Spalte `text`: eine Aufzählung dort würde jede
* Ergänzung zu einer Migration machen, und diese Liste wird sich ändern.
*/
export const EMERGENCY_RELATIONS = [
"Gattin/Gatte",
"Lebensgefährtin/Lebensgefährte",
"Mutter",
"Vater",
"Tochter",
"Sohn",
"Schwester",
"Bruder",
"Freundin/Freund",
"Sonstige",
] as const;
export type EmergencyRelation = (typeof EMERGENCY_RELATIONS)[number];
/**
* Eine einzelne Feldänderung im Protokoll.
*
@@ -119,7 +144,8 @@ export type Database = {
postal_code: string | null;
city: string | null;
address_country: string | null;
email: string;
/** Private Adresse, freiwillig — eindeutig, wenn angegeben. */
email: string | null;
phone: string | null;
job_title: string;
location_id: string;
@@ -169,7 +195,7 @@ export type Database = {
postal_code?: string | null;
city?: string | null;
address_country?: string | null;
email: string;
email?: string | null;
phone?: string | null;
job_title: string;
location_id: string;

View File

@@ -0,0 +1,37 @@
-- Die E-Mail-Adresse ist privat und freiwillig.
--
-- Sie war NOT NULL, obwohl es sich um die *private* Adresse handelt — nicht
-- um eine Firmenadresse, die mit dem Eintritt entsteht. Wer keine angeben
-- will oder keine hat, muss trotzdem angelegt werden können. Bisher zwang
-- die Spalte dazu, etwas zu erfinden, und erfundene Daten in einer
-- Personalakte sind schlimmer als fehlende.
--
-- Die Eindeutigkeit bleibt: sie verhindert weiterhin, dass dieselbe Adresse
-- zweimal vorkommt. Mehrere NULL-Werte stören sie nicht — in PostgreSQL
-- gelten sie in einem UNIQUE-Index als voneinander verschieden, und genau
-- das ist hier gewollt.
alter table employees alter column email drop not null;
comment on column employees.email is
'Private E-Mail-Adresse. Freiwillig; eindeutig, wenn angegeben.';
comment on column employees.phone is
'Private Telefonnummer. Freiwillig.';
-- ═══ Gegenprobe ══════════════════════════════════════════════════
-- Zwei Personen ohne Adresse müssen nebeneinander bestehen können, zwei mit
-- derselben nicht.
do $$
begin
create temp table probe_mail (email text unique) on commit drop;
insert into probe_mail (email) values (null), (null);
insert into probe_mail (email) values ('a@example.invalid');
begin
insert into probe_mail (email) values ('a@example.invalid');
raise exception 'Doppelte Adressen werden angenommen — die Eindeutigkeit ist verloren.';
exception when unique_violation then null;
end;
end;
$$;

View File

@@ -25,7 +25,7 @@ const PERSON_SPALTEN = [
"Nachname",
"Geschlecht",
"Geburtsdatum",
"E-Mail",
"Private E-Mail",
"Tätigkeit",
"Standort",
"Planstellennummer",