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",