Anmerkungen vom 16.09.: Uebersicht gegliedert, Personalnummer prueft frueher
1) Die Kacheln stehen jetzt in drei Gruppen — Personalstand, Personalbewegung, Recruiting & Vakanzen — in der Reihenfolge aus dem Entwurf des Kunden. Acht Zahlen nebeneinander sind acht Zahlen; sie beantworten aber drei verschiedene Fragen, und ohne Ueberschrift muss man jede Beschriftung einzeln lesen, um das herauszufinden. "Aktives Dienstverhaeltnis" steht vorn: es ist die Bezugsgroesse fast jeder Personalkennzahl. 2) Der Namensfilter in "Anstehend" ist jetzt immer da. Die Schwelle "erst ab neun Eintraegen" war in der Bedienung 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. 3b) Der Filter heisst jetzt "Aktives Dienstverhaeltnis (Aktiv + Langzeitabwesenheit)" — derselbe Name wie die Kachel, die dorthin verlinkt. 6) Die Personalnummer wird gegen die Datenbank geprueft, waehrend sie eingetippt wird, und nennt bei einem Treffer die Person, die sie schon hat. hire_employee weist sie weiterhin ab — das bleibt die verbindliche Pruefung, denn zwischen Frage und Anlegen kann jemand anderes dieselbe Nummer vergeben. Nur kam diese Abweisung bisher nach sechs Schritten Eingabe, und das Feld steht im ersten Schritt. Gemerkt wird dabei die gepruefte *Nummer* samt Ergebnis, nicht ein Ja/Nein: so ist die Sperre eine Ableitung aus dem, was im Feld steht, und es gibt keinen Zustand, dessen Zuruecksetzen man vergessen koennte. Zu 4) geprueft, nichts geaendert: die FTE-Kachel rechnet bereits Summe der Wochenstunden der heute Aktiven durch 38,5. Der Berichtemanager rechnet dieselbe Formel, nur als Summe der Einzelquotienten geschrieben.
This commit is contained in:
@@ -15,6 +15,44 @@ async function callRpc(fn: MutationFn, payload: Record<string, unknown>, 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.
|
||||
|
||||
@@ -28,6 +28,9 @@ const TONE: Record<string, { text: string; bar: string }> = {
|
||||
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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
{drafts.length > 0 && <DraftsCard drafts={drafts} />}
|
||||
{/* Acht Kacheln: vier je Zeile ab der grossen Breite, damit keine
|
||||
Zeile mit zwei Resten endet. */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-4 xl:grid-cols-8">
|
||||
{kpis.map((kpi) => (
|
||||
<Link
|
||||
key={kpi.label}
|
||||
href={kpi.href}
|
||||
className={`${CARD_CLASS} group relative overflow-hidden p-4 pl-5 transition-shadow hover:shadow-[var(--shadow-card-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500`}
|
||||
>
|
||||
<span className={`absolute inset-y-0 left-0 w-1 ${TONE[kpi.tone].bar}`} aria-hidden />
|
||||
{/* Number first in the visual order: it is what the eye is
|
||||
looking for, and the label only qualifies it. */}
|
||||
<div className={`text-3xl font-extrabold leading-none tabular-nums ${TONE[kpi.tone].text}`}>{kpi.value}</div>
|
||||
<div className="mt-1.5 flex items-center gap-1 text-xs font-semibold leading-tight text-ink-muted">
|
||||
{kpi.label}
|
||||
<ChevronRight className="h-3 w-3 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" aria-hidden />
|
||||
{/* 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. */}
|
||||
<div className="flex flex-wrap items-start gap-x-6 gap-y-4">
|
||||
{kpiGruppen.map((gruppe) => (
|
||||
<section key={gruppe.titel} className="min-w-0 flex-1">
|
||||
<h2 className="mb-1.5 text-[11px] font-bold uppercase tracking-wider text-ink-muted">{gruppe.titel}</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-[repeat(auto-fit,minmax(9rem,1fr))]">
|
||||
{gruppe.kacheln.map((kpi) => (
|
||||
<Link
|
||||
key={kpi.label}
|
||||
href={kpi.href}
|
||||
className={`${CARD_CLASS} group relative overflow-hidden p-4 pl-5 transition-shadow hover:shadow-[var(--shadow-card-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500`}
|
||||
>
|
||||
<span className={`absolute inset-y-0 left-0 w-1 ${TONE[kpi.tone].bar}`} aria-hidden />
|
||||
{/* Number first in the visual order: it is what the eye is
|
||||
looking for, and the label only qualifies it. */}
|
||||
<div className={`text-3xl font-extrabold leading-none tabular-nums ${TONE[kpi.tone].text}`}>{kpi.value}</div>
|
||||
<div className="mt-1.5 flex items-center gap-1 text-xs font-semibold leading-tight text-ink-muted">
|
||||
{kpi.label}
|
||||
<ChevronRight className="h-3 w-3 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" aria-hidden />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
<input
|
||||
type="search"
|
||||
value={suche}
|
||||
|
||||
@@ -19,7 +19,11 @@ type EmployeeFiltersProps = {
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "Aktiv", label: "Aktiv" },
|
||||
{ value: "Karenz", label: "Langzeitabwesenheit" },
|
||||
{ value: "Aktiv,Karenz", label: "Aktiv + Langzeitabwesenheit (beschäftigt)" },
|
||||
// Der Name der Kachel zuerst, die Bestandteile in Klammern dahinter: die
|
||||
// Kachel „Aktives Dienstverhältnis" verlinkt genau auf diese Auswahl, und
|
||||
// wer ihr folgt, soll oben dieselbe Bezeichnung wiederfinden statt eine
|
||||
// zweite für dieselbe Sache.
|
||||
{ value: "Aktiv,Karenz", label: "Aktives Dienstverhältnis (Aktiv + Langzeitabwesenheit)" },
|
||||
{ value: "Geplant", label: "Geplant" },
|
||||
{ value: "Ausgetreten", label: "Ausgetreten" },
|
||||
] as const;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { addEmployeeDependent, hireEmployee } from "@/actions/employees";
|
||||
import { deleteHireDraft, saveHireDraft } from "@/actions/hireDrafts";
|
||||
@@ -60,6 +60,22 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
|
||||
// Blocks step 1 rather than letting the hire fail at the RPC: the SVNR
|
||||
// trigger rejects a bad number, and by then the user is three steps on.
|
||||
// Dieselbe Überlegung wie bei der SV-Nummer, nur dass die Antwort aus der
|
||||
// Datenbank kommt: eine vergebene Personalnummer wies bisher erst
|
||||
// hire_employee ab — nach sechs Schritten Eingabe, und die Nummer steht im
|
||||
// ersten Feld des ersten Schritts.
|
||||
//
|
||||
// Gemerkt wird die *Nummer*, die als vergeben zurückkam, nicht ein Ja/Nein.
|
||||
// So ist die Sperre eine Ableitung aus dem, was im Feld steht, und muss
|
||||
// beim Weitertippen nicht zurückgesetzt werden — ein Zurücksetzen, das man
|
||||
// vergessen kann, sperrt sonst ein Formular ohne sichtbaren Grund.
|
||||
const [vergebeneNummer, setVergebeneNummer] = useState<number | null>(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
|
||||
))}
|
||||
</div>
|
||||
|
||||
{step === 0 && <StepPerson draft={draft} update={update} locations={locations} />}
|
||||
{step === 0 && <StepPerson draft={draft} update={update} locations={locations} onNummerBefund={merkeBefund} />}
|
||||
{step === 1 && <StepPosition draft={draft} update={update} openPositions={openPositions} />}
|
||||
{step === 2 && <StepVertrag draft={draft} update={update} />}
|
||||
{step === 3 && <StepAngehoerige draft={draft} update={update} />}
|
||||
|
||||
99
components/hire/PersonalnummerField.tsx
Normal file
99
components/hire/PersonalnummerField.tsx
Normal file
@@ -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<Befund | null>(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 (
|
||||
<TextField
|
||||
label="Personalnummer"
|
||||
required
|
||||
inputMode="numeric"
|
||||
value={wert}
|
||||
onChange={(v) => 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."}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<HireDraftData>) => 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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<TextField
|
||||
label="Personalnummer"
|
||||
required
|
||||
inputMode="numeric"
|
||||
value={draft.personnelNumber}
|
||||
onChange={(personnelNumber) => update({ personnelNumber: personnelNumber.replace(/\D/g, "") })}
|
||||
hint="Muss mit Loga und Interflex übereinstimmen. Wird nicht automatisch vergeben."
|
||||
<PersonalnummerField
|
||||
wert={draft.personnelNumber}
|
||||
onChange={(personnelNumber) => update({ personnelNumber })}
|
||||
onBefund={onNummerBefund}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<TextField label="Vorname" required value={draft.firstName} onChange={(firstName) => update({ firstName })} />
|
||||
|
||||
Reference in New Issue
Block a user