diff --git a/actions/employees.ts b/actions/employees.ts
index 3b828b0..b2f795e 100644
--- a/actions/employees.ts
+++ b/actions/employees.ts
@@ -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;
diff --git a/app/api/export/employees/route.ts b/app/api/export/employees/route.ts
index 037f92c..f9d38cd 100644
--- a/app/api/export/employees/route.ts
+++ b/app/api/export/employees/route.ts
@@ -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) ?? "") : "") },
diff --git a/components/employees/panels/DatenAendernPanel.tsx b/components/employees/panels/DatenAendernPanel.tsx
index 1fbcac8..b5b2f67 100644
--- a/components/employees/panels/DatenAendernPanel.tsx
+++ b/components/employees/panels/DatenAendernPanel.tsx
@@ -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({
- ({ value: r, label: r }))}
/>
diff --git a/components/employees/panels/KarenzPanel.tsx b/components/employees/panels/KarenzPanel.tsx
index 9aa06f8..3522ac2 100644
--- a/components/employees/panels/KarenzPanel.tsx
+++ b/components/employees/panels/KarenzPanel.tsx
@@ -33,7 +33,13 @@ export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClos
const [returnDate, setReturnDate] = useState("");
const [employmentMode, setEmploymentMode] = useState("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,9 +95,15 @@ 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)) {
- showToast("Wochenstunden müssen zwischen 0 und 38,5 liegen.", "error");
- return;
+ 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({
@@ -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" && (
)}
diff --git a/components/employees/tabs/StammdatenTab.tsx b/components/employees/tabs/StammdatenTab.tsx
index 4389cf4..00fcf16 100644
--- a/components/employees/tabs/StammdatenTab.tsx
+++ b/components/employees/tabs/StammdatenTab.tsx
@@ -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
))}
- {/* 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.
+
+
+ {/* 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. */}
Notfallkontakt
{employee.emergency_contact_name ? (
@@ -90,8 +92,6 @@ export function StammdatenTab({ employee, location, dependents }: { employee: Em
Kein Notfallkontakt hinterlegt.
)}
-
-
);
}
diff --git a/components/hire/HireWizard.tsx b/components/hire/HireWizard.tsx
index 1e421db..820d613 100644
--- a/components/hire/HireWizard.tsx
+++ b/components/hire/HireWizard.tsx
@@ -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
)}
- {step < 3 && (
+ {step < letzterSchritt && (
)}
- {step === 3 && (
+ {step === letzterSchritt && (
@@ -198,8 +239,10 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
{step === 0 && }
{step === 1 && }
- {step === 2 && }
- {step === 3 && }
+ {step === 2 && }
+ {step === 3 && }
+ {step === 4 && }
+ {step === 5 && }
);
}
diff --git a/components/hire/StepAngehoerige.tsx b/components/hire/StepAngehoerige.tsx
new file mode 100644
index 0000000..0b39173
--- /dev/null
+++ b/components/hire/StepAngehoerige.tsx
@@ -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) => void;
+}) {
+ const liste = draft.angehoerige;
+
+ function setze(index: number, patch: Partial) {
+ 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 (
+
+
+ Angehörige sind freiwillig und lassen sich jederzeit in der Personalakte nachtragen. Der Nachname ist mit dem
+ der einzustellenden Person vorbelegt — überschreibbar.
+
+
+ {liste.length === 0 ? (
+
Keine Angehörigen erfasst.
+ ) : (
+
+ {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 (
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/components/hire/StepNotfallkontakt.tsx b/components/hire/StepNotfallkontakt.tsx
new file mode 100644
index 0000000..0596687
--- /dev/null
+++ b/components/hire/StepNotfallkontakt.tsx
@@ -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) => void;
+}) {
+ const angefangen = Boolean(draft.emergencyContactName.trim() || draft.emergencyContactPhone.trim());
+ const unvollstaendig = angefangen && !(draft.emergencyContactName.trim() && draft.emergencyContactPhone.trim());
+
+ return (
+
+
+ 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.
+
- update({ email })} />
- 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. */}
+ update({ email })} />
+ update({ phone })} />
- {/* 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. */}
-
-
))}
-