diff --git a/actions/employees.ts b/actions/employees.ts index a785958..3670532 100644 --- a/actions/employees.ts +++ b/actions/employees.ts @@ -15,6 +15,44 @@ async function callRpc(fn: MutationFn, payload: Record, revalid return result; } +/** + * Ist diese Personalnummer noch frei? + * + * ── Warum das trotz der Prüfung in der Datenbank hier steht ────────── + * + * `hire_employee` weist eine vergebene Nummer ab, und das bleibt die + * verbindliche Prüfung: zwischen dieser Frage und dem Anlegen können Sekunden + * liegen, und in denen kann jemand anderes dieselbe Nummer vergeben. Der + * Unique-Index ist die einzige Stelle, die das sicher ausschliesst. + * + * Nur kommt diese Abweisung ganz am Ende — nach Position, Vertrag, + * Angehörigen, Notfallkontakt, sechs Schritten Eingabe. Die Nummer steht im + * *ersten* Feld des *ersten* Schritts. Wer sie vertippt, erfährt es + * frühestens nach fünf Minuten und darf dann suchen, welche der Angaben + * gemeint war. + * + * Deshalb hier die frühe Auskunft und dort die Entscheidung. Es ist keine + * doppelte Wahrheit, sondern dieselbe Frage zu zwei Zeitpunkten — und wenn + * die späte Antwort einmal abweicht, gewinnt sie. + * + * Kein `ActionResult`: eine vergebene Nummer ist kein Fehler des Aufrufs, + * sondern die Antwort auf die Frage. + */ +export async function personalnummerVergeben(nummer: number): Promise<{ vergeben: boolean; name?: string }> { + if (!Number.isInteger(nummer) || nummer <= 0) return { vergeben: false }; + return withUser(await currentUserId(), async (tx) => { + const treffer = await tx + .selectFrom("employees") + .select(["first_name", "last_name"]) + .where("personnel_number", "=", nummer) + .executeTakeFirst(); + // Der Name wird mitgegeben, weil „bereits vergeben" allein die Frage + // aufwirft, an wen — und die Antwort ist meistens der Grund: dieselbe + // Person ist schon angelegt, oder es war ein Zahlendreher. + return treffer ? { vergeben: true, name: `${treffer.first_name} ${treffer.last_name}` } : { vergeben: false }; + }); +} + export async function hireEmployee(payload: { /** * Wird eingegeben, nicht vergeben. diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index d0effda..999ff1f 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -28,6 +28,9 @@ const TONE: Record = { info: { text: "text-info-text", bar: "bg-info-text" }, }; +/** Eine Kennzahl auf der Übersicht. */ +type Kachel = { label: string; value: string | number; tone: keyof typeof TONE; href: string }; + // Die Tabelle, die hier stand, ist nach lib/colors.ts gewandert: Punkt und // Chip derselben Zeile kamen aus zwei getrennten Verzeichnissen, und eines // davon war das falsche. Jetzt leiten beide aus EVENT_CATEGORY ab und koennen @@ -151,78 +154,104 @@ export default async function DashboardPage({ // and rehire_employee sets entry_date but logs the event as // 'Wiedereintritt'. A year with rehires therefore shows a slightly higher // number on the tile than in the linked report. - const kpis = [ + // ── Warum die Kacheln in Gruppen stehen ──────────────────────────── + // + // Acht Zahlen nebeneinander sind acht Zahlen. Sie beantworten aber drei + // verschiedene Fragen: wie viele sind da, was hat sich bewegt, was ist + // offen. Ohne Überschrift muss man jede Beschriftung einzeln lesen, um das + // herauszufinden — mit ihr sieht man es, bevor man liest. + // + // Die Einteilung und die Reihenfolge kommen aus einem Entwurf des Kunden + // (E-Mail vom 16.09.2026). „Aktives Dienstverhältnis" steht darin bewusst + // **vorn**: es ist die Bezugsgrösse fast jeder Personalkennzahl, und wer + // eine Quote bildet, greift zuerst danach. + const kpiGruppen: { titel: string; kacheln: Kachel[] }[] = [ { - label: "Aktive Mitarbeiter:innen", - value: activeCount, - tone: "default", - href: "/employees?status=Aktiv", - }, - { label: "FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" }, - { - label: "Eintritte (Jahr)", - value: hiresYtd, - tone: "success", - href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`, + titel: "Personalstand", + kacheln: [ + // Nicht dasselbe wie „Aktive Mitarbeiter:innen" daneben: dort steht, + // wer heute arbeitet, hier, mit wem ein Vertrag läuft — + // Langzeitabwesende eingeschlossen. Sichtbar waren 805 und 10, + // addieren musste man selbst. + { + label: "Aktives Dienstverhältnis", + value: activeCount + karenzCount, + tone: "default", + // Mit Komma, nicht zweimal `status=`: die Liste liest den Parameter + // als *eine* Zeichenkette und trennt selbst. Zweimal übergeben macht + // Next daraus ein Array, und die Seite scheiterte an `.split(",")`. + href: "/employees?status=Aktiv,Karenz", + }, + // „(HC)" für Headcount — der Unterschied zur Kachel links ist genau + // der, den die Abkürzung benennt: Köpfe, die heute arbeiten. + { label: "Aktive Mitarbeiter:innen (HC)", value: activeCount, tone: "default", href: "/employees?status=Aktiv" }, + { label: "Langzeitabwesend", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" }, + // Ohne Zeitgrenze — anders als die Karte „Anstehend" darunter, die nur + // den eingestellten Vorschauzeitraum zeigt. Ein Eintritt in vier + // Monaten ist vereinbart und zählt, auch wenn er dort nicht auftaucht. + { + label: "Geplante Eintritte", + value: staffRows.filter((row) => statusOf(row) === "Geplant").length, + tone: "info", + href: "/employees?status=Geplant", + }, + { label: "Aktive FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" }, + ], }, { - label: "Austritte (Jahr)", - value: exitsYtd, - tone: "danger", - href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`, + titel: "Personalbewegung", + kacheln: [ + { + label: "Eintritte (Jahr)", + value: hiresYtd, + tone: "success", + href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`, + }, + { + label: "Austritte (Jahr)", + value: exitsYtd, + tone: "danger", + href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`, + }, + ], }, - { label: "Langzeitabwesend", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" }, - // ── Zwei Kacheln aus dem Workshop (Anforderung 10) ────────────── - // - // „Aktives Dienstverhältnis" ist nicht dasselbe wie „Aktive - // Mitarbeiter:innen" ganz links. Dort steht, wer heute arbeitet; hier, - // mit wem ein Vertrag läuft — Langzeitabwesende eingeschlossen. Genau - // diese Zahl ist die Bezugsgrösse fast jeder Personalkennzahl, und sie - // war bisher nirgends zu sehen: sichtbar waren 806 und 10, addieren - // musste man selbst. { - label: "Aktives Dienstverhältnis", - value: activeCount + karenzCount, - tone: "default", - // Mit Komma, nicht zweimal `status=`: die Liste liest den Parameter als - // *eine* Zeichenkette und trennt selbst. Zweimal übergeben macht Next - // daraus ein Array, und die Seite scheiterte an `.split(",")`. - href: "/employees?status=Aktiv,Karenz", + titel: "Recruiting & Vakanzen", + kacheln: [{ label: "Offene Positionen", value: openPositions.length, tone: "brand", href: "/positions" }], }, - // Geplante Eintritte ohne Zeitgrenze — anders als die Karte „Anstehend" - // darunter, die nur den eingestellten Vorschauzeitraum zeigt. Ein - // Eintritt in vier Monaten ist vereinbart und zählt, auch wenn er in der - // Vorschau noch nicht auftaucht. - { - label: "Geplante Eintritte", - value: staffRows.filter((row) => statusOf(row) === "Geplant").length, - tone: "info", - href: "/employees?status=Geplant", - }, - { label: "Offene Positionen", value: openPositions.length, tone: "brand", href: "/positions" }, ]; return (
{drafts.length > 0 && } - {/* Acht Kacheln: vier je Zeile ab der grossen Breite, damit keine - Zeile mit zwei Resten endet. */} -
- {kpis.map((kpi) => ( - - - {/* Number first in the visual order: it is what the eye is - looking for, and the label only qualifies it. */} -
{kpi.value}
-
- {kpi.label} - + {/* Die Gruppen nebeneinander, solange der Platz reicht, und darunter, + wenn nicht. `flex-wrap` statt eines Rasters: die Gruppen sind + verschieden breit (fünf, zwei, eine Kachel), und ein Raster zwänge + sie in gleiche Spalten — die einzelne Kachel rechts bekäme dann + denselben Platz wie fünf. */} +
+ {kpiGruppen.map((gruppe) => ( +
+

{gruppe.titel}

+
+ {gruppe.kacheln.map((kpi) => ( + + + {/* Number first in the visual order: it is what the eye is + looking for, and the label only qualifies it. */} +
{kpi.value}
+
+ {kpi.label} + +
+ + ))}
- +
))}
diff --git a/components/dashboard/AnstehendListe.tsx b/components/dashboard/AnstehendListe.tsx index 03a145c..c814cde 100644 --- a/components/dashboard/AnstehendListe.tsx +++ b/components/dashboard/AnstehendListe.tsx @@ -68,9 +68,14 @@ export function AnstehendListe({ return ( <> - {/* Erst ab neun Einträgen: darunter sieht man die ganze Liste ohnehin, - und ein Suchfeld über acht Zeilen ist mehr Bedienelement als Hilfe. */} - {eintraege.length > VORSCHAU && ( + {/* Immer da, sobald überhaupt etwas ansteht. + * + Zuerst stand hier eine Schwelle — erst ab neun Einträgen, weil man + darunter die ganze Liste ohnehin sieht. In der Bedienung war das + falsch: das Feld erschien bei 180 Tagen und verschwand bei 30, und + ein Bedienelement, das je nach Zeitraum da ist oder nicht, wirkt wie + ein Fehler. Wer sucht, sucht auch in sieben Zeilen. */} + {eintraege.length > 0 && ( (null); + const nummerFrei = Number(draft.personnelNumber) !== vergebeneNummer; + const merkeBefund = useCallback( + (befund: { nummer: number; vergeben: boolean }) => setVergebeneNummer(befund.vergeben ? befund.nummer : null), + [] + ); + const svNummerOk = !draft.svNummer.trim() || !requiresAustrianSvnr(locations.find((l) => l.id === draft.locationId)?.country) || @@ -80,7 +96,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf Person: Boolean( draft.personnelNumber.trim() && draft.firstName && draft.lastName && draft.birthDate && draft.locationId - ) && svNummerOk, + ) && svNummerOk && nummerFrei, Position: Boolean(draft.positionId && draft.besetzung), Vertrag: Boolean(draft.entryDate && draft.workDays.length > 0), // Angehörige: freiwillig — aber eine begonnene Zeile muss vollständig @@ -268,7 +284,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf ))}
- {step === 0 && } + {step === 0 && } {step === 1 && } {step === 2 && } {step === 3 && } diff --git a/components/hire/PersonalnummerField.tsx b/components/hire/PersonalnummerField.tsx new file mode 100644 index 0000000..83e703f --- /dev/null +++ b/components/hire/PersonalnummerField.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { personalnummerVergeben } from "@/actions/employees"; +import { TextField } from "@/components/ui/Field"; + +// Die Personalnummer wird eingegeben, nicht vergeben — sie muss mit Loga und +// Interflex übereinstimmen. Genau deshalb kommt sie von aussen, und genau +// deshalb kann sie schon vergeben sein: an dieselbe Person, die jemand ein +// zweites Mal anlegt, oder an eine ganz andere nach einem Zahlendreher. +// +// Die Datenbank weist das ab (hire_employee prüft es eigens), aber erst beim +// Anlegen — nach sechs Schritten Eingabe. Bis dahin ist die falsche Nummer +// schon durch alle Formulare gereist. +// +// ── Wann gefragt wird ─────────────────────────────────────────────── +// +// Nicht bei jedem Tastendruck: „3", „34", „347" wären drei Fragen an die +// Datenbank für eine Eingabe, und die ersten beiden hätten eine Antwort auf +// eine Nummer, die niemand meint. Stattdessen eine halbe Sekunde Ruhe +// abwarten — lange genug, dass eine vierstellige Nummer am Stück eingetippt +// eine einzige Frage ergibt, kurz genug, dass die Antwort da ist, bevor der +// Blick zum nächsten Feld wandert. +// +// Die Antwort kann veralten, während sie unterwegs ist. Deshalb zählt jede +// Anfrage mit und nur die jüngste darf schreiben: sonst überschriebe die +// langsamere Antwort auf „347" die schnellere auf „3471". +// +// ── Warum der Befund an der Nummer hängt und nicht an einem Ja/Nein ── +// +// Gespeichert wird **die geprüfte Nummer** mitsamt ihrem Ergebnis, nicht +// bloss „frei" oder „vergeben". Damit ist jede Anzeige eine Ableitung aus +// dem, was gerade im Feld steht — es gibt keinen Zustand, der zurückgesetzt +// werden müsste, wenn die Eingabe sich ändert, und deshalb auch keine Stelle, +// an der das Zurücksetzen vergessen werden kann. + +const WARTEZEIT_MS = 500; + +type Befund = { nummer: number; vergeben: boolean; name?: string }; + +export function PersonalnummerField({ + wert, + onChange, + onBefund, +}: { + wert: string; + onChange: (wert: string) => void; + /** Meldet dem Assistenten die geprüfte Nummer und ihr Ergebnis. */ + onBefund: (befund: Befund) => void; +}) { + const [befund, setBefund] = useState(null); + const laufendeNr = useRef(0); + + const nummer = Number(wert); + const gueltig = Boolean(wert.trim()) && Number.isInteger(nummer) && nummer > 0; + // Nur ein Befund zu *dieser* Nummer zählt. Steht im Feld inzwischen etwas + // anderes, ist die alte Auskunft gegenstandslos. + const passend = gueltig && befund?.nummer === nummer ? befund : null; + const laeuft = gueltig && !passend; + + useEffect(() => { + if (!gueltig) return; + + const meine = ++laufendeNr.current; + const zeit = setTimeout(async () => { + let antwort: { vergeben: boolean; name?: string }; + try { + antwort = await personalnummerVergeben(nummer); + } catch { + // Keine Auskunft ist kein Hindernis: die Datenbank entscheidet beim + // Anlegen ohnehin. Eine Meldung über eine gescheiterte *Vorab*-Prüfung + // wäre für die Eingebende nur Lärm. + antwort = { vergeben: false }; + } + if (meine !== laufendeNr.current) return; + const neu: Befund = { nummer, ...antwort }; + setBefund(neu); + onBefund(neu); + }, WARTEZEIT_MS); + + return () => clearTimeout(zeit); + }, [gueltig, nummer, onBefund]); + + return ( + onChange(v.replace(/\D/g, ""))} + error={ + passend?.vergeben + ? `Diese Personalnummer ist bereits vergeben${passend.name ? ` — an ${passend.name}` : ""}.` + : undefined + } + hint={laeuft ? "Wird geprüft…" : "Muss mit Loga und Interflex übereinstimmen. Wird nicht automatisch vergeben."} + /> + ); +} diff --git a/components/hire/StepPerson.tsx b/components/hire/StepPerson.tsx index 410f8f7..ceec9a6 100644 --- a/components/hire/StepPerson.tsx +++ b/components/hire/StepPerson.tsx @@ -1,24 +1,24 @@ import { SvNummerField } from "@/components/employees/SvNummerField"; import { TitleFields } from "@/components/employees/TitleFields"; import { SelectField, TextField } from "@/components/ui/Field"; +import { PersonalnummerField } from "./PersonalnummerField"; import type { HireDraftData } from "./types"; type StepPersonProps = { draft: HireDraftData; update: (patch: Partial) => void; locations: { id: string; name: string; country: string }[]; + /** Meldet die geprüfte Personalnummer und ihr Ergebnis — steuert „Weiter". */ + onNummerBefund: (befund: { nummer: number; vergeben: boolean }) => void; }; -export function StepPerson({ draft, update, locations }: StepPersonProps) { +export function StepPerson({ draft, update, locations, onNummerBefund }: StepPersonProps) { return (
- update({ personnelNumber: personnelNumber.replace(/\D/g, "") })} - hint="Muss mit Loga und Interflex übereinstimmen. Wird nicht automatisch vergeben." + update({ personnelNumber })} + onBefund={onNummerBefund} />
update({ firstName })} />