| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441 |
- "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>
- );
- }
|