From 9d754359e0571ed635440a8cac6d081752666781 Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Tue, 11 Aug 2026 21:27:00 +0200 Subject: [PATCH] Enter the personnel number, tell the two kinds of company car apart, record who to call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three requests from use, one of which changes the schema's mind about something. The personnel number is no longer issued. It was GENERATED ALWAYS AS IDENTITY, which refuses a supplied value outright — but it has to match Loga and Interflex, and a number this application invents is unknown there, so the same person ends up with two. Identity dropped, entered everywhere instead: in the wizard, in the import, and validated against a duplicate with a message that names the number. Worth stating plainly: the column had no unique constraint. The identity prevented collisions as a side effect, and once the value comes from outside that side effect is gone. The constraint is the point now, and it was missing. Company cars distinguish Verbrenner from Elektro, tied to has_dienstwagen by a CHECK so "E-KFZ" cannot appear against someone without a car. The list filters on it — with, without, only electric, only combustion — which is the question the report was really about; it was answerable before only through an export and manual work. Emergency contact is name, phone and relationship. Relationship stays free text: the examples given — Gattin/Gatte, Schwester/Bruder, Freund — are not a list that closes without telling someone their arrangement does not count. Name and phone are all-or-nothing, in the database and in both forms: a name without a number helps nobody, a number without a name does not say who answers. Two mistakes of mine on the way, both caught by checks I had written into the migrations rather than by me: - The first CHECK on the car type would have permitted exactly the case it was written against. `art in (…)` yields NULL rather than false when the column is null, and a CHECK counts NULL as satisfied. It needs an explicit `is not null` in front. - The constraint was added before the backfill, so it rejected every existing row with a car. Existing cars are recorded as Verbrenner, which is an assumption — but a visible one: "Elektro" appears nowhere nobody confirmed it. hire_employee and change_employee_data both had to learn the new columns. They name their columns one by one, and what is missing there is dropped in silence — the interface would have collected the fields and thrown them away, which is what happened to the email address this morning. Verified against the live database, all rolled back: a hire without a number is refused, a duplicate is refused naming it, a freely chosen one goes through; E-KFZ plus contact arrive intact; a contact without a phone is refused. A change records both, with before and after in the audit detail. Co-Authored-By: Claude Opus 5 --- actions/employees.ts | 14 +- app/(app)/employees/page.tsx | 18 ++- app/api/export/employees/route.ts | 4 + components/employees/EmployeeFilters.tsx | 16 +++ components/employees/RoleEmploymentFields.tsx | 26 +++- .../employees/panels/DatenAendernPanel.tsx | 31 +++++ components/employees/tabs/StammdatenTab.tsx | 15 +++ components/hire/HireWizard.tsx | 21 ++- components/hire/StepPerson.tsx | 36 ++++++ components/hire/StepSummary.tsx | 8 +- components/hire/types.ts | 19 ++- lib/import/load.ts | 37 +++--- lib/import/schema.ts | 32 +++++ lib/supabase/types.ts | 19 +++ ...0811100000_personnel_number_is_entered.sql | 120 ++++++++++++++++++ ...1110000_emergency_contact_and_car_type.sql | 96 ++++++++++++++ ...11120000_change_data_covers_new_fields.sql | 91 +++++++++++++ .../20260811130000_hire_takes_new_fields.sql | 62 +++++++++ 18 files changed, 637 insertions(+), 28 deletions(-) create mode 100644 supabase/migrations/20260811100000_personnel_number_is_entered.sql create mode 100644 supabase/migrations/20260811110000_emergency_contact_and_car_type.sql create mode 100644 supabase/migrations/20260811120000_change_data_covers_new_fields.sql create mode 100644 supabase/migrations/20260811130000_hire_takes_new_fields.sql diff --git a/actions/employees.ts b/actions/employees.ts index d1ff052..3b828b0 100644 --- a/actions/employees.ts +++ b/actions/employees.ts @@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache"; import { currentUserId } from "@/lib/auth/session"; import { withUser } from "@/lib/db"; import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc"; -import type { CollectiveAgreement, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types"; +import type { CollectiveAgreement, DienstwagenArt, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types"; async function callRpc(fn: MutationFn, payload: Record, revalidate: string[]): Promise { const result = await runMutation(await currentUserId(), fn, payload); @@ -14,6 +14,14 @@ async function callRpc(fn: MutationFn, payload: Record, revalid } export async function hireEmployee(payload: { + /** + * Wird eingegeben, nicht vergeben. + * + * Sie muss mit Loga und Interflex übereinstimmen; eine hier selbst gezogene + * Nummer wäre dort unbekannt und die Person hätte in drei Systemen zwei + * Nummern. Die Datenbank weist eine bereits vergebene Nummer ab. + */ + personnel_number: number; first_name: string; last_name: string; title_prefix?: string[]; @@ -47,6 +55,10 @@ export async function hireEmployee(payload: { work_days?: Weekday[]; is_betriebsrat?: boolean; has_dienstwagen?: boolean; + dienstwagen_art?: DienstwagenArt | null; + emergency_contact_name?: string; + emergency_contact_phone?: string; + emergency_contact_relation?: string; is_laterale_fuehrung?: boolean; is_c_level?: boolean; }): Promise { diff --git a/app/(app)/employees/page.tsx b/app/(app)/employees/page.tsx index 97f8c86..8ad8438 100644 --- a/app/(app)/employees/page.tsx +++ b/app/(app)/employees/page.tsx @@ -15,7 +15,15 @@ import type { EmploymentStatus } from "@/lib/supabase/types"; const PAGE_SIZE = 15; -type SearchParams = { q?: string; division?: string; status?: string; location?: string; page?: string }; +type SearchParams = { + q?: string; + division?: string; + status?: string; + location?: string; + /** "ja" | "nein" | "elektro" | "verbrenner" — alles andere wird übergangen. */ + dienstwagen?: string; + page?: string; +}; type EmployeesPageProps = { searchParams: Promise; @@ -27,6 +35,7 @@ function pageHref(params: SearchParams, page: number): string { if (params.division) sp.set("division", params.division); if (params.status) sp.set("status", params.status); if (params.location) sp.set("location", params.location); + if (params.dienstwagen) sp.set("dienstwagen", params.dienstwagen); sp.set("page", String(page)); return `/employees?${sp.toString()}`; } @@ -126,6 +135,13 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps } if (params.location) q = q.where("location_id", "=", params.location); + + // Auf die vier bekannten Werte eingeschränkt: der Parameter kommt aus + // der Adresszeile und ist damit fremdbestimmt. + if (params.dienstwagen === "ja") q = q.where("has_dienstwagen", "=", true); + else if (params.dienstwagen === "nein") q = q.where("has_dienstwagen", "=", false); + else if (params.dienstwagen === "elektro") q = q.where("dienstwagen_art", "=", "Elektro"); + else if (params.dienstwagen === "verbrenner") q = q.where("dienstwagen_art", "=", "Verbrenner"); return q; }; diff --git a/app/api/export/employees/route.ts b/app/api/export/employees/route.ts index d295868..037f92c 100644 --- a/app/api/export/employees/route.ts +++ b/app/api/export/employees/route.ts @@ -142,6 +142,10 @@ function employeeExportColumns( { header: "Kollektivvertrag", get: (e) => e.collective_agreement }, { header: "Betriebsrat", get: (e) => e.is_betriebsrat }, { header: "Dienstwagen", get: (e) => e.has_dienstwagen }, + { header: "Antriebsart", get: (e) => e.dienstwagen_art ?? "" }, + { header: "Notfallkontakt", get: (e) => e.emergency_contact_name ?? "" }, + { header: "Notfallkontakt Telefon", get: (e) => e.emergency_contact_phone ?? "" }, + { header: "Notfallkontakt Verhältnis", get: (e) => e.emergency_contact_relation ?? "" }, { header: "Laterale Führung", get: (e) => e.is_laterale_fuehrung }, { header: "C-Level", get: (e) => e.is_c_level }, { header: "Paygrade", get: (e) => e.paygrade }, diff --git a/components/employees/EmployeeFilters.tsx b/components/employees/EmployeeFilters.tsx index d29fbc5..49b4077 100644 --- a/components/employees/EmployeeFilters.tsx +++ b/components/employees/EmployeeFilters.tsx @@ -104,6 +104,22 @@ export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersPr ))} + {/* „Wer hat einen Dienstwagen" ist eine wiederkehrende Frage — als + Filter beantwortet sie sich mit einem Klick, statt über einen + Export mit Nachbearbeitung. E-KFZ getrennt, weil das die Frage + hinter der Frage ist. */} + ); } diff --git a/components/employees/RoleEmploymentFields.tsx b/components/employees/RoleEmploymentFields.tsx index 764ecdc..3c77e17 100644 --- a/components/employees/RoleEmploymentFields.tsx +++ b/components/employees/RoleEmploymentFields.tsx @@ -1,7 +1,7 @@ "use client"; import { SelectField } from "@/components/ui/Field"; -import type { CollectiveAgreement, Weekday, WorkerType } from "@/lib/supabase/types"; +import type { CollectiveAgreement, DienstwagenArt, Weekday, WorkerType } from "@/lib/supabase/types"; const WEEKDAYS: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; @@ -11,6 +11,15 @@ export type RoleEmploymentValue = { workDays: Weekday[]; isBetriebsrat: boolean; hasDienstwagen: boolean; + /** + * Nur bedeutsam, solange hasDienstwagen gesetzt ist. + * + * Der Wert bleibt beim Abwählen stehen, statt zurückgesetzt zu werden — + * wer versehentlich klickt und zurückklickt, findet seine Angabe wieder. + * Beim Speichern setzen die Aufrufer ihn auf null, wie es der CHECK + * verlangt. + */ + dienstwagenArt: DienstwagenArt; isLateraleFuehrung: boolean; isCLevel: boolean; }; @@ -78,6 +87,21 @@ export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymen onChange({ hasDienstwagen: e.target.checked })} /> Dienstwagen + {/* Nur sichtbar, wenn es einen gibt: eine Antriebsart ohne Fahrzeug + ist keine Angabe, sondern eine Frage ohne Gegenstand — und die + Datenbank weist sie ab. */} + {value.hasDienstwagen && ( + onChange({ dienstwagenArt: v as DienstwagenArt })} + options={[ + { value: "Verbrenner", label: "Verbrenner" }, + { value: "Elektro", label: "Elektro (E-KFZ)" }, + ]} + /> + )}