Quellcode durchsuchen

RHL-024 feat(search): enhance search UI with pagination and sorting features

Code_Uwe vor 3 Wochen
Ursprung
Commit
b02b8f29b1

+ 61 - 12
components/search/SearchForm.jsx

@@ -3,7 +3,11 @@
 import React from "react";
 import { ChevronDown, Search } from "lucide-react";
 
-import { SEARCH_SCOPE } from "@/lib/frontend/search/urlState";
+import {
+	SEARCH_SCOPE,
+	SEARCH_LIMITS,
+	DEFAULT_SEARCH_LIMIT,
+} from "@/lib/frontend/search/urlState";
 
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
@@ -39,11 +43,18 @@ export default function SearchForm({
 	branchesStatus,
 	selectedBranches,
 	onToggleBranch,
+	limit,
+	onLimitChange,
 }) {
 	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
@@ -87,11 +98,11 @@ export default function SearchForm({
 					)}
 				</div>
 
-				{isAdminDev ? (
-					<div className="grid gap-2">
-						<Label>Suchbereich</Label>
+				<div className="flex flex-wrap items-center gap-2">
+					{isAdminDev ? (
+						<div className="grid gap-2">
+							<Label>Suchbereich</Label>
 
-						<div className="flex flex-wrap items-center gap-2">
 							<DropdownMenu>
 								<DropdownMenuTrigger asChild>
 									<Button
@@ -125,15 +136,53 @@ export default function SearchForm({
 									</DropdownMenuRadioGroup>
 								</DropdownMenuContent>
 							</DropdownMenu>
-
-							{scope === SEARCH_SCOPE.SINGLE ? (
-								<span className="text-xs text-muted-foreground">
-									Aktiv: {branch}
-								</span>
-							) : null}
 						</div>
+					) : 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>
-				) : null}
+
+					{isAdminDev && scope === SEARCH_SCOPE.SINGLE ? (
+						<span className="pt-6 text-xs text-muted-foreground">
+							Aktiv: {branch}
+						</span>
+					) : null}
+				</div>
 			</form>
 
 			{isAdminDev && scope === SEARCH_SCOPE.MULTI ? (

+ 130 - 304
components/search/SearchPage.jsx

@@ -5,16 +5,21 @@ import { useRouter, useSearchParams } from "next/navigation";
 import { RefreshCw } from "lucide-react";
 
 import { useAuth } from "@/components/auth/authContext";
-import { ApiClientError, getBranches, search } from "@/lib/frontend/apiClient";
 import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
 import { searchPath } from "@/lib/frontend/routes";
 
 import {
-	SEARCH_SCOPE,
 	parseSearchUrlState,
 	serializeSearchUrlState,
+	SEARCH_SCOPE,
 } from "@/lib/frontend/search/urlState";
+import { normalizeSearchUrlStateForUser } from "@/lib/frontend/search/normalizeState";
 import { mapSearchError } from "@/lib/frontend/search/errorMapping";
+import { useSearchQuery } from "@/lib/frontend/search/useSearchQuery";
+import {
+	useSearchBranches,
+	BRANCH_LIST_STATE,
+} from "@/lib/frontend/search/useSearchBranches";
 
 import ExplorerPageShell from "@/components/explorer/ExplorerPageShell";
 import ExplorerSectionCard from "@/components/explorer/ExplorerSectionCard";
@@ -24,351 +29,169 @@ import { Button } from "@/components/ui/button";
 import SearchForm from "@/components/search/SearchForm";
 import SearchResults from "@/components/search/SearchResults";
 
-const PAGE_LIMIT = 100;
-
-const BRANCH_LIST_STATE = Object.freeze({
-	IDLE: "idle",
-	LOADING: "loading",
-	READY: "ready",
-	ERROR: "error",
-});
+function buildSearchHref({ routeBranch, state }) {
+	const base = searchPath(routeBranch);
+	const qs = serializeSearchUrlState(state);
+	return qs ? `${base}?${qs}` : base;
+}
 
-export default function SearchPage({ branch }) {
+export default function SearchPage({ branch: routeBranch }) {
 	const router = useRouter();
 	const searchParams = useSearchParams();
-	const { status, user } = useAuth();
+	const { status: authStatus, user } = useAuth();
 
-	const isAuthenticated = status === "authenticated" && user;
+	const isAuthenticated = authStatus === "authenticated" && user;
 	const isAdminDev =
 		isAuthenticated && (user.role === "admin" || user.role === "dev");
-	const isBranchUser = isAuthenticated && user.role === "branch";
 
+	// 1) URL -> parsed state (pure helper)
 	const parsedUrlState = React.useMemo(() => {
-		return parseSearchUrlState(searchParams, { routeBranch: branch });
-	}, [searchParams, branch]);
+		return parseSearchUrlState(searchParams, { routeBranch });
+	}, [searchParams, routeBranch]);
 
-	// Enforce "single = this route branch" and keep branch users safe.
+	// 2) Normalize for user role + route context (pure helper)
 	const urlState = React.useMemo(() => {
-		// AuthGate ensures auth before rendering, but keep this defensive.
-		if (!user) return parsedUrlState;
-
-		// Branch users: always single, no cross-branch scope params.
-		if (user.role === "branch") {
-			return {
-				...parsedUrlState,
-				scope: SEARCH_SCOPE.SINGLE,
-				branch,
-				branches: [],
-			};
-		}
-
-		// Admin/dev: single scope is always the route branch context.
-		if (parsedUrlState.scope === SEARCH_SCOPE.SINGLE) {
-			return { ...parsedUrlState, branch };
-		}
-
-		return parsedUrlState;
-	}, [parsedUrlState, user, branch]);
+		return normalizeSearchUrlStateForUser(parsedUrlState, {
+			routeBranch,
+			user,
+		});
+	}, [parsedUrlState, routeBranch, user]);
 
+	// 3) The identity of a first-page search (cursor intentionally excluded).
 	const searchKey = React.useMemo(() => {
-		// This is our "query identity" without cursor.
 		return serializeSearchUrlState(urlState);
 	}, [urlState]);
 
-	// Keep a ref of the latest key so async "load more" cannot append to a new search.
-	const searchKeyRef = React.useRef(searchKey);
-	React.useEffect(() => {
-		searchKeyRef.current = searchKey;
-	}, [searchKey]);
-
-	// Input draft (URL remains the single source of truth for executed searches).
+	// 4) Draft input (URL remains SoT for executed searches).
 	const [qDraft, setQDraft] = React.useState(urlState.q || "");
 	React.useEffect(() => {
 		setQDraft(urlState.q || "");
 	}, [urlState.q]);
 
-	// Admin/dev: load branch list for multi-select (fail-open).
-	const [branchList, setBranchList] = React.useState({
-		status: BRANCH_LIST_STATE.IDLE,
-		branches: null,
-	});
-
-	React.useEffect(() => {
-		if (!isAdminDev) return;
-
-		let cancelled = false;
-		setBranchList({ status: BRANCH_LIST_STATE.LOADING, branches: null });
-
-		(async () => {
-			try {
-				const res = await getBranches();
-				if (cancelled) return;
+	// 5) Admin/dev only: branches list for multi select (fail-open)
+	const branchesQuery = useSearchBranches({ enabled: isAdminDev });
 
-				const branches = Array.isArray(res?.branches) ? res.branches : [];
-				setBranchList({ status: BRANCH_LIST_STATE.READY, branches });
-			} catch (err) {
-				if (cancelled) return;
-
-				console.error("[SearchPage] getBranches failed:", err);
-				setBranchList({ status: BRANCH_LIST_STATE.ERROR, branches: null });
-			}
-		})();
-
-		return () => {
-			cancelled = true;
-		};
-	}, [isAdminDev, user?.userId]);
-
-	// Search results state.
-	const [statusState, setStatusState] = React.useState("idle"); // idle|loading|success|error
-	const [items, setItems] = React.useState([]);
-	const [nextCursor, setNextCursor] = React.useState(null);
-	const [error, setError] = React.useState(null);
-
-	const [isLoadingMore, setIsLoadingMore] = React.useState(false);
-	const [loadMoreError, setLoadMoreError] = React.useState(null);
-
-	const searchRequestIdRef = React.useRef(0);
-	const loadMoreRequestIdRef = React.useRef(0);
+	// 6) Data lifecycle (first page + load more)
+	const query = useSearchQuery({
+		searchKey,
+		urlState,
+		routeBranch,
+		user,
+		limit: urlState.limit,
+	});
 
-	const mappedError = React.useMemo(() => mapSearchError(error), [error]);
+	// 7) Map errors to German UX copy
+	const mappedError = React.useMemo(
+		() => mapSearchError(query.error),
+		[query.error]
+	);
 	const mappedLoadMoreError = React.useMemo(
-		() => mapSearchError(loadMoreError),
-		[loadMoreError]
+		() => mapSearchError(query.loadMoreError),
+		[query.loadMoreError]
 	);
 
-	// Redirect on unauthenticated search calls (session expired mid-session).
+	// 8) Redirect when unauthenticated mid-request
 	React.useEffect(() => {
 		if (mappedError?.kind !== "unauthenticated") return;
 
 		const next =
 			typeof window !== "undefined"
 				? `${window.location.pathname}${window.location.search}`
-				: searchPath(branch);
+				: searchPath(routeBranch);
 
 		window.location.replace(
 			buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next })
 		);
-	}, [mappedError?.kind, branch]);
-
-	function buildHref(nextState, { push } = {}) {
-		const base = searchPath(branch);
-		const qs = serializeSearchUrlState(nextState);
-		const href = qs ? `${base}?${qs}` : base;
+	}, [mappedError?.kind, routeBranch]);
+
+	// 9) URL write helpers (search is URL-driven)
+	const pushStateToUrl = React.useCallback(
+		(nextState) => {
+			router.push(buildSearchHref({ routeBranch, state: nextState }));
+		},
+		[router, routeBranch]
+	);
 
-		if (push) router.push(href);
-		else router.replace(href);
-	}
+	const replaceStateToUrl = React.useCallback(
+		(nextState) => {
+			router.replace(buildSearchHref({ routeBranch, state: nextState }));
+		},
+		[router, routeBranch]
+	);
 
