SearchPage.jsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. "use client";
  2. import React from "react";
  3. import { useRouter, useSearchParams } from "next/navigation";
  4. import { RefreshCw } from "lucide-react";
  5. import { useAuth } from "@/components/auth/authContext";
  6. import { ApiClientError, getBranches, search } from "@/lib/frontend/apiClient";
  7. import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
  8. import { searchPath } from "@/lib/frontend/routes";
  9. import {
  10. SEARCH_SCOPE,
  11. parseSearchUrlState,
  12. serializeSearchUrlState,
  13. } from "@/lib/frontend/search/urlState";
  14. import { mapSearchError } from "@/lib/frontend/search/errorMapping";
  15. import ExplorerPageShell from "@/components/explorer/ExplorerPageShell";
  16. import ExplorerSectionCard from "@/components/explorer/ExplorerSectionCard";
  17. import ForbiddenView from "@/components/system/ForbiddenView";
  18. import { Button } from "@/components/ui/button";
  19. import SearchForm from "@/components/search/SearchForm";
  20. import SearchResults from "@/components/search/SearchResults";
  21. const PAGE_LIMIT = 100;
  22. const BRANCH_LIST_STATE = Object.freeze({
  23. IDLE: "idle",
  24. LOADING: "loading",
  25. READY: "ready",
  26. ERROR: "error",
  27. });
  28. export default function SearchPage({ branch }) {
  29. const router = useRouter();
  30. const searchParams = useSearchParams();
  31. const { status, user } = useAuth();
  32. const isAuthenticated = status === "authenticated" && user;
  33. const isAdminDev =
  34. isAuthenticated && (user.role === "admin" || user.role === "dev");
  35. const isBranchUser = isAuthenticated && user.role === "branch";
  36. const parsedUrlState = React.useMemo(() => {
  37. return parseSearchUrlState(searchParams, { routeBranch: branch });
  38. }, [searchParams, branch]);
  39. // Enforce "single = this route branch" and keep branch users safe.
  40. const urlState = React.useMemo(() => {
  41. // AuthGate ensures auth before rendering, but keep this defensive.
  42. if (!user) return parsedUrlState;
  43. // Branch users: always single, no cross-branch scope params.
  44. if (user.role === "branch") {
  45. return {
  46. ...parsedUrlState,
  47. scope: SEARCH_SCOPE.SINGLE,
  48. branch,
  49. branches: [],
  50. };
  51. }
  52. // Admin/dev: single scope is always the route branch context.
  53. if (parsedUrlState.scope === SEARCH_SCOPE.SINGLE) {
  54. return { ...parsedUrlState, branch };
  55. }
  56. return parsedUrlState;
  57. }, [parsedUrlState, user, branch]);
  58. const searchKey = React.useMemo(() => {
  59. // This is our "query identity" without cursor.
  60. return serializeSearchUrlState(urlState);
  61. }, [urlState]);
  62. // Keep a ref of the latest key so async "load more" cannot append to a new search.
  63. const searchKeyRef = React.useRef(searchKey);
  64. React.useEffect(() => {
  65. searchKeyRef.current = searchKey;
  66. }, [searchKey]);
  67. // Input draft (URL remains the single source of truth for executed searches).
  68. const [qDraft, setQDraft] = React.useState(urlState.q || "");
  69. React.useEffect(() => {
  70. setQDraft(urlState.q || "");
  71. }, [urlState.q]);
  72. // Admin/dev: load branch list for multi-select (fail-open).
  73. const [branchList, setBranchList] = React.useState({
  74. status: BRANCH_LIST_STATE.IDLE,
  75. branches: null,
  76. });
  77. React.useEffect(() => {
  78. if (!isAdminDev) return;
  79. let cancelled = false;
  80. setBranchList({ status: BRANCH_LIST_STATE.LOADING, branches: null });
  81. (async () => {
  82. try {
  83. const res = await getBranches();
  84. if (cancelled) return;
  85. const branches = Array.isArray(res?.branches) ? res.branches : [];
  86. setBranchList({ status: BRANCH_LIST_STATE.READY, branches });
  87. } catch (err) {
  88. if (cancelled) return;
  89. console.error("[SearchPage] getBranches failed:", err);
  90. setBranchList({ status: BRANCH_LIST_STATE.ERROR, branches: null });
  91. }
  92. })();
  93. return () => {
  94. cancelled = true;
  95. };
  96. }, [isAdminDev, user?.userId]);
  97. // Search results state.
  98. const [statusState, setStatusState] = React.useState("idle"); // idle|loading|success|error
  99. const [items, setItems] = React.useState([]);
  100. const [nextCursor, setNextCursor] = React.useState(null);
  101. const [error, setError] = React.useState(null);
  102. const [isLoadingMore, setIsLoadingMore] = React.useState(false);
  103. const [loadMoreError, setLoadMoreError] = React.useState(null);
  104. const searchRequestIdRef = React.useRef(0);
  105. const loadMoreRequestIdRef = React.useRef(0);
  106. const mappedError = React.useMemo(() => mapSearchError(error), [error]);
  107. const mappedLoadMoreError = React.useMemo(
  108. () => mapSearchError(loadMoreError),
  109. [loadMoreError]
  110. );
  111. // Redirect on unauthenticated search calls (session expired mid-session).
  112. React.useEffect(() => {
  113. if (mappedError?.kind !== "unauthenticated") return;
  114. const next =
  115. typeof window !== "undefined"
  116. ? `${window.location.pathname}${window.location.search}`
  117. : searchPath(branch);
  118. window.location.replace(
  119. buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next })
  120. );
  121. }, [mappedError?.kind, branch]);
  122. function buildHref(nextState, { push } = {}) {
  123. const base = searchPath(branch);
  124. const qs = serializeSearchUrlState(nextState);
  125. const href = qs ? `${base}?${qs}` : base;
  126. if (push) router.push(href);
  127. else router.replace(href);
  128. }
  129. function handleSubmit() {
  130. const nextState = {
  131. q: qDraft,
  132. // Branch users always single; admin/dev use selected scope.
  133. scope: isAdminDev ? urlState.scope : SEARCH_SCOPE.SINGLE,
  134. branch, // explicit for shareability (even though route already contains it)
  135. branches: urlState.branches,
  136. from: urlState.from,
  137. to: urlState.to,
  138. };
  139. buildHref(nextState, { push: true });
  140. }
  141. function handleScopeChange(nextScope) {
  142. if (!isAdminDev) return;
  143. const nextState = {
  144. // Scope changes rerun the currently executed query (URL q), not the draft.
  145. q: urlState.q,
  146. scope: nextScope,
  147. branch,
  148. branches: nextScope === SEARCH_SCOPE.MULTI ? urlState.branches : [],
  149. from: urlState.from,
  150. to: urlState.to,
  151. };
  152. buildHref(nextState, { push: false });
  153. }
  154. function toggleMultiBranch(branchId) {
  155. if (!isAdminDev) return;
  156. const current = Array.isArray(urlState.branches) ? urlState.branches : [];
  157. const set = new Set(current);
  158. if (set.has(branchId)) set.delete(branchId);
  159. else set.add(branchId);
  160. const nextState = {
  161. q: urlState.q,
  162. scope: SEARCH_SCOPE.MULTI,
  163. branches: Array.from(set),
  164. from: urlState.from,
  165. to: urlState.to,
  166. };
  167. buildHref(nextState, { push: false });
  168. }
  169. function buildSearchRequest({ cursor = null } = {}) {
  170. const q = urlState.q;
  171. if (!q) return null;
  172. const base = {
  173. q,
  174. limit: PAGE_LIMIT,
  175. };
  176. if (urlState.from) base.from = urlState.from;
  177. if (urlState.to) base.to = urlState.to;
  178. if (cursor) base.cursor = cursor;
  179. // Branch role: never send scope/branches from URL (avoid forbidden / keep safe).
  180. if (isBranchUser) {
  181. base.branch = branch;
  182. return base;
  183. }
  184. // Admin/dev scopes:
  185. if (urlState.scope === SEARCH_SCOPE.ALL) {
  186. base.scope = "all";
  187. return base;
  188. }
  189. if (urlState.scope === SEARCH_SCOPE.MULTI) {
  190. base.scope = "multi";
  191. base.branches = urlState.branches;
  192. return base;
  193. }
  194. // Single (explicit branch param for shareability)
  195. base.branch = branch;
  196. return base;
  197. }
  198. async function runFirstPage() {
  199. // Reset "load more" UI whenever we start a new first-page search.
  200. setIsLoadingMore(false);
  201. setLoadMoreError(null);
  202. const q = urlState.q;
  203. // No search yet.
  204. if (!q) {
  205. setStatusState("idle");
  206. setItems([]);
  207. setNextCursor(null);
  208. setError(null);
  209. return;
  210. }
  211. // Local validation: multi scope requires at least one branch.
  212. if (isAdminDev && urlState.scope === SEARCH_SCOPE.MULTI) {
  213. const branches = Array.isArray(urlState.branches)
  214. ? urlState.branches
  215. : [];
  216. if (branches.length === 0) {
  217. setStatusState("error");
  218. setItems([]);
  219. setNextCursor(null);
  220. setError(
  221. new ApiClientError({
  222. status: 400,
  223. code: "VALIDATION_SEARCH_BRANCHES",
  224. message: "Invalid branches",
  225. })
  226. );
  227. return;
  228. }
  229. }
  230. const req = buildSearchRequest({ cursor: null });
  231. if (!req) return;
  232. const id = ++searchRequestIdRef.current;
  233. setStatusState("loading");
  234. setItems([]);
  235. setNextCursor(null);
  236. setError(null);
  237. try {
  238. const res = await search(req);
  239. if (id !== searchRequestIdRef.current) return;
  240. const nextItems = Array.isArray(res?.items) ? res.items : [];
  241. const next = typeof res?.nextCursor === "string" ? res.nextCursor : null;
  242. setItems(nextItems);
  243. setNextCursor(next);
  244. setStatusState("success");
  245. } catch (err) {
  246. if (id !== searchRequestIdRef.current) return;
  247. setItems([]);
  248. setNextCursor(null);
  249. setError(err);
  250. setStatusState("error");
  251. }
  252. }
  253. async function loadMore() {
  254. if (!nextCursor) return;
  255. if (isLoadingMore) return;
  256. const baseKey = searchKeyRef.current;
  257. const req = buildSearchRequest({ cursor: nextCursor });
  258. if (!req) return;
  259. const id = ++loadMoreRequestIdRef.current;
  260. setIsLoadingMore(true);
  261. setLoadMoreError(null);
  262. try {
  263. const res = await search(req);
  264. // If a newer "load more" started, ignore this result.
  265. if (id !== loadMoreRequestIdRef.current) return;
  266. // If the base search changed, do not append.
  267. if (searchKeyRef.current !== baseKey) return;
  268. const moreItems = Array.isArray(res?.items) ? res.items : [];
  269. const next = typeof res?.nextCursor === "string" ? res.nextCursor : null;
  270. setItems((prev) => [...prev, ...moreItems]);
  271. setNextCursor(next);
  272. } catch (err) {
  273. if (id !== loadMoreRequestIdRef.current) return;
  274. setLoadMoreError(err);
  275. } finally {
  276. if (id === loadMoreRequestIdRef.current) {
  277. setIsLoadingMore(false);
  278. }
  279. }
  280. }
  281. // Run first-page search whenever the URL-driven search identity changes.
  282. React.useEffect(() => {
  283. runFirstPage();
  284. // eslint-disable-next-line react-hooks/exhaustive-deps
  285. }, [searchKey, isAdminDev, isBranchUser, branch]);
  286. // Forbidden (keep consistent with Explorer UX).
  287. if (mappedError?.kind === "forbidden") {
  288. return <ForbiddenView attemptedBranch={branch} />;
  289. }
  290. const actions = (
  291. <Button
  292. variant="outline"
  293. size="sm"
  294. onClick={() => runFirstPage()}
  295. disabled={!urlState.q || statusState === "loading"}
  296. title="Aktualisieren"
  297. >
  298. <RefreshCw className="h-4 w-4" />
  299. Aktualisieren
  300. </Button>
  301. );
  302. const resultsHeaderRight = urlState.q ? (
  303. <span className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
  304. {urlState.q}
  305. </span>
  306. ) : null;
  307. const resultsDescription = urlState.q
  308. ? `Niederlassung ${branch}`
  309. : "Geben Sie einen Suchbegriff ein, um zu starten.";
  310. return (
  311. <ExplorerPageShell
  312. title="Suche"
  313. description={`Lieferscheine durchsuchen • Niederlassung ${branch}`}
  314. actions={actions}
  315. >
  316. <ExplorerSectionCard
  317. title="Suche"
  318. description="Suchbegriff und Suchbereich auswählen."
  319. >
  320. <SearchForm
  321. branch={branch}
  322. qDraft={qDraft}
  323. onQDraftChange={setQDraft}
  324. onSubmit={handleSubmit}
  325. currentQuery={urlState.q}
  326. isSubmitting={statusState === "loading"}
  327. isAdminDev={isAdminDev}
  328. scope={urlState.scope}
  329. onScopeChange={handleScopeChange}
  330. availableBranches={
  331. branchList.status === BRANCH_LIST_STATE.READY &&
  332. Array.isArray(branchList.branches)
  333. ? branchList.branches
  334. : []
  335. }
  336. branchesStatus={branchList.status}
  337. selectedBranches={urlState.branches}
  338. onToggleBranch={toggleMultiBranch}
  339. />
  340. </ExplorerSectionCard>
  341. <ExplorerSectionCard
  342. title="Ergebnisse"
  343. description={resultsDescription}
  344. headerRight={resultsHeaderRight}
  345. >
  346. <SearchResults
  347. branch={branch}
  348. scope={urlState.scope}
  349. status={statusState}
  350. items={items}
  351. error={mappedError}
  352. onRetry={runFirstPage}
  353. nextCursor={nextCursor}
  354. onLoadMore={loadMore}
  355. isLoadingMore={isLoadingMore}
  356. loadMoreError={mappedLoadMoreError}
  357. />
  358. </ExplorerSectionCard>
  359. </ExplorerPageShell>
  360. );
  361. }