Ver Fonte

RHL-024 feat(search): implement search UI with form, results display, and sorting functionality

Code_Uwe há 3 semanas atrás
pai
commit
07e61cf991

+ 3 - 19
app/(protected)/[branch]/search/page.jsx

@@ -1,22 +1,6 @@
-import PlaceholderPage from "@/components/placeholders/PlaceholderPage";
+import SearchPage from "@/components/search/SearchPage";
 
-/**
- * /:branch/search
- *
- * Important:
- * - This is a static segment under [branch].
- * - It must exist explicitly, so "search" is not interpreted as [year].
- *
- * Next.js 15+ treats `params` as an async value (Promise) for dynamic routes.
- */
 export default async function BranchSearchPage({ params }) {
-	const resolvedParams = await params;
-
-	return (
-		<PlaceholderPage
-			title="Suche"
-			description="Platzhalter für die Suche. Die echte Suche wird in einem späteren Ticket umgesetzt."
-			params={resolvedParams}
-		/>
-	);
+	const { branch } = await params;
+	return <SearchPage branch={branch} />;
 }

+ 193 - 0
components/search/SearchForm.jsx

@@ -0,0 +1,193 @@
+"use client";
+
+import React from "react";
+import { ChevronDown, Search } from "lucide-react";
+
+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",
+});
+
+export default function SearchForm({
+	branch,
+	qDraft,
+	onQDraftChange,
+	onSubmit,
+	currentQuery,
+	isSubmitting,
+	isAdminDev,
+	scope,
+	onScopeChange,
+	availableBranches,
+	branchesStatus,
+	selectedBranches,
+	onToggleBranch,
+}) {
+	const canSearch = typeof qDraft === "string" && qDraft.trim().length > 0;
+
+	const scopeLabel = SCOPE_LABELS[scope] || "Unbekannt";
+
+	return (
+		<div className="space-y-4">
+			<form
+				onSubmit={(e) => {
+					e.preventDefault();
+					if (!canSearch) return;
+					onSubmit();
+				}}
+				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>
+
+				{isAdminDev ? (
+					<div className="grid gap-2">
+						<Label>Suchbereich</Label>
+
+						<div className="flex flex-wrap items-center gap-2">
+							<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>
+
+							{scope === SEARCH_SCOPE.SINGLE ? (
+								<span className="text-xs text-muted-foreground">
+									Aktiv: {branch}
+								</span>
+							) : null}
+						</div>
+					</div>
+				) : null}
+			</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>
+			) : null}
+		</div>
+	);
+}

+ 441 - 0
components/search/SearchPage.jsx