-	function handleSubmit() {
+	// 10) Handlers
+	const handleSubmit = React.useCallback(() => {
 		const nextState = {
+			...urlState,
 			q: qDraft,
-			// Branch users always single; admin/dev use selected scope.
-			scope: isAdminDev ? urlState.scope : SEARCH_SCOPE.SINGLE,
-			branch, // explicit for shareability (even though route already contains it)
-			branches: urlState.branches,
-			from: urlState.from,
-			to: urlState.to,
+			branch: routeBranch,
 		};
 
-		buildHref(nextState, { push: true });
-	}
+		pushStateToUrl(nextState);
+	}, [urlState, qDraft, routeBranch, pushStateToUrl]);
 
-	function handleScopeChange(nextScope) {
-		if (!isAdminDev) return;
+	const handleScopeChange = React.useCallback(
+		(nextScope) => {
+			if (!isAdminDev) return;
 
-		const nextState = {
-			// Scope changes rerun the currently executed query (URL q), not the draft.
-			q: urlState.q,
-			scope: nextScope,
-			branch,
-			branches: nextScope === SEARCH_SCOPE.MULTI ? urlState.branches : [],
-			from: urlState.from,
-			to: urlState.to,
-		};
-
-		buildHref(nextState, { push: false });
-	}
-
-	function toggleMultiBranch(branchId) {
-		if (!isAdminDev) return;
-
-		const current = Array.isArray(urlState.branches) ? urlState.branches : [];
-		const set = new Set(current);
-
-		if (set.has(branchId)) set.delete(branchId);
-		else set.add(branchId);
-
-		const nextState = {
-			q: urlState.q,
-			scope: SEARCH_SCOPE.MULTI,
-			branches: Array.from(set),
-			from: urlState.from,
-			to: urlState.to,
-		};
-
-		buildHref(nextState, { push: false });
-	}
-
-	function buildSearchRequest({ cursor = null } = {}) {
-		const q = urlState.q;
-
-		if (!q) return null;
-
-		const base = {
-			q,
-			limit: PAGE_LIMIT,
-		};
-
-		if (urlState.from) base.from = urlState.from;
-		if (urlState.to) base.to = urlState.to;
-		if (cursor) base.cursor = cursor;
-
-		// Branch role: never send scope/branches from URL (avoid forbidden / keep safe).
-		if (isBranchUser) {
-			base.branch = branch;
-			return base;
-		}
-
-		// Admin/dev scopes:
-		if (urlState.scope === SEARCH_SCOPE.ALL) {
-			base.scope = "all";
-			return base;
-		}
-
-		if (urlState.scope === SEARCH_SCOPE.MULTI) {
-			base.scope = "multi";
-			base.branches = urlState.branches;
-			return base;
-		}
-
-		// Single (explicit branch param for shareability)
-		base.branch = branch;
-		return base;
-	}
-
-	async function runFirstPage() {
-		// Reset "load more" UI whenever we start a new first-page search.
-		setIsLoadingMore(false);
-		setLoadMoreError(null);
-
-		const q = urlState.q;
-
-		// No search yet.
-		if (!q) {
-			setStatusState("idle");
-			setItems([]);
-			setNextCursor(null);
-			setError(null);
-			return;
-		}
-
-		// Local validation: multi scope requires at least one branch.
-		if (isAdminDev && urlState.scope === SEARCH_SCOPE.MULTI) {
-			const branches = Array.isArray(urlState.branches)
-				? urlState.branches
-				: [];
-			if (branches.length === 0) {
-				setStatusState("error");
-				setItems([]);
-				setNextCursor(null);
-				setError(
-					new ApiClientError({
-						status: 400,
-						code: "VALIDATION_SEARCH_BRANCHES",
-						message: "Invalid branches",
-					})
-				);
-				return;
-			}
-		}
-
-		const req = buildSearchRequest({ cursor: null });
-		if (!req) return;
-
-		const id = ++searchRequestIdRef.current;
-
-		setStatusState("loading");
-		setItems([]);
-		setNextCursor(null);
-		setError(null);
-
-		try {
-			const res = await search(req);
-
-			if (id !== searchRequestIdRef.current) return;
-
-			const nextItems = Array.isArray(res?.items) ? res.items : [];
-			const next = typeof res?.nextCursor === "string" ? res.nextCursor : null;
-
-			setItems(nextItems);
-			setNextCursor(next);
-			setStatusState("success");
-		} catch (err) {
-			if (id !== searchRequestIdRef.current) return;
-
-			setItems([]);
-			setNextCursor(null);
-			setError(err);
-			setStatusState("error");
-		}
-	}
-
-	async function loadMore() {
-		if (!nextCursor) return;
-		if (isLoadingMore) return;
-
-		const baseKey = searchKeyRef.current;
-
-		const req = buildSearchRequest({ cursor: nextCursor });
-		if (!req) return;
+			const nextState = {
+				...urlState,
+				scope: nextScope,
+				branch: routeBranch,
+				branches: nextScope === SEARCH_SCOPE.MULTI ? urlState.branches : [],
+			};
 
-		const id = ++loadMoreRequestIdRef.current;
+			replaceStateToUrl(nextState);
+		},
+		[isAdminDev, urlState, routeBranch, replaceStateToUrl]
+	);
 
-		setIsLoadingMore(true);
-		setLoadMoreError(null);
+	const handleToggleBranch = React.useCallback(
+		(branchId) => {
+			if (!isAdminDev) return;
 
-		try {
-			const res = await search(req);
+			const current = Array.isArray(urlState.branches) ? urlState.branches : [];
+			const set = new Set(current);
 
-			// If a newer "load more" started, ignore this result.
-			if (id !== loadMoreRequestIdRef.current) return;
+			if (set.has(branchId)) set.delete(branchId);
+			else set.add(branchId);
 
-			// If the base search changed, do not append.
-			if (searchKeyRef.current !== baseKey) return;
+			const nextState = {
+				...urlState,
+				scope: SEARCH_SCOPE.MULTI,
+				branches: Array.from(set),
+			};
 
-			const moreItems = Array.isArray(res?.items) ? res.items : [];
-			const next = typeof res?.nextCursor === "string" ? res.nextCursor : null;
+			replaceStateToUrl(nextState);
+		},
+		[isAdminDev, urlState, replaceStateToUrl]
+	);
 
-			setItems((prev) => [...prev, ...moreItems]);
-			setNextCursor(next);
-		} catch (err) {
-			if (id !== loadMoreRequestIdRef.current) return;
-			setLoadMoreError(err);
-		} finally {
-			if (id === loadMoreRequestIdRef.current) {
-				setIsLoadingMore(false);
-			}
-		}
-	}
+	const handleLimitChange = React.useCallback(
+		(nextLimit) => {
+			const nextState = {
+				...urlState,
+				limit: nextLimit,
+				branch: routeBranch,
+			};
 
-	// Run first-page search whenever the URL-driven search identity changes.
-	React.useEffect(() => {
-		runFirstPage();
-		// eslint-disable-next-line react-hooks/exhaustive-deps
-	}, [searchKey, isAdminDev, isBranchUser, branch]);
+			// Like scope changes: rerun based on URL state (executed query), not on draft.
+			replaceStateToUrl(nextState);
+		},
+		[urlState, routeBranch, replaceStateToUrl]
+	);
 
