From c23df086488f665f7be70fcbbe8c89720dc653ed Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Tue, 11 Aug 2026 21:47:04 +0200 Subject: [PATCH] Ask for the optional things separately, and stop claiming numbers are issued MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- actions/employees.ts | 11 +- app/api/export/employees/route.ts | 4 +- .../employees/panels/DatenAendernPanel.tsx | 9 +- components/employees/panels/KarenzPanel.tsx | 31 +++-- components/employees/tabs/StammdatenTab.tsx | 16 +-- components/hire/HireWizard.tsx | 95 +++++++++++---- components/hire/StepAngehoerige.tsx | 113 ++++++++++++++++++ components/hire/StepNotfallkontakt.tsx | 60 ++++++++++ components/hire/StepPerson.tsx | 35 +----- components/hire/StepSummary.tsx | 3 - components/hire/types.ts | 18 ++- lib/import/load.ts | 4 +- lib/import/schema.ts | 11 +- lib/import/validate.ts | 4 +- lib/supabase/types.ts | 30 ++++- .../20260811140000_private_email_optional.sql | 37 ++++++ tests/unit/import-validate.test.ts | 2 +- 17 files changed, 387 insertions(+), 96 deletions(-) create mode 100644 components/hire/StepAngehoerige.tsx create mode 100644 components/hire/StepNotfallkontakt.tsx create mode 100644 supabase/migrations/20260811140000_private_email_optional.sql 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 ( +
+ + {a.firstName || a.lastName ? `${a.firstName} ${a.lastName}`.trim() : `Angehörige:r ${i + 1}`} + {a.birthDate && · {fmtDate(a.birthDate)}} + + +
+
+ setze(i, { firstName: v })} /> + setze(i, { lastName: v })} /> +
+
+ setze(i, { relationship: v as RelationshipType })} + options={VERHAELTNIS.map((r) => ({ value: r, label: r }))} + /> + setze(i, { birthDate: v })} /> +
+ setze(i, { svNummer: v })} + error={svFehler ? svnrErrorMessage(svFehler) : undefined} + hint={!svFehler && a.svNummer.trim() ? formatSvnr(a.svNummer) : undefined} + /> +
+ +
+ +
+
+ ); + })} +
+ )} + +
+ +
+
+ ); +} 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({ emergencyContactName })} + /> + update({ emergencyContactPhone })} + /> +
+ + update({ emergencyContactRelation })} + placeholder="Bitte wählen…" + options={EMERGENCY_RELATIONS.map((r) => ({ value: r, label: r }))} + /> + + {unvollstaendig && ( +

+ Name und Telefonnummer werden beide gebraucht — oder beide leer lassen. +

+ )} +
+ ); +} diff --git a/components/hire/StepPerson.tsx b/components/hire/StepPerson.tsx index 051f13f..410f8f7 100644 --- a/components/hire/StepPerson.tsx +++ b/components/hire/StepPerson.tsx @@ -45,37 +45,12 @@ export function StepPerson({ draft, update, locations }: StepPersonProps) { birthDate={draft.birthDate || null} />
- 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. */} -
- Notfallkontakt -
-
- update({ emergencyContactName })} - /> - update({ emergencyContactPhone })} - /> -
- update({ emergencyContactRelation })} - hint="Zum Beispiel Gattin, Bruder, Freundin. Freitext — die Aufzählung liesse sich nicht schliessen." - /> -
-
- ))} -

- Personalnummer und Firmen-E-Mail-Adresse werden automatisch vergeben. -

); } diff --git a/components/hire/types.ts b/components/hire/types.ts index 64233d0..66e507d 100644 --- a/components/hire/types.ts +++ b/components/hire/types.ts @@ -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, }; diff --git a/lib/import/load.ts b/lib/import/load.ts index 85ecc34..5c87e7d 100644 --- a/lib/import/load.ts +++ b/lib/import/load.ts @@ -68,7 +68,9 @@ export async function bestandLaden(tx: Tx): Promise { 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!))), }; } diff --git a/lib/import/schema.ts b/lib/import/schema.ts index 7708807..a82b7fe 100644 --- a/lib/import/schema.ts +++ b/lib/import/schema.ts @@ -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" }, diff --git a/lib/import/validate.ts b/lib/import/validate.ts index 0399d87..778bad4 100644 --- a/lib/import/validate.ts +++ b/lib/import/validate.ts @@ -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); diff --git a/lib/supabase/types.ts b/lib/supabase/types.ts index 8be9cf9..ddefbbf 100644 --- a/lib/supabase/types.ts +++ b/lib/supabase/types.ts @@ -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; diff --git a/supabase/migrations/20260811140000_private_email_optional.sql b/supabase/migrations/20260811140000_private_email_optional.sql new file mode 100644 index 0000000..e7e8c90 --- /dev/null +++ b/supabase/migrations/20260811140000_private_email_optional.sql @@ -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; +$$; diff --git a/tests/unit/import-validate.test.ts b/tests/unit/import-validate.test.ts index c39cbe7..b1c3fed 100644 --- a/tests/unit/import-validate.test.ts +++ b/tests/unit/import-validate.test.ts @@ -25,7 +25,7 @@ const PERSON_SPALTEN = [ "Nachname", "Geschlecht", "Geburtsdatum", - "E-Mail", + "Private E-Mail", "Tätigkeit", "Standort", "Planstellennummer",