@@ -0,0 +1,441 @@
+"use client";
+
+import React from "react";
+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,
+} from "@/lib/frontend/search/urlState";
+import { mapSearchError } from "@/lib/frontend/search/errorMapping";
+
+import ExplorerPageShell from "@/components/explorer/ExplorerPageShell";
+import ExplorerSectionCard from "@/components/explorer/ExplorerSectionCard";
+import ForbiddenView from "@/components/system/ForbiddenView";
+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",
+});
+
+export default function SearchPage({ branch }) {
+	const router = useRouter();
+	const searchParams = useSearchParams();
+	const { status, user } = useAuth();
+
+	const isAuthenticated = status === "authenticated" && user;
+	const isAdminDev =
+		isAuthenticated && (user.role === "admin" || user.role === "dev");
+	const isBranchUser = isAuthenticated && user.role === "branch";
+
+	const parsedUrlState = React.useMemo(() => {
+		return parseSearchUrlState(searchParams, { routeBranch: branch });
+	}, [searchParams, branch]);
+
+	// Enforce "single = this route branch" and keep branch users safe.
+	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]);
+
+	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).
+	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;
+
+				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);
+
+	const mappedError = React.useMemo(() => mapSearchError(error), [error]);
+	const mappedLoadMoreError = React.useMemo(
+		() => mapSearchError(loadMoreError),
+		[loadMoreError]
+	);
+
+	// Redirect on unauthenticated search calls (session expired mid-session).
+	React.useEffect(() => {
+		if (mappedError?.kind !== "unauthenticated") return;
+
+		const next =
+			typeof window !== "undefined"
+				? `${window.location.pathname}${window.location.search}`
+				: searchPath(branch);
+
+		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;
+
+		if (push) router.push(href);
+		else router.replace(href);
+	}
+
+	function handleSubmit() {
+		const nextState = {
+			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,
+		};
+
+		buildHref(nextState, { push: true });
+	}
+
+	function handleScopeChange(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 id = ++loadMoreRequestIdRef.current;
+
+		setIsLoadingMore(true);
+		setLoadMoreError(null);
+
+		try {
+			const res = await search(req);
+
+			// If a newer "load more" started, ignore this result.
+			if (id !== loadMoreRequestIdRef.current) return;
+
+			// If the base search changed, do not append.
+			if (searchKeyRef.current !== baseKey) return;
+
+			const moreItems = Array.isArray(res?.items) ? res.items : [];
+			const next = typeof res?.nextCursor === "string" ? res.nextCursor : null;
+
+			setItems((prev) => [...prev, ...moreItems]);
+			setNextCursor(next);
+		} catch (err) {
+			if (id !== loadMoreRequestIdRef.current) return;
+			setLoadMoreError(err);
+		} finally {
+			if (id === loadMoreRequestIdRef.current) {
+				setIsLoadingMore(false);
+			}
+		}
+	}
+
+	// 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]);
+
+	// Forbidden (keep consistent with Explorer UX).
+	if (mappedError?.kind === "forbidden") {
+		return <ForbiddenView attemptedBranch={branch} />;
+	}
+
+	const actions = (
+		<Button
+			variant="outline"
+			size="sm"
+			onClick={() => runFirstPage()}
+			disabled={!urlState.q || statusState === "loading"}
+			title="Aktualisieren"
+		>
+			<RefreshCw className="h-4 w-4" />
+			Aktualisieren
+		</Button>
+	);
+
+	const resultsHeaderRight = urlState.q ? (
+		<span className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
+			{urlState.q}
+		</span>
+	) : null;
+
+	const resultsDescription = urlState.q
+		? `Niederlassung ${branch}`
+		: "Geben Sie einen Suchbegriff ein, um zu starten.";
+
+	return (
+		<ExplorerPageShell
+			title="Suche"
+			description={`Lieferscheine durchsuchen • Niederlassung ${branch}`}
+			actions={actions}
+		>
+			<ExplorerSectionCard
+				title="Suche"
+				description="Suchbegriff und Suchbereich auswählen."
+			>
+				<SearchForm
+					branch={branch}
+					qDraft={qDraft}
+					onQDraftChange={setQDraft}
+					onSubmit={handleSubmit}
+					currentQuery={urlState.q}
+					isSubmitting={statusState === "loading"}
+					isAdminDev={isAdminDev}
+					scope={urlState.scope}
+					onScopeChange={handleScopeChange}
+					availableBranches={
+						branchList.status === BRANCH_LIST_STATE.READY &&
+						Array.isArray(branchList.branches)
+							? branchList.branches
+							: []
+					}
+					branchesStatus={branchList.status}
+					selectedBranches={urlState.branches}
+					onToggleBranch={toggleMultiBranch}
+				/>
+			</ExplorerSectionCard>
+
+			<ExplorerSectionCard
+				title="Ergebnisse"
+				description={resultsDescription}
+				headerRight={resultsHeaderRight}
+			>
+				<SearchResults
+					branch={branch}
+					scope={urlState.scope}
+					status={statusState}
+					items={items}
+					error={mappedError}
+					onRetry={runFirstPage}
+					nextCursor={nextCursor}
+					onLoadMore={loadMore}
+					isLoadingMore={isLoadingMore}
+					loadMoreError={mappedLoadMoreError}
+				/>
+			</ExplorerSectionCard>
+		</ExplorerPageShell>
+	);
+}

+ 306 - 0
components/search/SearchResults.jsx

@@ -0,0 +1,306 @@
+"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 ExplorerLoading from "@/components/explorer/states/ExplorerLoading";
+import ExplorerEmpty from "@/components/explorer/states/ExplorerEmpty";
+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}`;
+}
+
+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}`;
+}
+
+export default function SearchResults({
+	branch,
+	scope,
+	status,
+	items,
+	error,
+	onRetry,
+	nextCursor,
+	onLoadMore,
+	isLoadingMore,
+	loadMoreError,
+}) {
+	const showBranchColumn =
+		scope === SEARCH_SCOPE.ALL || scope === SEARCH_SCOPE.MULTI;
+
+	const [sortMode, setSortMode] = React.useState(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;
+	}, [items, sortMode]);
+
+	if (status === "idle") {
+		return (
+			<ExplorerEmpty
+				title="Suche starten"
+				description="Bitte geben Sie einen Suchbegriff ein und klicken Sie auf „Suchen“."
+				upHref={null}
+			/>
+		);
+	}
+
+	if (status === "loading") {
+		return <ExplorerLoading variant="table" count={8} />;
+	}
+
+	if (status === "error" && error) {
+		return (
+			<ExplorerError
+				title={error.title}
+				description={error.description}
+				onRetry={onRetry}
+			/>
+		);
+	}
+
+	const list = Array.isArray(sortedItems) ? sortedItems : [];
+
+	if (list.length === 0) {
+		return (
+			<ExplorerEmpty
+				title="Keine Treffer"
+				description="Für Ihre Suche wurden keine Treffer gefunden."
+				upHref={null}
+			/>
+		);
+	}
+
+	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>
+
+										<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>
+
+			{loadMoreError ? (
+				<Alert variant="destructive">
+					<AlertTitle>{loadMoreError.title}</AlertTitle>
+					<AlertDescription>{loadMoreError.description}</AlertDescription>
+				</Alert>
+			) : null}
+
+			{nextCursor ? (
+				<div className="flex justify-center">
+					<Button
+						type="button"
+						variant="outline"
+						onClick={onLoadMore}
+						disabled={isLoadingMore}
+						title="Weitere Ergebnisse laden"
+					>
+						{isLoadingMore ? (
+							<>
+								<Loader2 className="h-4 w-4 animate-spin" />
+								Lädt…
+							</>
+						) : (
+							"Mehr laden"
+						)}
+					</Button>
+				</div>
+			) : null}
+		</div>
+	);
+}