-	// Forbidden (keep consistent with Explorer UX).
+	// Forbidden stays consistent with Explorer UX.
 	if (mappedError?.kind === "forbidden") {
-		return <ForbiddenView attemptedBranch={branch} />;
+		return <ForbiddenView attemptedBranch={routeBranch} />;
 	}
 
 	const actions = (
 		<Button
 			variant="outline"
 			size="sm"
-			onClick={() => runFirstPage()}
-			disabled={!urlState.q || statusState === "loading"}
+			onClick={query.retry}
+			disabled={!urlState.q || query.status === "loading"}
 			title="Aktualisieren"
 		>
 			<RefreshCw className="h-4 w-4" />
@@ -383,13 +206,13 @@ export default function SearchPage({ branch }) {
 	) : null;
 
 	const resultsDescription = urlState.q
-		? `Niederlassung ${branch}`
+		? `Niederlassung ${routeBranch}`
 		: "Geben Sie einen Suchbegriff ein, um zu starten.";
 
 	return (
 		<ExplorerPageShell
 			title="Suche"
-			description={`Lieferscheine durchsuchen • Niederlassung ${branch}`}
+			description={`Lieferscheine durchsuchen • Niederlassung ${routeBranch}`}
 			actions={actions}
 		>
 			<ExplorerSectionCard
@@ -397,24 +220,26 @@ export default function SearchPage({ branch }) {
 				description="Suchbegriff und Suchbereich auswählen."
 			>
 				<SearchForm
-					branch={branch}
+					branch={routeBranch}
 					qDraft={qDraft}
 					onQDraftChange={setQDraft}
 					onSubmit={handleSubmit}
 					currentQuery={urlState.q}
-					isSubmitting={statusState === "loading"}
+					isSubmitting={query.status === "loading"}
 					isAdminDev={isAdminDev}
 					scope={urlState.scope}
 					onScopeChange={handleScopeChange}
 					availableBranches={
-						branchList.status === BRANCH_LIST_STATE.READY &&
-						Array.isArray(branchList.branches)
-							? branchList.branches
+						branchesQuery.status === BRANCH_LIST_STATE.READY &&
+						Array.isArray(branchesQuery.branches)
+							? branchesQuery.branches
 							: []
 					}
-					branchesStatus={branchList.status}
+					branchesStatus={branchesQuery.status}
 					selectedBranches={urlState.branches}
-					onToggleBranch={toggleMultiBranch}
+					onToggleBranch={handleToggleBranch}
+					limit={urlState.limit}
+					onLimitChange={handleLimitChange}
 				/>
 			</ExplorerSectionCard>
 
@@ -424,15 +249,16 @@ export default function SearchPage({ branch }) {
 				headerRight={resultsHeaderRight}
 			>
 				<SearchResults
-					branch={branch}
+					branch={routeBranch}
 					scope={urlState.scope}
-					status={statusState}
-					items={items}
+					status={query.status}
+					items={query.items}
+					total={query.total}
 					error={mappedError}
-					onRetry={runFirstPage}
-					nextCursor={nextCursor}
-					onLoadMore={loadMore}
-					isLoadingMore={isLoadingMore}
+					onRetry={query.retry}
+					nextCursor={query.nextCursor}
+					onLoadMore={query.loadMore}
+					isLoadingMore={query.isLoadingMore}
 					loadMoreError={mappedLoadMoreError}
 				/>
 			</ExplorerSectionCard>

+ 17 - 207
components/search/SearchResults.jsx

@@ -1,12 +1,7 @@
 "use client";
 
 import React from "react";
-import Link from "next/link";
-import { Eye, FolderOpen, Loader2, SlidersHorizontal } from "lucide-react";
-
-import { SEARCH_SCOPE } from "@/lib/frontend/search/urlState";
-import { dayPath } from "@/lib/frontend/routes";
-import { buildPdfUrl } from "@/lib/frontend/explorer/pdfUrl";
+import { Loader2 } from "lucide-react";
 
 import ExplorerLoading from "@/components/explorer/states/ExplorerLoading";
 import ExplorerEmpty from "@/components/explorer/states/ExplorerEmpty";
@@ -14,50 +9,21 @@ import ExplorerError from "@/components/explorer/states/ExplorerError";
 
 import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
 import { Button } from "@/components/ui/button";
-import {
-	DropdownMenu,
-	DropdownMenuContent,
-	DropdownMenuLabel,
-	DropdownMenuRadioGroup,
-	DropdownMenuRadioItem,
-	DropdownMenuSeparator,
-	DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import {
-	Table,
-	TableBody,
-	TableCaption,
-	TableCell,
-	TableHead,
-	TableHeader,
-	TableRow,
-} from "@/components/ui/table";
-
-const SORT = Object.freeze({
-	RELEVANCE: "relevance",
-	DATE_DESC: "date_desc",
-	FILENAME_ASC: "filename_asc",
-});
 
-function toDateKey(it) {
-	const y = String(it?.year || "");
-	const m = String(it?.month || "").padStart(2, "0");
-	const d = String(it?.day || "").padStart(2, "0");
-	return `${y}-${m}-${d}`;
-}
+import {
+	sortSearchItems,
+	SEARCH_RESULTS_SORT,
+} from "@/lib/frontend/search/resultsSorting";
 
-function formatDateDe(it) {
-	const y = String(it?.year || "");
-	const m = String(it?.month || "").padStart(2, "0");
-	const d = String(it?.day || "").padStart(2, "0");
-	return `${d}.${m}.${y}`;
-}
+import SearchResultsToolbar from "@/components/search/SearchResultsToolbar";
+import SearchResultsTable from "@/components/search/SearchResultsTable";
 
 export default function SearchResults({
 	branch,
 	scope,
 	status,
 	items,
+	total,
 	error,
 	onRetry,
 	nextCursor,
@@ -65,36 +31,10 @@ export default function SearchResults({
 	isLoadingMore,
 	loadMoreError,
 }) {
-	const showBranchColumn =
-		scope === SEARCH_SCOPE.ALL || scope === SEARCH_SCOPE.MULTI;
-
-	const [sortMode, setSortMode] = React.useState(SORT.RELEVANCE);
+	const [sortMode, setSortMode] = React.useState(SEARCH_RESULTS_SORT.RELEVANCE);
 
 	const sortedItems = React.useMemo(() => {
-		const arr = Array.isArray(items) ? [...items] : [];
-
-		if (sortMode === SORT.RELEVANCE) return arr;
-
-		if (sortMode === SORT.DATE_DESC) {
-			return arr.sort((a, b) => {
-				const da = toDateKey(a);
-				const db = toDateKey(b);
-
-				if (da !== db) return da < db ? 1 : -1;
-
-				const fa = String(a?.filename || "");
-				const fb = String(b?.filename || "");
-				return fa.localeCompare(fb, "de");
-			});
-		}
-
-		if (sortMode === SORT.FILENAME_ASC) {
-			return arr.sort((a, b) =>
-				String(a?.filename || "").localeCompare(String(b?.filename || ""), "de")
-			);
-		}
-
-		return arr;
+		return sortSearchItems(items, sortMode);
 	}, [items, sortMode]);
 
 	if (status === "idle") {
@@ -135,144 +75,14 @@ export default function SearchResults({
 
 	return (
 		<div className="space-y-4">
-			<div className="flex flex-wrap items-center justify-between gap-2">
-				<div className="text-xs text-muted-foreground">
-					{list.length} Treffer (aktuell geladen)
-				</div>
-
-				<DropdownMenu>
-					<DropdownMenuTrigger asChild>
-						<Button variant="outline" size="sm" type="button">
-							<SlidersHorizontal className="h-4 w-4" />
-							Sortierung
-						</Button>
-					</DropdownMenuTrigger>
-
-					<DropdownMenuContent align="end" className="min-w-[16rem]">
-						<DropdownMenuLabel>Sortierung</DropdownMenuLabel>
-						<DropdownMenuSeparator />
-
-						<DropdownMenuRadioGroup
-							value={sortMode}
-							onValueChange={(value) => setSortMode(value)}
-						>
-							<DropdownMenuRadioItem value={SORT.RELEVANCE}>
-								Relevanz
-							</DropdownMenuRadioItem>
-							<DropdownMenuRadioItem value={SORT.DATE_DESC}>
-								Datum (neueste zuerst)
-							</DropdownMenuRadioItem>
-							<DropdownMenuRadioItem value={SORT.FILENAME_ASC}>
-								Dateiname (A–Z)
-							</DropdownMenuRadioItem>
-						</DropdownMenuRadioGroup>
-					</DropdownMenuContent>
-				</DropdownMenu>
-			</div>
-
-			<Table>
-				<TableCaption>
-					Hinweis: PDFs werden in einem neuen Tab geöffnet.
-				</TableCaption>
-
-				<TableHeader>
-					<TableRow>
-						{showBranchColumn ? <TableHead>Niederlassung</TableHead> : null}
-						<TableHead>Datum</TableHead>
-						<TableHead>Datei</TableHead>
-						<TableHead className="hidden md:table-cell">Pfad</TableHead>
-						<TableHead className="text-right">Aktion</TableHead>
-					</TableRow>
-				</TableHeader>
-
-				<TableBody>
-					{list.map((it) => {
-						const itemBranch = String(it?.branch || branch);
-						const year = String(it?.year || "");
-						const month = String(it?.month || "");
-						const day = String(it?.day || "");
-						const filename = String(it?.filename || "");
-						const relativePath = String(it?.relativePath || "");
-						const snippet =
-							typeof it?.snippet === "string" && it.snippet.trim()
-								? it.snippet.trim()
-								: null;
-
-						const pdfUrl = buildPdfUrl({
-							branch: itemBranch,
-							year,
-							month,
-							day,
-							filename,
-						});
-
-						const dayHref = dayPath(itemBranch, year, month, day);
-
-						return (
-							<TableRow
-								key={
-									relativePath ||
-									`${itemBranch}/${year}/${month}/${day}/${filename}`
-								}
-							>
-								{showBranchColumn ? (
-									<TableCell>
-										<span className="text-sm">{itemBranch}</span>
-									</TableCell>
-								) : null}
-
-								<TableCell>
-									<span className="text-sm">{formatDateDe(it)}</span>
-								</TableCell>
-
-								<TableCell className="min-w-0">
-									<div className="min-w-0">
-										<p className="truncate font-medium">{filename}</p>
-										{snippet ? (
-											<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
-												{snippet}
-											</p>
-										) : null}
-										<p className="truncate text-xs text-muted-foreground md:hidden">
-											{relativePath}
-										</p>
-									</div>
-								</TableCell>
-
-								<TableCell className="hidden md:table-cell">
-									<span className="text-xs text-muted-foreground">
-										{relativePath}
-									</span>
-								</TableCell>
-
-								<TableCell className="text-right">
-									<div className="flex justify-end gap-2">
-										<Button variant="outline" size="sm" asChild>
-											<a
-												href={pdfUrl}
-												target="_blank"
-												rel="noopener noreferrer"
-												aria-label={`PDF öffnen: ${filename}`}
-												title={`PDF öffnen: ${filename}`}
-											>
-												<Eye className="h-4 w-4" />
-												Öffnen
-											</a>
-										</Button>
+			<SearchResultsToolbar
+				countLoaded={list.length}
+				total={total}
+				sortMode={sortMode}
+				onSortModeChange={setSortMode}
+			/>
 
-										<Button variant="outline" size="sm" asChild>
-											<Link href={dayHref} title="Zum Tag">
-												<FolderOpen className="h-4 w-4" />
-												Zum Tag
-											</Link>
-										</Button>
-									</div>
-								</TableCell>
-							</TableRow>
-						);
-					})}
-				</TableBody>
-			</Table>
+			<SearchResultsTable routeBranch={branch} scope={scope} items={list} />
 
 			{loadMoreError ? (
 				<Alert variant="destructive">

+ 134 - 0
components/search/SearchResultsTable.jsx

@@ -0,0 +1,134 @@
+"use client";
+
+import React from "react";
+import Link from "next/link";
+import { Eye, FolderOpen } from "lucide-react";
+
+import { dayPath } from "@/lib/frontend/routes";
+import { buildPdfUrl } from "@/lib/frontend/explorer/pdfUrl";
+import { formatSearchItemDateDe } from "@/lib/frontend/search/resultsSorting";
+import { SEARCH_SCOPE } from "@/lib/frontend/search/urlState";
+
+import { Button } from "@/components/ui/button";
+import {
+	Table,
+	TableBody,
+	TableCaption,
+	TableCell,
+	TableHead,
+	TableHeader,
+	TableRow,
+} from "@/components/ui/table";
+
+export default function SearchResultsTable({ routeBranch, scope, items }) {
+	const showBranchColumn =
+		scope === SEARCH_SCOPE.ALL || scope === SEARCH_SCOPE.MULTI;
+
+	const list = Array.isArray(items) ? items : [];
+
+	return (
+		<Table>
+			<TableCaption>
+				Hinweis: PDFs werden in einem neuen Tab geöffnet.
+			</TableCaption>
+
+			<TableHeader>
+				<TableRow>
+					{showBranchColumn ? <TableHead>Niederlassung</TableHead> : null}
+					<TableHead>Datum</TableHead>
+					<TableHead>Datei</TableHead>
+					<TableHead className="hidden md:table-cell">Pfad</TableHead>
+					<TableHead className="text-right">Aktion</TableHead>
+				</TableRow>
+			</TableHeader>
+
+			<TableBody>
+				{list.map((it) => {
+					const itemBranch = String(it?.branch || routeBranch);
+					const year = String(it?.year || "");
+					const month = String(it?.month || "");
+					const day = String(it?.day || "");
+					const filename = String(it?.filename || "");
+					const relativePath = String(it?.relativePath || "");
+					const snippet =
+						typeof it?.snippet === "string" && it.snippet.trim()
+							? it.snippet.trim()
+							: null;
+
+					const pdfUrl = buildPdfUrl({
+						branch: itemBranch,
+						year,
+						month,
+						day,
+						filename,
+					});
+
+					const dayHref = dayPath(itemBranch, year, month, day);
+
+					const key =
+						relativePath || `${itemBranch}/${year}/${month}/${day}/${filename}`;
+
+					return (
+						<TableRow key={key}>
+							{showBranchColumn ? (
+								<TableCell>
+									<span className="text-sm">{itemBranch}</span>
+								</TableCell>
+							) : null}
+
+							<TableCell>
+								<span className="text-sm">{formatSearchItemDateDe(it)}</span>
+							</TableCell>
+
+							<TableCell className="min-w-0">
+								<div className="min-w-0">
+									<p className="truncate font-medium">{filename}</p>
+
+									{snippet ? (
+										<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
+											{snippet}
+										</p>
+									) : null}
+
+									<p className="truncate text-xs text-muted-foreground md:hidden">
+										{relativePath}
+									</p>
+								</div>
+							</TableCell>
+
+							<TableCell className="hidden md:table-cell">
+								<span className="text-xs text-muted-foreground">
+									{relativePath}
+								</span>
+							</TableCell>
+
+							<TableCell className="text-right">
+								<div className="flex justify-end gap-2">
+									<Button variant="outline" size="sm" asChild>
+										<a
+											href={pdfUrl}
+											target="_blank"
+											rel="noopener noreferrer"
+											aria-label={`PDF öffnen: ${filename}`}
+											title={`PDF öffnen: ${filename}`}
+										>
+											<Eye className="h-4 w-4" />
+											Öffnen
+										</a>
+									</Button>
+
+									<Button variant="outline" size="sm" asChild>
+										<Link href={dayHref} title="Zum Tag">
+											<FolderOpen className="h-4 w-4" />
+											Zum Tag
+										</Link>
+									</Button>
+								</div>
+							</TableCell>
+						</TableRow>
+					);
+				})}
+			</TableBody>
+		</Table>
+	);
+}

+ 65 - 0
components/search/SearchResultsToolbar.jsx

@@ -0,0 +1,65 @@
+"use client";
+
+import React from "react";
+import { SlidersHorizontal } from "lucide-react";
+
+import { SEARCH_RESULTS_SORT } from "@/lib/frontend/search/resultsSorting";
+
+import { Button } from "@/components/ui/button";
+import {
+	DropdownMenu,
+	DropdownMenuContent,
+	DropdownMenuLabel,
+	DropdownMenuRadioGroup,
+	DropdownMenuRadioItem,
+	DropdownMenuSeparator,
+	DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+export default function SearchResultsToolbar({
+	countLoaded,
+	total,
+	sortMode,
+	onSortModeChange,
+}) {
+	const hasTotal = typeof total === "number" && Number.isFinite(total);
+
+	const label = hasTotal
+		? `${countLoaded} von ${total} Treffern geladen`
+		: `${countLoaded} Treffer (aktuell geladen)`;
+
+	return (
+		<div className="flex flex-wrap items-center justify-between gap-2">
+			<div className="text-xs text-muted-foreground">{label}</div>
+
+			<DropdownMenu>
+				<DropdownMenuTrigger asChild>
+					<Button variant="outline" size="sm" type="button">
+						<SlidersHorizontal className="h-4 w-4" />
+						Sortierung
+					</Button>
+				</DropdownMenuTrigger>
+
+				<DropdownMenuContent align="end" className="min-w-[16rem]">
+					<DropdownMenuLabel>Sortierung</DropdownMenuLabel>
+					<DropdownMenuSeparator />
+
+					<DropdownMenuRadioGroup
+						value={sortMode}
+						onValueChange={(value) => onSortModeChange(value)}
+					>
+						<DropdownMenuRadioItem value={SEARCH_RESULTS_SORT.RELEVANCE}>
+							Relevanz
+						</DropdownMenuRadioItem>
+						<DropdownMenuRadioItem value={SEARCH_RESULTS_SORT.DATE_DESC}>
+							Datum (neueste zuerst)
+						</DropdownMenuRadioItem>
+						<DropdownMenuRadioItem value={SEARCH_RESULTS_SORT.FILENAME_ASC}>
+							Dateiname (A–Z)
+						</DropdownMenuRadioItem>
+					</DropdownMenuRadioGroup>
+				</DropdownMenuContent>
+			</DropdownMenu>
+		</div>
+	);
+}