|
@@ -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>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|