5 Commits 61032b1c10 ... fe0efa279c

Autor SHA1 Mensagem Data
  Code_Uwe fe0efa279c RHL-043 style(admin-users): polish sticky actions and table column sizing 1 mês atrás
  Code_Uwe 0526aa1c9a RHL-043 feat(admin-users): show loaded user count in filter toolbar 1 mês atrás
  Code_Uwe 261f5225a4 RHL-043 feat(admin-users): require username confirmation for deletes 1 mês atrás
  Code_Uwe 22707b3bba RHL-043 feat(admin-users): harden branch create and edit flows 1 mês atrás
  Code_Uwe 0f5d54d0b0 RHL-043 feat(admin-users): add user-management ux helper utilities 1 mês atrás

+ 2 - 7
components/admin/users/AdminUsersClient.jsx

@@ -111,13 +111,7 @@ export default function AdminUsersClient() {
 				title="Benutzer"
 				description="Suche und Filter anwenden."
 				headerRight={
-					<div className="flex items-center gap-2">
-						<span className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
-							{items.length} Benutzer geladen
-						</span>
-
-						<CreateUserDialog disabled={disabled} onCreated={refresh} />
-					</div>
+					<CreateUserDialog disabled={disabled} onCreated={refresh} />
 				}
 			>
 				<div className="space-y-4">
@@ -126,6 +120,7 @@ export default function AdminUsersClient() {
 						onDraftChange={onDraftChange}
 						onApply={applyFilters}
 						onReset={resetFilters}
+						loadedCount={items.length}
 						disabled={disabled}
 					/>
 

+ 23 - 12
components/admin/users/AdminUsersFilters.jsx

@@ -58,12 +58,17 @@ export default function AdminUsersFilters({
 	onDraftChange,
 	onApply,
 	onReset,
+	loadedCount = 0,
 	disabled,
 }) {
 	const q = draft?.q ?? "";
 	const role = draft?.role ?? "";
 	const branchId = draft?.branchId ?? "";
 
+	const safeLoadedCount = Number.isFinite(loadedCount)
+		? Math.max(0, loadedCount)
+		: 0;
+
 	return (
 		<div className="space-y-3">
 			<div className="grid gap-3 md:grid-cols-3">
@@ -96,19 +101,25 @@ export default function AdminUsersFilters({
 				</div>
 			</div>
 
-			<div className="flex flex-wrap gap-2">
-				<Button type="button" onClick={onApply} disabled={disabled}>
-					Anwenden
-				</Button>
+			<div className="flex flex-wrap items-center justify-between gap-2">
+				<div className="flex flex-wrap gap-2">
+					<Button type="button" onClick={onApply} disabled={disabled}>
+						Anwenden
+					</Button>
 
-				<Button
-					type="button"
-					variant="outline"
-					onClick={onReset}
-					disabled={disabled}
-				>
-					Zurücksetzen
-				</Button>
+					<Button
+						type="button"
+						variant="outline"
+						onClick={onReset}
+						disabled={disabled}
+					>
+						Zurücksetzen
+					</Button>
+				</div>
+
+				<span className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
+					{safeLoadedCount} Benutzer geladen
+				</span>
 			</div>
 		</div>
 	);

+ 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}
 				/>

+ 41 - 4
components/admin/users/DeleteUserDialog.jsx

@@ -14,9 +14,12 @@ import {
 } from "@/components/ui/dialog";
 import { Button } from "@/components/ui/button";
 import { Badge } from "@/components/ui/badge";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
 
 import { adminDeleteUser, ApiClientError } from "@/lib/frontend/apiClient";
 import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
+import { isUsernameConfirmationMatch } from "@/lib/frontend/admin/users/userManagementUx";
 import {
 	notifySuccess,
 	notifyError,
@@ -39,8 +42,13 @@ export default function DeleteUserDialog({
 	const [open, setOpen] = React.useState(false);
 	const [isSubmitting, setIsSubmitting] = React.useState(false);
 	const [error, setError] = React.useState(null);
+	const [typedUsername, setTypedUsername] = React.useState("");
 
 	const effectiveDisabled = Boolean(disabled || isSubmitting);
+	const canConfirmDelete = isUsernameConfirmationMatch({
+		expectedUsername: user?.username,
+		typedUsername,
+	});
 
 	const redirectToLoginExpired = React.useCallback(() => {
 		const next =
@@ -55,12 +63,15 @@ export default function DeleteUserDialog({
 
 	const handleOpenChange = React.useCallback((nextOpen) => {
 		setOpen(nextOpen);
-		if (!nextOpen) setError(null);
+		if (!nextOpen) {
+			setError(null);
+			setTypedUsername("");
+		}
 	}, []);
 
 	const handleDelete = React.useCallback(async () => {
 		if (!user?.id) return;
-		if (effectiveDisabled) return;
+		if (effectiveDisabled || !canConfirmDelete) return;
 
 		setError(null);
 		setIsSubmitting(true);
@@ -132,7 +143,7 @@ export default function DeleteUserDialog({
 		} finally {
 			setIsSubmitting(false);
 		}
-	}, [user, effectiveDisabled, onDeleted, redirectToLoginExpired]);
+	}, [user, effectiveDisabled, canConfirmDelete, onDeleted, redirectToLoginExpired]);
 
 	if (!user || typeof user.id !== "string" || !user.id) return null;
 
@@ -177,6 +188,32 @@ export default function DeleteUserDialog({
 						</div>
 					</div>
 
+					<div className="space-y-2 rounded-lg border border-destructive/50 bg-destructive/5 p-3">
+						<p className="text-sm font-medium text-destructive">
+							Achtung: Dieser Benutzer wird dauerhaft gelöscht.
+						</p>
+						<p className="text-sm text-muted-foreground">
+							Bitte geben Sie den Benutzernamen{" "}
+							<strong>{user.username}</strong> ein, um das Löschen zu
+							bestätigen.
+						</p>
+						<div className="grid gap-2">
+							<Label htmlFor="du-confirm-username">
+								Bitte Benutzernamen eingeben, um zu bestätigen
+							</Label>
+							<Input
+								id="du-confirm-username"
+								value={typedUsername}
+								onChange={(e) => setTypedUsername(e.target.value)}
+								disabled={effectiveDisabled}
+								autoCapitalize="none"
+								autoCorrect="off"
+								spellCheck={false}
+								placeholder={String(user.username || "")}
+							/>
+						</div>
+					</div>
+
 					{error ? (
 						<div className="rounded-lg border border-destructive/40 bg-card p-3">
 							<p className="text-sm font-medium text-destructive">
@@ -204,7 +241,7 @@ export default function DeleteUserDialog({
 					<Button
 						type="button"
 						variant="destructive"
-						disabled={effectiveDisabled}
+						disabled={effectiveDisabled || !canConfirmDelete}
 						onClick={handleDelete}
 						title="Endgültig löschen"
 					>

+ 7 - 8
components/admin/users/EditUserDialog.jsx

@@ -1,8 +1,3 @@
-// ---------------------------------------------------------------------------
-// Ordner: components/admin/users
-// Datei: EditUserDialog.jsx
-// Relativer Pfad: components/admin/users/EditUserDialog.jsx
-// ---------------------------------------------------------------------------
 "use client";
 
 import React from "react";
@@ -28,6 +23,8 @@ export default function EditUserDialog({ user, disabled = false, onUpdated }) {
 		form,
 		setPatch,
 		error,
+		branchesStatus,
+		branchExistence,
 		isSubmitting,
 		effectiveDisabled,
 		canSubmit,
@@ -43,12 +40,12 @@ export default function EditUserDialog({ user, disabled = false, onUpdated }) {
 				<Button
 					type="button"
 					variant="outline"
-					size="sm"
+					size="icon-sm"
 					disabled={disabled}
 					title="Benutzer bearbeiten"
+					aria-label="Benutzer bearbeiten"
 				>
-					<Pencil className="h-4 w-4" />
-					Bearbeiten
+					<Pencil className="h-4 w-4 text-amber-500 dark:text-amber-400" />
 				</Button>
 			</DialogTrigger>
 
@@ -65,6 +62,8 @@ export default function EditUserDialog({ user, disabled = false, onUpdated }) {
 					form={form}
 					setPatch={setPatch}
 					error={error}
+					branchesStatus={branchesStatus}
+					branchExistence={branchExistence}
 					isSubmitting={isSubmitting}
 					disabled={effectiveDisabled}
 					canSubmit={canSubmit}

+ 10 - 8
components/admin/users/UsersTable.jsx

@@ -22,16 +22,18 @@ export default function UsersTable({ items, disabled = false, onUserUpdated }) {
 	const list = Array.isArray(items) ? items : [];
 
 	return (
-		<Table className="table-fixed">
+		<Table className="min-w-[76rem] table-fixed">
 			<TableHeader>
 				<TableRow>
 					<TableHead className="w-44">Benutzername</TableHead>
-					<TableHead>E-Mail</TableHead>
-					<TableHead className="w-36">Rolle</TableHead>
-					<TableHead className="w-32">NL</TableHead>
+					<TableHead className="w-56">E-Mail</TableHead>
+					<TableHead className="w-40">Rolle</TableHead>
+					<TableHead className="w-20">NL</TableHead>
 					<TableHead className="w-40">Passwortwechsel</TableHead>
-					<TableHead className="w-56">Aktualisiert</TableHead>
-					<TableHead className="w-44 text-right">Aktion</TableHead>
+					<TableHead className="w-40">Aktualisiert</TableHead>
+					<TableHead className="sticky right-0 z-20 w-20 bg-card text-right">
+						Aktion
+					</TableHead>
 				</TableRow>
 			</TableHeader>
 
@@ -77,8 +79,8 @@ export default function UsersTable({ items, disabled = false, onUserUpdated }) {
 								{formatDateTimeDe(u.updatedAt)}
 							</TableCell>
 
-							<TableCell className="text-right">
-								<div className="flex items-center justify-end gap-2">
+							<TableCell className="sticky right-0 z-10 w-20 bg-card text-right">
+								<div className="flex items-center justify-end gap-1">
 									<EditUserDialog
 										user={u}
 										disabled={disabled}

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

+ 3 - 2
components/admin/users/usersUi.js

@@ -1,3 +1,5 @@
+import { normalizeBranchIdInput } from "@/lib/frontend/admin/users/userManagementUx";
+
 export const ROLE_LABELS_DE = Object.freeze({
 	branch: "Niederlassung",
 	admin: "Admin",
@@ -23,6 +25,5 @@ export function formatDateTimeDe(iso) {
 }
 
 export function normalizeBranchIdDraft(value) {
-	if (typeof value !== "string") return "";
-	return value.trim().toUpperCase();
+	return normalizeBranchIdInput(value);
 }

+ 100 - 0
lib/frontend/admin/users/userManagementUx.js

@@ -0,0 +1,100 @@
+const BRANCH_ID_RE = /^NL\d+$/;
+const BRANCH_ID_CAPTURE_RE = /^NL(\d+)$/i;
+
+function normalizeComparableText(value) {
+	return String(value ?? "")
+		.trim()
+		.toLowerCase();
+}
+
+export function normalizeUsernameForConfirmation(value) {
+	return normalizeComparableText(value);
+}
+
+export function isUsernameConfirmationMatch({
+	expectedUsername,
+	typedUsername,
+}) {
+	const expected = normalizeUsernameForConfirmation(expectedUsername);
+	const typed = normalizeUsernameForConfirmation(typedUsername);
+	return Boolean(expected) && expected === typed;
+}
+
+export function normalizeBranchIdInput(value) {
+	return String(value ?? "")
+		.trim()
+		.toUpperCase();
+}
+
+export function normalizeBranchNumberInput(value) {
+	const raw = String(value ?? "").trim();
+	if (!raw) return "";
+
+	const digits = raw.replace(/\D+/g, "");
+	if (!digits) return "";
+
+	// Keep "0" as a valid numeric value but collapse leading zeros.
+	return digits.replace(/^0+(?=\d)/, "");
+}
+
+export function formatBranchIdFromNumberInput(value) {
+	const numberPart = normalizeBranchNumberInput(value);
+	if (!numberPart) return "";
+	return `NL${numberPart.padStart(2, "0")}`;
+}
+
+export function extractBranchNumberInputFromBranchId(branchId) {
+	const match = BRANCH_ID_CAPTURE_RE.exec(normalizeBranchIdInput(branchId));
+	if (!match) return "";
+	return match[1];
+}
+
+export function isValidBranchIdFormat(value) {
+	return BRANCH_ID_RE.test(normalizeBranchIdInput(value));
+}
+
+/**
+ * Decides branch existence UX state for branch-role create/edit forms.
+ *
+ * Rules:
+ * - Only relevant when role is "branch" and a branchId was entered.
+ * - If branch list is available (status=ready), unknown branchIds block submit.
+ * - If branch list failed to load (status=error), do not block submit (fail-open).
+ */
+export function evaluateBranchExistence({
+	role,
+	branchId,
+	branchesStatus,
+	availableBranchIds,
+}) {
+	const isBranchRole = String(role ?? "").trim() === "branch";
+	const normalizedBranchId = normalizeBranchIdInput(branchId);
+	const hasBranchId = Boolean(normalizedBranchId);
+
+	const listReady =
+		branchesStatus === "ready" && Array.isArray(availableBranchIds);
+	const listError = branchesStatus === "error";
+
+	const normalizedAvailable = listReady
+		? availableBranchIds.map(normalizeBranchIdInput).filter(Boolean)
+		: [];
+
+	const branchExists =
+		isBranchRole && hasBranchId && listReady
+			? normalizedAvailable.includes(normalizedBranchId)
+			: null;
+
+	const hasUnknownBranch =
+		isBranchRole && hasBranchId && listReady && branchExists === false;
+
+	return {
+		isBranchRole,
+		normalizedBranchId,
+		hasBranchId,
+		listReady,
+		listError,
+		branchExists,
+		hasUnknownBranch,
+		shouldBlockSubmit: Boolean(hasUnknownBranch),
+	};
+}

+ 128 - 0
lib/frontend/admin/users/userManagementUx.test.js

@@ -0,0 +1,128 @@
+/* @vitest-environment node */
+
+import { describe, it, expect } from "vitest";
+import {
+	normalizeUsernameForConfirmation,
+	isUsernameConfirmationMatch,
+	normalizeBranchIdInput,
+	normalizeBranchNumberInput,
+	formatBranchIdFromNumberInput,
+	extractBranchNumberInputFromBranchId,
+	isValidBranchIdFormat,
+	evaluateBranchExistence,
+} from "./userManagementUx.js";
+
+describe("lib/frontend/admin/users/userManagementUx", () => {
+	describe("username confirmation", () => {
+		it("normalizes to lowercase and trims", () => {
+			expect(normalizeUsernameForConfirmation("  Alice.Admin  ")).toBe(
+				"alice.admin",
+			);
+		});
+
+		it("matches typed username case-insensitively after trimming", () => {
+			expect(
+				isUsernameConfirmationMatch({
+					expectedUsername: "branch.user",
+					typedUsername: "  BRANCH.USER ",
+				}),
+			).toBe(true);
+		});
+
+		it("returns false for empty or mismatching values", () => {
+			expect(
+				isUsernameConfirmationMatch({
+					expectedUsername: "branch.user",
+					typedUsername: "other.user",
+				}),
+			).toBe(false);
+
+			expect(
+				isUsernameConfirmationMatch({
+					expectedUsername: "",
+					typedUsername: "",
+				}),
+			).toBe(false);
+		});
+	});
+
+	describe("branch formatting", () => {
+		it("normalizes branchId input to uppercase + trim", () => {
+			expect(normalizeBranchIdInput(" nl01 ")).toBe("NL01");
+		});
+
+		it("normalizes numeric branch input and strips non-digits", () => {
+			expect(normalizeBranchNumberInput(" 001 ")).toBe("1");
+			expect(normalizeBranchNumberInput(" 32a ")).toBe("32");
+			expect(normalizeBranchNumberInput("000")).toBe("0");
+			expect(normalizeBranchNumberInput("abc")).toBe("");
+		});
+
+		it("formats NL branchId with 2+ digit policy", () => {
+			expect(formatBranchIdFromNumberInput("1")).toBe("NL01");
+			expect(formatBranchIdFromNumberInput("32")).toBe("NL32");
+			expect(formatBranchIdFromNumberInput("200")).toBe("NL200");
+		});
+
+		it("extracts numeric branch input from an existing branchId", () => {
+			expect(extractBranchNumberInputFromBranchId("NL01")).toBe("01");
+			expect(extractBranchNumberInputFromBranchId("nl200")).toBe("200");
+			expect(extractBranchNumberInputFromBranchId("XX1")).toBe("");
+		});
+
+		it("validates branchId format", () => {
+			expect(isValidBranchIdFormat("NL01")).toBe(true);
+			expect(isValidBranchIdFormat("nl200")).toBe(true);
+			expect(isValidBranchIdFormat("XX1")).toBe(false);
+			expect(isValidBranchIdFormat("NL")).toBe(false);
+		});
+	});
+
+	describe("branch existence decision", () => {
+		it("blocks submit for unknown branch only when list is ready", () => {
+			expect(
+				evaluateBranchExistence({
+					role: "branch",
+					branchId: "NL99",
+					branchesStatus: "ready",
+					availableBranchIds: ["NL01", "NL02"],
+				}).shouldBlockSubmit,
+			).toBe(true);
+		});
+
+		it("allows submit when branch exists in ready list", () => {
+			expect(
+				evaluateBranchExistence({
+					role: "branch",
+					branchId: "NL02",
+					branchesStatus: "ready",
+					availableBranchIds: ["NL01", "NL02"],
+				}).shouldBlockSubmit,
+			).toBe(false);
+		});
+
+		it("is fail-open when list fetch failed", () => {
+			const result = evaluateBranchExistence({
+				role: "branch",
+				branchId: "NL99",
+				branchesStatus: "error",
+				availableBranchIds: null,
+			});
+
+			expect(result.listError).toBe(true);
+			expect(result.shouldBlockSubmit).toBe(false);
+		});
+
+		it("is irrelevant for non-branch roles", () => {
+			const result = evaluateBranchExistence({
+				role: "admin",
+				branchId: "NL99",
+				branchesStatus: "ready",
+				availableBranchIds: ["NL01"],
+			});
+
+			expect(result.isBranchRole).toBe(false);
+			expect(result.shouldBlockSubmit).toBe(false);
+		});
+	});
+});