Просмотр исходного кода

RHL-043 feat(admin-users): harden branch create and edit flows

Code_Uwe 1 месяц назад
Родитель
Сommit
22707b3bba

+ 75 - 0
components/admin/users/BranchNumberInput.jsx

@@ -0,0 +1,75 @@
+"use client";
+
+import React from "react";
+
+import {
+	extractBranchNumberInputFromBranchId,
+	formatBranchIdFromNumberInput,
+	normalizeBranchIdInput,
+} from "@/lib/frontend/admin/users/userManagementUx";
+
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+
+export default function BranchNumberInput({
+	id,
+	branchId,
+	onBranchIdChange,
+	disabled,
+	invalid = false,
+	describedBy,
+}) {
+	const [numberDraft, setNumberDraft] = React.useState(() =>
+		extractBranchNumberInputFromBranchId(branchId),
+	);
+
+	React.useEffect(() => {
+		const normalizedExternalBranchId = normalizeBranchIdInput(branchId);
+		const currentDerivedBranchId = formatBranchIdFromNumberInput(numberDraft);
+
+		// Keep user typing stable (e.g. "01" stays visible) while syncing true external changes
+		// like dialog open/reset/user switch.
+		if (currentDerivedBranchId === normalizedExternalBranchId) return;
+
+		setNumberDraft(extractBranchNumberInputFromBranchId(branchId));
+	}, [branchId, numberDraft]);
+
+	const wrapperClass = invalid
+		? "border-destructive"
+		: "border-input";
+
+	return (
+		<div className="grid gap-2">
+			<Label htmlFor={id}>Niederlassung</Label>
+
+			<div
+				className={`flex h-9 items-stretch overflow-hidden rounded-md border ${wrapperClass}`}
+			>
+				<span className="inline-flex shrink-0 items-center border-r bg-muted px-3 text-sm text-muted-foreground">
+					NL
+				</span>
+
+				<Input
+					id={id}
+					type="text"
+					inputMode="numeric"
+					pattern="[0-9]*"
+					value={numberDraft}
+					onChange={(e) => {
+						const nextNumberDraft = String(e.target.value || "").replace(
+							/\D+/g,
+							"",
+						);
+						setNumberDraft(nextNumberDraft);
+						onBranchIdChange?.(formatBranchIdFromNumberInput(nextNumberDraft));
+					}}
+					disabled={disabled}
+					placeholder="z. B. 01"
+					className="h-full rounded-none border-0 shadow-none focus-visible:ring-0"
+					aria-invalid={invalid ? "true" : "false"}
+					aria-describedby={describedBy}
+				/>
+			</div>
+		</div>
+	);
+}

+ 6 - 0
components/admin/users/CreateUserDialog.jsx

@@ -24,8 +24,11 @@ export default function CreateUserDialog({ disabled = false, onCreated }) {
 		setPatch,
 		error,
 		policyLines,
+		branchesStatus,
+		branchExistence,
 		isSubmitting,
 		effectiveDisabled,
+		canSubmit,
 		handleSubmit,
 		handleOpenChange,
 	} = useCreateUserDialog({ disabled, onCreated });
