Эх сурвалжийг харах

RHL-037 feat(search): refactor SearchForm and add new components for improved search functionality

Code_Uwe 3 долоо хоног өмнө
parent
commit
24b23af433

+ 41 - 192
components/search/SearchForm.jsx

@@ -1,33 +1,14 @@
 "use client";
 
 import React from "react";
-import { ChevronDown, Search } from "lucide-react";
 
-import {
-	SEARCH_SCOPE,
-	SEARCH_LIMITS,
-	DEFAULT_SEARCH_LIMIT,
-} from "@/lib/frontend/search/urlState";
+import { SEARCH_SCOPE } from "@/lib/frontend/search/urlState";
 
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Skeleton } from "@/components/ui/skeleton";
-import {
-	DropdownMenu,
-	DropdownMenuContent,
-	DropdownMenuLabel,
-	DropdownMenuRadioGroup,
-	DropdownMenuRadioItem,
-	DropdownMenuSeparator,
-	DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-
-const SCOPE_LABELS = Object.freeze({
-	[SEARCH_SCOPE.SINGLE]: "Diese Niederlassung",
-	[SEARCH_SCOPE.MULTI]: "Mehrere Niederlassungen",
-	[SEARCH_SCOPE.ALL]: "Alle Niederlassungen",
-});
+import SearchQueryBox from "@/components/search/form/SearchQueryBox";
+import SearchScopeSelect from "@/components/search/form/SearchScopeSelect";
+import SearchLimitSelect from "@/components/search/form/SearchLimitSelect";
+import SearchSingleBranchCombobox from "@/components/search/form/SearchSingleBranchCombobox";
+import SearchMultiBranchPicker from "@/components/search/form/SearchMultiBranchPicker";
 
 export default function SearchForm({
 	branch,
@@ -39,6 +20,7 @@ export default function SearchForm({
 	isAdminDev,
 	scope,
 	onScopeChange,
+	onSingleBranchChange,
 	availableBranches,
 	branchesStatus,
 	selectedBranches,
@@ -48,13 +30,6 @@ export default function SearchForm({
 }) {
 	const canSearch = typeof qDraft === "string" && qDraft.trim().length > 0;
 
-	const scopeLabel = SCOPE_LABELS[scope] || "Unbekannt";
-
-	const normalizedLimit =
-		Number.isInteger(limit) && SEARCH_LIMITS.includes(limit)
-			? limit
-			: DEFAULT_SEARCH_LIMIT;
-
 	return (
 		<div className="space-y-4">
 			<form
@@ -65,177 +40,51 @@ export default function SearchForm({
 				}}
 				className="space-y-3"
 			>
-				<div className="grid gap-2">
-					<Label htmlFor="q">Suchbegriff</Label>
-
-					<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
-						<Input
-							id="q"
-							name="q"
-							value={qDraft}
-							onChange={(e) => onQDraftChange(e.target.value)}
-							placeholder="z. B. Bridgestone, Rechnung, Kundennummer…"
-							disabled={isSubmitting}
-						/>
-
-						<Button type="submit" disabled={!canSearch || isSubmitting}>
-							<Search className="h-4 w-4" />
-							Suchen
-						</Button>
-					</div>
-
-					{currentQuery ? (
-						<div className="text-xs text-muted-foreground">
-							Aktuelle Suche:{" "}
-							<span className="ml-1 rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
-								{currentQuery}
-							</span>
-						</div>
-					) : (
-						<div className="text-xs text-muted-foreground">
-							Tipp: Die Suche ist URL-basiert und kann als Link geteilt werden.
-						</div>
-					)}
-				</div>
+				<SearchQueryBox
+					qDraft={qDraft}
+					onQDraftChange={onQDraftChange}
+					onSubmit={onSubmit}
+					currentQuery={currentQuery}
+					isSubmitting={isSubmitting}
+					canSearch={canSearch}
+				/>
 
 				<div className="flex flex-wrap items-center gap-2">
 					{isAdminDev ? (
-						<div className="grid gap-2">
-							<Label>Suchbereich</Label>
-
-							<DropdownMenu>
-								<DropdownMenuTrigger asChild>
-									<Button
-										type="button"
-										variant="outline"
-										disabled={isSubmitting}
-										title="Suchbereich auswählen"
-									>
-										{scopeLabel}
-										<ChevronDown className="h-4 w-4" />
-									</Button>
-								</DropdownMenuTrigger>
-
-								<DropdownMenuContent align="start" className="min-w-[16rem]">
-									<DropdownMenuLabel>Suchbereich</DropdownMenuLabel>
-									<DropdownMenuSeparator />
-
-									<DropdownMenuRadioGroup
-										value={scope}
-										onValueChange={(value) => onScopeChange(value)}
-									>
-										<DropdownMenuRadioItem value={SEARCH_SCOPE.SINGLE}>
-											Diese Niederlassung ({branch})
-										</DropdownMenuRadioItem>
-										<DropdownMenuRadioItem value={SEARCH_SCOPE.MULTI}>
-											Mehrere Niederlassungen
-										</DropdownMenuRadioItem>
-										<DropdownMenuRadioItem value={SEARCH_SCOPE.ALL}>
-											Alle Niederlassungen
-										</DropdownMenuRadioItem>
-									</DropdownMenuRadioGroup>
-								</DropdownMenuContent>
-							</DropdownMenu>
-						</div>
+						<SearchScopeSelect
+							branch={branch}
+							scope={scope}
+							onScopeChange={onScopeChange}
+							isSubmitting={isSubmitting}
+						/>
 					) : null}
 
-					<div className="grid gap-2">
-						<Label>Treffer pro Seite</Label>
-
-						<DropdownMenu>
-							<DropdownMenuTrigger asChild>
-								<Button
-									type="button"
-									variant="outline"
-									disabled={isSubmitting}
-									title="Treffer pro Seite auswählen"
-								>
-									{normalizedLimit}
-									<ChevronDown className="h-4 w-4" />
-								</Button>
-							</DropdownMenuTrigger>
-
-							<DropdownMenuContent align="start" className="min-w-[12rem]">
-								<DropdownMenuLabel>Treffer pro Seite</DropdownMenuLabel>
-								<DropdownMenuSeparator />
-
-								<DropdownMenuRadioGroup
-									value={String(normalizedLimit)}
-									onValueChange={(value) => {
-										const n = Number(value);
-										if (!Number.isInteger(n)) return;
-										onLimitChange(n);
-									}}
-								>
-									{SEARCH_LIMITS.map((n) => (
-										<DropdownMenuRadioItem key={n} value={String(n)}>
-											{n}
-										</DropdownMenuRadioItem>
-									))}
-								</DropdownMenuRadioGroup>
-							</DropdownMenuContent>
-						</DropdownMenu>
-					</div>
-
 					{isAdminDev && scope === SEARCH_SCOPE.SINGLE ? (
-						<span className="pt-6 text-xs text-muted-foreground">
-							Aktiv: {branch}
-						</span>
+						<SearchSingleBranchCombobox
+							branch={branch}
+							branchesStatus={branchesStatus}
+							availableBranches={availableBranches}
+							onSingleBranchChange={onSingleBranchChange}
+							isSubmitting={isSubmitting}
+						/>
 					) : null}
+
+					<SearchLimitSelect
+						limit={limit}
+						onLimitChange={onLimitChange}
+						isSubmitting={isSubmitting}
+					/>
 				</div>
 			</form>
 
 			{isAdminDev && scope === SEARCH_SCOPE.MULTI ? (
-				<div className="rounded-lg border bg-card p-4 text-card-foreground shadow-sm">
-					<div className="space-y-2">
-						<p className="text-sm font-medium">Niederlassungen auswählen</p>
-						<p className="text-xs text-muted-foreground">
-							Wählen Sie eine oder mehrere Niederlassungen. Die Suche wird nach
-							jeder Änderung aktualisiert.
-						</p>
-
-						{branchesStatus === "loading" ? (
-							<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
-								{Array.from({ length: 8 }).map((_, i) => (
-									<div key={i} className="flex items-center gap-2">
-										<Skeleton className="h-4 w-4" />
-										<Skeleton className="h-4 w-16" />
-									</div>
-								))}
-							</div>
-						) : branchesStatus === "error" ? (
-							<p className="text-sm text-muted-foreground">
-								Niederlassungen konnten nicht geladen werden.
-							</p>
-						) : (
-							<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
-								{(Array.isArray(availableBranches)
-									? availableBranches
-									: []
-								).map((b) => {
-									const checked = Array.isArray(selectedBranches)
-										? selectedBranches.includes(b)
-										: false;
-
-									return (
-										<label
-											key={b}
-											className="flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted/50"
-										>
-											<input
-												type="checkbox"
-												checked={checked}
-												onChange={() => onToggleBranch(b)}
-												disabled={isSubmitting}
-											/>
-											<span className="text-sm">{b}</span>
-										</label>
-									);
-								})}
-							</div>
-						)}
-					</div>
-				</div>
+				<SearchMultiBranchPicker
+					branchesStatus={branchesStatus}
+					availableBranches={availableBranches}
+					selectedBranches={selectedBranches}
+					onToggleBranch={onToggleBranch}
+					isSubmitting={isSubmitting}
+				/>
 			) : null}
 		</div>
 	);

+ 72 - 0
components/search/form/SearchLimitSelect.jsx

@@ -0,0 +1,72 @@
+"use client";
+
+import React from "react";
+import { ChevronDown } from "lucide-react";
+
+import {
+	SEARCH_LIMITS,
+	DEFAULT_SEARCH_LIMIT,
+} from "@/lib/frontend/search/urlState";
+
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import {
+	DropdownMenu,
+	DropdownMenuContent,
+	DropdownMenuLabel,
+	DropdownMenuRadioGroup,
+	DropdownMenuRadioItem,
+	DropdownMenuSeparator,
+	DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+export default function SearchLimitSelect({
+	limit,
+	onLimitChange,
+	isSubmitting,
+}) {
+	const normalizedLimit =
+		Number.isInteger(limit) && SEARCH_LIMITS.includes(limit)
+			? limit
+			: DEFAULT_SEARCH_LIMIT;
+
+	return (
+		<div className="grid gap-2">
+			<Label>Treffer pro Seite</Label>
+
+			<DropdownMenu>
+				<DropdownMenuTrigger asChild>
+					<Button
+						type="button"
+						variant="outline"
+						disabled={isSubmitting}
+						title="Treffer pro Seite auswählen"
+					>
+						{normalizedLimit}
+						<ChevronDown className="h-4 w-4" />
+					</Button>
+				</DropdownMenuTrigger>
+
+				<DropdownMenuContent align="start" className="min-w-[12rem]">
+					<DropdownMenuLabel>Treffer pro Seite</DropdownMenuLabel>
+					<DropdownMenuSeparator />
+
+					<DropdownMenuRadioGroup
+						value={String(normalizedLimit)}
+						onValueChange={(value) => {
+							const n = Number(value);
+							if (!Number.isInteger(n)) return;
+							onLimitChange(n);
+						}}
+					>
+						{SEARCH_LIMITS.map((n) => (
+							<DropdownMenuRadioItem key={n} value={String(n)}>
+								{n}
+							</DropdownMenuRadioItem>
+						))}
+					</DropdownMenuRadioGroup>
+				</DropdownMenuContent>
+			</DropdownMenu>
+		</div>
+	);
+}

+ 209 - 0
components/search/form/SearchMultiBranchPicker.jsx

@@ -0,0 +1,209 @@
+"use client";
+
+import React from "react";
+import { Check, ChevronsUpDown, X } from "lucide-react";
+
+import { isValidBranchParam } from "@/lib/frontend/params";
+import { cn } from "@/lib/utils";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+	Popover,
+	PopoverContent,
+	PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+	Command,
+	CommandEmpty,
+	CommandGroup,
+	CommandInput,
+	CommandItem,
+	CommandList,
+} from "@/components/ui/command";
+
+function normalizeTypedBranch(value) {
+	if (typeof value !== "string") return null;
+	const s = value.trim().toUpperCase();
+	return s ? s : null;
+}
+
+export default function SearchMultiBranchPicker({
+	branchesStatus,
+	availableBranches,
+	selectedBranches,
+	onToggleBranch,
+	isSubmitting,
+}) {
+	const [open, setOpen] = React.useState(false);
+	const [manual, setManual] = React.useState("");
+
+	const listReady = branchesStatus === "ready";
+	const listLoading = branchesStatus === "loading";
+	const listError = branchesStatus === "error";
+
+	const branchOptions = Array.isArray(availableBranches)
+		? availableBranches
+		: [];
+
+	const selected = Array.isArray(selectedBranches) ? selectedBranches : [];
+	const selectedSet = React.useMemo(
+		() => new Set(selected.map(String)),
+		[selected]
+	);
+
+	const typed = normalizeTypedBranch(manual);
+	const canAddTyped = Boolean(
+		typed && isValidBranchParam(typed) && !selectedSet.has(typed)
+	);
+
+	return (
+		<div className="rounded-lg border bg-card p-4 text-card-foreground shadow-sm">
+			<div className="space-y-3">
+				<p className="text-sm font-medium">Niederlassungen auswählen</p>
+				<p className="text-xs text-muted-foreground">
+					Wählen Sie eine oder mehrere Niederlassungen. Die Suche wird nach
+					jeder Änderung aktualisiert.
+				</p>
+
+				<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
+					<div className="flex flex-col gap-2">
+						<Label>Niederlassung hinzufügen</Label>
+
+						{listLoading ? (
+							<Skeleton className="h-9 w-full sm:w-[18rem]" />
+						) : (
+							<Popover open={open} onOpenChange={setOpen}>
+								<PopoverTrigger asChild>
+									<Button
+										type="button"
+										variant="outline"
+										role="combobox"
+										aria-expanded={open}
+										disabled={isSubmitting || !listReady}
+										className="w-full justify-between sm:w-[18rem]"
+										title={
+											listReady
+												? "Niederlassung hinzufügen"
+												: "Niederlassungen konnten nicht geladen werden"
+										}
+									>
+										Niederlassung hinzufügen…
+										<ChevronsUpDown className="h-4 w-4 opacity-70" />
+									</Button>
+								</PopoverTrigger>
+
+								<PopoverContent align="start" className="w-[18rem] p-0">
+									<Command>
+										<CommandInput placeholder="Suchen…" />
+										<CommandList>
+											<CommandEmpty>Keine Treffer.</CommandEmpty>
+											<CommandGroup>
+												{branchOptions.map((b) => {
+													const id = String(b);
+													const isSelected = selectedSet.has(id);
+
+													return (
+														<CommandItem
+															key={id}
+															value={id}
+															onSelect={(value) => {
+																const next = normalizeTypedBranch(value);
+																if (!next || !isValidBranchParam(next)) return;
+
+																if (!selectedSet.has(next))
+																	onToggleBranch(next);
+																setOpen(false);
+															}}
+														>
+															<Check
+																className={cn(
+																	"mr-2 h-4 w-4",
+																	isSelected ? "opacity-100" : "opacity-0"
+																)}
+															/>
+															{id}
+														</CommandItem>
+													);
+												})}
+											</CommandGroup>
+										</CommandList>
+									</Command>
+								</PopoverContent>
+							</Popover>
+						)}
+
+						{listError ? (
+							<div className="space-y-2">
+								<p className="text-xs text-muted-foreground">
+									Niederlassungen konnten nicht geladen werden. Sie können NLxx
+									manuell hinzufügen.
+								</p>
+
+								<div className="flex gap-2">
+									<Input
+										value={manual}
+										onChange={(e) => setManual(e.target.value)}
+										onKeyDown={(e) => {
+											if (e.key !== "Enter") return;
+											if (!canAddTyped) return;
+
+											e.preventDefault();
+											onToggleBranch(typed);
+											setManual("");
+										}}
+										placeholder="z. B. NL17"
+										disabled={isSubmitting}
+									/>
+									<Button
+										type="button"
+										variant="outline"
+										disabled={!canAddTyped || isSubmitting}
+										onClick={() => {
+											if (!canAddTyped) return;
+											onToggleBranch(typed);
+											setManual("");
+										}}
+									>
+										Hinzufügen
+									</Button>
+								</div>
+							</div>
+						) : null}
+					</div>
+
+					<div className="w-full sm:w-auto">
+						<Label>Ausgewählt ({selected.length})</Label>
+
+						{selected.length === 0 ? (
+							<p className="mt-2 text-xs text-muted-foreground">
+								Keine Niederlassungen ausgewählt.
+							</p>
+						) : (
+							<div className="mt-2 flex flex-wrap gap-2">
+								{selected.map((b) => (
+									<Badge key={b} variant="secondary" className="gap-1">
+										<span>{b}</span>
+										<button
+											type="button"
+											className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-sm hover:bg-muted/60"
+											onClick={() => onToggleBranch(b)}
+											disabled={isSubmitting}
+											aria-label={`Entfernen: ${b}`}
+											title={`Entfernen: ${b}`}
+										>
+											<X className="h-3 w-3" />
+										</button>
+									</Badge>
+								))}
+							</div>
+						)}
+					</div>
+				</div>
+			</div>
+		</div>
+	);
+}

+ 52 - 0
components/search/form/SearchQueryBox.jsx

@@ -0,0 +1,52 @@
+"use client";
+
+import React from "react";
+import { Search } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+
+export default function SearchQueryBox({
+	qDraft,
+	onQDraftChange,
+	onSubmit,
+	currentQuery,
+	isSubmitting,
+	canSearch,
+}) {
+	return (
+		<div className="grid gap-2">
+			<Label htmlFor="q">Suchbegriff</Label>
+
+			<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
+				<Input
+					id="q"
+					name="q"
+					value={qDraft}
+					onChange={(e) => onQDraftChange(e.target.value)}
+					placeholder="z. B. Bridgestone, Rechnung, Kundennummer…"
+					disabled={isSubmitting}
+				/>
+
+				<Button type="submit" disabled={!canSearch || isSubmitting}>
+					<Search className="h-4 w-4" />
+					Suchen
+				</Button>
+			</div>
+
+			{currentQuery ? (
+				<div className="text-xs text-muted-foreground">
+					Aktuelle Suche:{" "}
+					<span className="ml-1 rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
+						{currentQuery}
+					</span>
+				</div>
+			) : (
+				<div className="text-xs text-muted-foreground">
+					Tipp: Die Suche ist URL-basiert und kann als Link geteilt werden.
+				</div>
+			)}
+		</div>
+	);
+}

+ 73 - 0
components/search/form/SearchScopeSelect.jsx

@@ -0,0 +1,73 @@
+"use client";
+
+import React from "react";
+import { ChevronDown } from "lucide-react";
+
+import { SEARCH_SCOPE } from "@/lib/frontend/search/urlState";
+
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import {
+	DropdownMenu,
+	DropdownMenuContent,
+	DropdownMenuLabel,
+	DropdownMenuRadioGroup,
+	DropdownMenuRadioItem,
+	DropdownMenuSeparator,
+	DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+const SCOPE_LABELS = Object.freeze({
+	[SEARCH_SCOPE.SINGLE]: "Diese Niederlassung",
+	[SEARCH_SCOPE.MULTI]: "Mehrere Niederlassungen",
+	[SEARCH_SCOPE.ALL]: "Alle Niederlassungen",
+});
+
+export default function SearchScopeSelect({
+	branch,
+	scope,
+	onScopeChange,
+	isSubmitting,
+}) {
+	const scopeLabel = SCOPE_LABELS[scope] || "Unbekannt";
+
+	return (
+		<div className="grid gap-2">
+			<Label>Suchbereich</Label>
+
+			<DropdownMenu>
+				<DropdownMenuTrigger asChild>
+					<Button
+						type="button"
+						variant="outline"
+						disabled={isSubmitting}
+						title="Suchbereich auswählen"
+					>
+						{scopeLabel}
+						<ChevronDown className="h-4 w-4" />
+					</Button>
+				</DropdownMenuTrigger>
+
+				<DropdownMenuContent align="start" className="min-w-[16rem]">
+					<DropdownMenuLabel>Suchbereich</DropdownMenuLabel>
+					<DropdownMenuSeparator />
+
+					<DropdownMenuRadioGroup
+						value={scope}
+						onValueChange={(value) => onScopeChange(value)}
+					>
+						<DropdownMenuRadioItem value={SEARCH_SCOPE.SINGLE}>
+							Diese Niederlassung ({branch})
+						</DropdownMenuRadioItem>
+						<DropdownMenuRadioItem value={SEARCH_SCOPE.MULTI}>
+							Mehrere Niederlassungen
+						</DropdownMenuRadioItem>
+						<DropdownMenuRadioItem value={SEARCH_SCOPE.ALL}>
+							Alle Niederlassungen
+						</DropdownMenuRadioItem>
+					</DropdownMenuRadioGroup>
+				</DropdownMenuContent>
+			</DropdownMenu>
+		</div>
+	);
+}

+ 169 - 0
components/search/form/SearchSingleBranchCombobox.jsx

@@ -0,0 +1,169 @@
+"use client";
+
+import React from "react";
+import { Check, ChevronsUpDown } from "lucide-react";
+
+import { isValidBranchParam } from "@/lib/frontend/params";
+import { cn } from "@/lib/utils";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+	Popover,
+	PopoverContent,
+	PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+	Command,
+	CommandEmpty,
+	CommandGroup,
+	CommandInput,
+	CommandItem,
+	CommandList,
+} from "@/components/ui/command";
+
+function normalizeTypedBranch(value) {
+	if (typeof value !== "string") return null;
+	const s = value.trim().toUpperCase();
+	return s ? s : null;
+}
+
+export default function SearchSingleBranchCombobox({
+	branch,
+	branchesStatus,
+	availableBranches,
+	onSingleBranchChange,
+	isSubmitting,
+}) {
+	const [open, setOpen] = React.useState(false);
+	const [manual, setManual] = React.useState("");
+
+	const listReady = branchesStatus === "ready";
+	const listLoading = branchesStatus === "loading";
+	const listError = branchesStatus === "error";
+
+	const branchOptions = Array.isArray(availableBranches)
+		? availableBranches
+		: [];
+
+	const typed = normalizeTypedBranch(manual);
+	const canApply = Boolean(typed && isValidBranchParam(typed));
+
+	return (
+		<div className="grid gap-2">
+			<Label>Niederlassung</Label>
+
+			<Popover open={open} onOpenChange={setOpen}>
+				<PopoverTrigger asChild>
+					<Button
+						type="button"
+						variant="outline"
+						role="combobox"
+						aria-expanded={open}
+						disabled={isSubmitting}
+						className="w-full justify-between sm:w-[18rem]"
+						title="Niederlassung auswählen"
+					>
+						{branch}
+						<ChevronsUpDown className="h-4 w-4 opacity-70" />
+					</Button>
+				</PopoverTrigger>
+
+				<PopoverContent align="start" className="w-[18rem] p-0">
+					{listReady ? (
+						<Command>
+							<CommandInput placeholder="Niederlassung suchen…" />
+							<CommandList>
+								<CommandEmpty>Keine Treffer.</CommandEmpty>
+								<CommandGroup>
+									{branchOptions.map((b) => {
+										const id = String(b);
+										const isActive = id === branch;
+
+										return (
+											<CommandItem
+												key={id}
+												value={id}
+												onSelect={(value) => {
+													if (typeof onSingleBranchChange !== "function")
+														return;
+
+													const next = normalizeTypedBranch(value);
+													if (!next || !isValidBranchParam(next)) return;
+
+													if (next !== branch) onSingleBranchChange(next);
+													setOpen(false);
+												}}
+											>
+												<Check
+													className={cn(
+														"mr-2 h-4 w-4",
+														isActive ? "opacity-100" : "opacity-0"
+													)}
+												/>
+												{id}
+											</CommandItem>
+										);
+									})}
+								</CommandGroup>
+							</CommandList>
+						</Command>
+					) : listLoading ? (
+						<div className="space-y-2 p-3">
+							{Array.from({ length: 6 }).map((_, i) => (
+								<div key={i} className="flex items-center gap-2">
+									<Skeleton className="h-4 w-4" />
+									<Skeleton className="h-4 w-16" />
+								</div>
+							))}
+						</div>
+					) : (
+						<div className="space-y-3 p-3">
+							<p className="text-xs text-muted-foreground">
+								Niederlassungen konnten nicht geladen werden. Sie können NLxx
+								manuell eingeben.
+							</p>
+
+							<div className="flex gap-2">
+								<Input
+									value={manual}
+									onChange={(e) => setManual(e.target.value)}
+									onKeyDown={(e) => {
+										if (e.key !== "Enter") return;
+										if (!canApply) return;
+
+										e.preventDefault();
+										if (typeof onSingleBranchChange !== "function") return;
+
+										onSingleBranchChange(typed);
+										setManual("");
+										setOpen(false);
+									}}
+									placeholder="z. B. NL17"
+									disabled={isSubmitting}
+								/>
+								<Button
+									type="button"
+									variant="outline"
+									disabled={!canApply || isSubmitting}
+									onClick={() => {
+										if (!canApply) return;
+										if (typeof onSingleBranchChange !== "function") return;
+
+										onSingleBranchChange(typed);
+										setManual("");
+										setOpen(false);
+									}}
+								>
+									Wechseln
+								</Button>
+							</div>
+						</div>
+					)}
+				</PopoverContent>
+			</Popover>
+		</div>
+	);
+}