@@ -53,8 +56,11 @@ export default function CreateUserDialog({ disabled = false, onCreated }) {
 					setPatch={setPatch}
 					error={error}
 					policyLines={policyLines}
+					branchesStatus={branchesStatus}
+					branchExistence={branchExistence}
 					isSubmitting={isSubmitting}
 					disabled={effectiveDisabled}
+					canSubmit={canSubmit}
 					onCancel={() => setOpen(false)}
 					onSubmit={handleSubmit}
 				/>

+ 35 - 14
components/admin/users/create-user/CreateUserForm.jsx

@@ -3,7 +3,7 @@
 import React from "react";
 import { Loader2 } from "lucide-react";
 
-import { normalizeBranchIdDraft } from "@/components/admin/users/usersUi";
+import BranchNumberInput from "@/components/admin/users/BranchNumberInput";
 import { CREATE_ROLE_OPTIONS } from "@/components/admin/users/create-user/createUserUtils";
 
 import { Button } from "@/components/ui/button";
@@ -57,12 +57,21 @@ export default function CreateUserForm({
 	setPatch,
 	error,
 	policyLines,
+	branchesStatus,
+	branchExistence,
 	disabled,
 	isSubmitting,
+	canSubmit,
 	onCancel,
 	onSubmit,
 }) {
 	const role = String(form?.role || "branch");
+	const branchMessageId = "cu-branch-message";
+	const showUnknownBranch =
+		role === "branch" && Boolean(branchExistence?.hasUnknownBranch);
+	const showFailOpenNote = role === "branch" && Boolean(branchExistence?.listError);
+	const showBranchLoading =
+		role === "branch" && !showUnknownBranch && branchesStatus === "loading";
 
 	return (
 		<form onSubmit={onSubmit} className="space-y-4">
@@ -122,14 +131,33 @@ export default function CreateUserForm({
 
 				{role === "branch" ? (
 					<div className="grid gap-2">
-						<Label htmlFor="cu-branch">Niederlassung</Label>
-						<Input
+						<BranchNumberInput
 							id="cu-branch"
-							value={form?.branchId ?? ""}
-							onChange={(e) => setPatch({ branchId: e.target.value })}
+							branchId={form?.branchId ?? ""}
+							onBranchIdChange={(branchId) => setPatch({ branchId })}
 							disabled={disabled}
-							placeholder="z. B. NL01"
+							invalid={showUnknownBranch}
+							describedBy={showUnknownBranch ? branchMessageId : undefined}
 						/>
+
+						{showUnknownBranch ? (
+							<p id={branchMessageId} className="text-xs text-destructive">
+								Diese Niederlassung ist nicht in der aktuellen Liste vorhanden.
+							</p>
+						) : null}
+
+						{showFailOpenNote ? (
+							<p className="text-xs text-muted-foreground">
+								Hinweis: Die Niederlassungsliste konnte nicht geladen werden.
+								Die Existenz kann aktuell nicht geprüft werden.
+							</p>
+						) : null}
+
+						{showBranchLoading ? (
+							<p className="text-xs text-muted-foreground">
+								Niederlassungsliste wird geladen…
+							</p>
+						) : null}
 					</div>
 				) : (
 					<div className="grid gap-2">
@@ -139,13 +167,6 @@ export default function CreateUserForm({
 				)}
 			</div>
 
-			{/* Keep branchId normalized as user types (optional UX polish) */}
-			{role === "branch" ? (
-				<div className="text-xs text-muted-foreground">
-					Aktuell: {normalizeBranchIdDraft(form?.branchId || "") || "—"}
-				</div>
-			) : null}
-
 			<div className="grid gap-2">
 				<Label htmlFor="cu-password">Initiales Passwort</Label>
 				<Input
@@ -174,7 +195,7 @@ export default function CreateUserForm({
 					Abbrechen
 				</Button>
 
-				<Button type="submit" disabled={disabled}>
+				<Button type="submit" disabled={disabled || !canSubmit}>
 					{isSubmitting ? (
 						<>
 							<Loader2 className="h-4 w-4 animate-spin" />

+ 46 - 7
components/admin/users/create-user/useCreateUserDialog.js

@@ -15,12 +15,16 @@ import {
 	reasonsToHintLinesDe,
 	buildWeakPasswordMessageDe,
 } from "@/lib/frontend/profile/passwordPolicyUi";
+import { useSearchBranches } from "@/lib/frontend/search/useSearchBranches";
+import {
+	evaluateBranchExistence,
+	normalizeBranchIdInput,
+	isValidBranchIdFormat,
+} from "@/lib/frontend/admin/users/userManagementUx";
 
-import { normalizeBranchIdDraft } from "@/components/admin/users/usersUi";
 import {
 	CREATE_ROLE_OPTIONS,
 	EMAIL_RE,
-	BRANCH_RE,
 	normalizeUsername,
 	normalizeEmail,
 } from "@/components/admin/users/create-user/createUserUtils";
@@ -41,8 +45,7 @@ function validateClient(form) {
 	const username = normalizeUsername(form?.username);
 	const email = normalizeEmail(form?.email);
 	const role = String(form?.role || "").trim();
-	const branchId =
-		role === "branch" ? normalizeBranchIdDraft(form?.branchId) : "";
+	const branchId = role === "branch" ? normalizeBranchIdInput(form?.branchId) : "";
 	const initialPassword = String(form?.initialPassword || "");
 
 	if (!username) return { title: "Benutzername fehlt.", description: null };
@@ -65,7 +68,7 @@ function validateClient(form) {
 				description: "Für Niederlassungs-User ist eine NL erforderlich.",
 			};
 		}
-		if (!BRANCH_RE.test(branchId)) {
+		if (!isValidBranchIdFormat(branchId)) {
 			return {
 				title: "Niederlassung ist ungültig.",
 				description: "Format: NL01, NL02, ...",
@@ -132,8 +135,23 @@ export function useCreateUserDialog({ disabled = false, onCreated } = {}) {
 	const [error, setError] = React.useState(null);
 
 	const policyLines = React.useMemo(() => getPasswordPolicyHintLinesDe(), []);
+	const role = String(form?.role || "branch").trim();
+	const shouldLoadBranches = open && role === "branch";
+	const { status: branchesStatus, branches: availableBranchIds } =
+		useSearchBranches({ enabled: shouldLoadBranches });
 
 	const effectiveDisabled = Boolean(disabled || isSubmitting);
+	const branchExistence = React.useMemo(
+		() =>
+			evaluateBranchExistence({
+				role,
+				branchId: form?.branchId,
+				branchesStatus,
+				availableBranchIds,
+			}),
+		[role, form?.branchId, branchesStatus, availableBranchIds],
+	);
+	const canSubmit = !effectiveDisabled && !branchExistence.shouldBlockSubmit;
 
 	const setPatch = React.useCallback((patch) => {
 		setForm((prev) => ({ ...prev, ...(patch || {}) }));
@@ -180,6 +198,17 @@ export function useCreateUserDialog({ disabled = false, onCreated } = {}) {
 				return;
 			}
 
+			if (branchExistence.shouldBlockSubmit) {
+				const mapped = {
+					title: "Niederlassung existiert nicht.",
+					description:
+						"Die gewählte Niederlassung ist nicht in der aktuellen Liste vorhanden.",
+				};
+				setError(mapped);
+				notifyError(mapped);
+				return;
+			}
+
 			setIsSubmitting(true);
 
 			try {
@@ -188,7 +217,7 @@ export function useCreateUserDialog({ disabled = false, onCreated } = {}) {
 				const role = String(form.role || "").trim();
 
 				const branchId =
-					role === "branch" ? normalizeBranchIdDraft(form.branchId) : null;
+					role === "branch" ? normalizeBranchIdInput(form.branchId) : null;
 
 				const initialPassword = String(form.initialPassword || "");
 
@@ -276,7 +305,14 @@ export function useCreateUserDialog({ disabled = false, onCreated } = {}) {
 				setIsSubmitting(false);
 			}
 		},
-		[effectiveDisabled, form, onCreated, redirectToLoginExpired, resetForm],
+		[
+			effectiveDisabled,
+			form,
+			onCreated,
+			redirectToLoginExpired,
+			resetForm,
+			branchExistence.shouldBlockSubmit,
+		],
 	);
 
 	return {
@@ -286,8 +322,11 @@ export function useCreateUserDialog({ disabled = false, onCreated } = {}) {
 		setPatch,
 		error,
 		policyLines,
+		branchesStatus,
+		branchExistence,
 		isSubmitting,
 		effectiveDisabled,
+		canSubmit,
 		handleSubmit,
 		handleOpenChange,
 	};

+ 33 - 17
components/admin/users/edit-user/EditUserForm.jsx

@@ -1,14 +1,9 @@
-// ---------------------------------------------------------------------------
-// Ordner: components/admin/users/edit-user
-// Datei: EditUserForm.jsx
-// Relativer Pfad: components/admin/users/edit-user/EditUserForm.jsx
-// ---------------------------------------------------------------------------
 "use client";
 
 import React from "react";
 import { Loader2 } from "lucide-react";
 
-import { normalizeBranchIdDraft } from "@/components/admin/users/usersUi";
+import BranchNumberInput from "@/components/admin/users/BranchNumberInput";
 import { EDIT_ROLE_OPTIONS } from "@/components/admin/users/edit-user/editUserUtils";
 
 import { Button } from "@/components/ui/button";
@@ -64,6 +59,8 @@ export default function EditUserForm({
 	form,
 	setPatch,
 	error,
+	branchesStatus,
+	branchExistence,
 	disabled,
 	isSubmitting,
 	canSubmit,
@@ -72,6 +69,12 @@ export default function EditUserForm({
 }) {
 	const role = String(form?.role || "branch");
 	const userId = String(user?.id || "");
+	const branchMessageId = "eu-branch-message";
+	const showUnknownBranch =
+		role === "branch" && Boolean(branchExistence?.hasUnknownBranch);
+	const showFailOpenNote = role === "branch" && Boolean(branchExistence?.listError);
+	const showBranchLoading =
+		role === "branch" && !showUnknownBranch && branchesStatus === "loading";
 
 	return (
 		<form onSubmit={onSubmit} className="space-y-4">
@@ -135,14 +138,33 @@ export default function EditUserForm({
 
 				{role === "branch" ? (
 					<div className="grid gap-2">
-						<Label htmlFor="eu-branch">Niederlassung</Label>
-						<Input
+						<BranchNumberInput
 							id="eu-branch"
-							value={form?.branchId ?? ""}
-							onChange={(e) => setPatch({ branchId: e.target.value })}
+							branchId={form?.branchId ?? ""}
+							onBranchIdChange={(branchId) => setPatch({ branchId })}
 							disabled={disabled}
-							placeholder="z. B. NL01"
+							invalid={showUnknownBranch}
+							describedBy={showUnknownBranch ? branchMessageId : undefined}
 						/>
+
+						{showUnknownBranch ? (
+							<p id={branchMessageId} className="text-xs text-destructive">
+								Diese Niederlassung ist nicht in der aktuellen Liste vorhanden.
+							</p>
+						) : null}
+
+						{showFailOpenNote ? (
+							<p className="text-xs text-muted-foreground">
+								Hinweis: Die Niederlassungsliste konnte nicht geladen werden.
+								Die Existenz kann aktuell nicht geprüft werden.
+							</p>
+						) : null}
+
+						{showBranchLoading ? (
+							<p className="text-xs text-muted-foreground">
+								Niederlassungsliste wird geladen…
+							</p>
+						) : null}
 					</div>
 				) : (
 					<div className="grid gap-2">
@@ -152,12 +174,6 @@ export default function EditUserForm({
 				)}
 			</div>
 
-			{role === "branch" ? (
-				<div className="text-xs text-muted-foreground">
-					Aktuell: {normalizeBranchIdDraft(form?.branchId || "") || "—"}
-				</div>
-			) : null}
-
 			<div className="flex items-start gap-3 rounded-lg border p-3">
 				<Checkbox
 					checked={Boolean(form?.mustChangePassword)}

+ 44 - 12
components/admin/users/edit-user/useEditUserDialog.js

@@ -1,14 +1,15 @@
-// ---------------------------------------------------------------------------
-// Ordner: components/admin/users/edit-user
-// Datei: useEditUserDialog.js
-// Relativer Pfad: components/admin/users/edit-user/useEditUserDialog.js
-// ---------------------------------------------------------------------------
 "use client";
 
 import React from "react";
 
 import { adminUpdateUser, ApiClientError } from "@/lib/frontend/apiClient";
 import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
+import { useSearchBranches } from "@/lib/frontend/search/useSearchBranches";
+import {
+	evaluateBranchExistence,
+	isValidBranchIdFormat,
+	normalizeBranchIdInput,
+} from "@/lib/frontend/admin/users/userManagementUx";
 import {
 	notifySuccess,
 	notifyError,
@@ -16,11 +17,9 @@ import {
 	notifyInfo,
 } from "@/lib/frontend/ui/toast";
 
-import { normalizeBranchIdDraft } from "@/components/admin/users/usersUi";
 import {
 	EDIT_ROLE_OPTIONS,
 	EMAIL_RE,
-	BRANCH_RE,
 	normalizeUsername,
 	normalizeEmail,
 } from "@/components/admin/users/edit-user/editUserUtils";
@@ -47,7 +46,7 @@ function validateClient(form) {
 	const isKnownRole = EDIT_ROLE_OPTIONS.some((x) => x.value === role);
 
 	const branchId =
-		role === "branch" ? normalizeBranchIdDraft(form?.branchId || "") : "";
+		role === "branch" ? normalizeBranchIdInput(form?.branchId || "") : "";
 
 	if (!username) return { title: "Benutzername fehlt.", description: null };
 	if (!email) return { title: "E-Mail fehlt.", description: null };
@@ -70,7 +69,7 @@ function validateClient(form) {
 				description: "Für Niederlassungs-User ist eine NL erforderlich.",
 			};
 		}
-		if (!BRANCH_RE.test(branchId)) {
+		if (!isValidBranchIdFormat(branchId)) {
 			return {
 				title: "Niederlassung ist ungültig.",
 				description: "Format: NL01, NL02, ...",
@@ -113,7 +112,7 @@ function buildNormalizedForm(form) {
 		email: normalizeEmail(form?.email),
 		role,
 		branchId:
-			role === "branch" ? normalizeBranchIdDraft(form?.branchId || "") : "",
+			role === "branch" ? normalizeBranchIdInput(form?.branchId || "") : "",
 		mustChangePassword: Boolean(form?.mustChangePassword),
 	};
 }
@@ -163,7 +162,22 @@ export function useEditUserDialog({ user, disabled = false, onUpdated } = {}) {
 	const [form, setForm] = React.useState(() => buildInitialFormFromUser(user));
 	const [error, setError] = React.useState(null);
 
+	const role = String(form?.role || "branch").trim();
+	const shouldLoadBranches = open && role === "branch";
+	const { status: branchesStatus, branches: availableBranchIds } =
+		useSearchBranches({ enabled: shouldLoadBranches });
+
 	const effectiveDisabled = Boolean(disabled || isSubmitting || !user?.id);
+	const branchExistence = React.useMemo(
+		() =>
+			evaluateBranchExistence({
+				role,
+				branchId: form?.branchId,
+				branchesStatus,
+				availableBranchIds,
+			}),
+		[role, form?.branchId, branchesStatus, availableBranchIds],
+	);
 
 	const patchPreview = React.useMemo(() => {
 		return buildPatch({ user, form });
@@ -182,8 +196,12 @@ export function useEditUserDialog({ user, disabled = false, onUpdated } = {}) {
 	]);
 
 	const canSubmit = React.useMemo(() => {
-		return !effectiveDisabled && Object.keys(patchPreview).length > 0;
-	}, [effectiveDisabled, patchPreview]);
+		return (
+			!effectiveDisabled &&
+			Object.keys(patchPreview).length > 0 &&
+			!branchExistence.shouldBlockSubmit
+		);
+	}, [effectiveDisabled, patchPreview, branchExistence.shouldBlockSubmit]);
 
 	const setPatch = React.useCallback((patch) => {
 		setForm((prev) => ({ ...prev, ...(patch || {}) }));
@@ -238,6 +256,17 @@ export function useEditUserDialog({ user, disabled = false, onUpdated } = {}) {
 				return;
 			}
 
+			if (branchExistence.shouldBlockSubmit) {
+				const mapped = {
+					title: "Niederlassung existiert nicht.",
+					description:
+						"Die gewählte Niederlassung ist nicht in der aktuellen Liste vorhanden.",
+				};
+				setError(mapped);
+				notifyError(mapped);
+				return;
+			}
+
 			const patch = buildPatch({ user, form });
 
 			if (Object.keys(patch).length === 0) {
@@ -342,6 +371,7 @@ export function useEditUserDialog({ user, disabled = false, onUpdated } = {}) {
 			onUpdated,
 			redirectToLoginExpired,
 			resetForm,
+			branchExistence.shouldBlockSubmit,
 		],
 	);
 
@@ -351,6 +381,8 @@ export function useEditUserDialog({ user, disabled = false, onUpdated } = {}) {
 		form,
 		setPatch,
 		error,
+		branchesStatus,
+		branchExistence,
 		isSubmitting,
 		effectiveDisabled,
 		canSubmit,