SearchPage.jsx 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  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 { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
  7. import { searchPath } from "@/lib/frontend/routes";
  8. import { isValidBranchParam } from "@/lib/frontend/params";
  9. import { isAdminLike as isAdminLikeRole } from "@/lib/frontend/auth/roles";
  10. import { parseSearchUrlState } from "@/lib/frontend/search/urlState";
  11. import { normalizeSearchUrlStateForUser } from "@/lib/frontend/search/normalizeState";
  12. import { mapSearchError } from "@/lib/frontend/search/errorMapping";
  13. import { useSearchQuery } from "@/lib/frontend/search/useSearchQuery";
  14. import {
  15. useSearchBranches,
  16. BRANCH_LIST_STATE,
  17. } from "@/lib/frontend/search/useSearchBranches";
  18. import {
  19. buildSearchHref,
  20. buildSearchKey,
  21. getScopeLabel,
  22. needsMultiBranchSelectionHint,
  23. buildNextStateForScopeChange,
  24. buildNextStateForToggleBranch,
  25. buildNextStateForClearAllBranches,
  26. buildHrefForSingleBranchSwitch,
  27. } from "@/lib/frontend/search/pageHelpers";
  28. import {
  29. loadSearchHistory,
  30. addSearchHistoryEntry,
  31. clearSearchHistory,
  32. buildSearchHrefFromEntry,
  33. DEFAULT_SEARCH_HISTORY_MAX_ITEMS,
  34. } from "@/lib/frontend/search/history";
  35. import { buildDateFilterValidationError } from "@/lib/frontend/search/dateFilterValidation";
  36. import ExplorerPageShell from "@/components/explorer/ExplorerPageShell";
  37. import ExplorerSectionCard from "@/components/explorer/ExplorerSectionCard";
  38. import ForbiddenView from "@/components/system/ForbiddenView";
  39. import { Button } from "@/components/ui/button";
  40. import SearchForm from "@/components/search/SearchForm";
  41. import SearchResults from "@/components/search/SearchResults";
  42. export default function SearchPage({ branch: routeBranch }) {
  43. const router = useRouter();
  44. const searchParams = useSearchParams();
  45. const { status: authStatus, user } = useAuth();
  46. const userId =
  47. typeof user?.userId === "string" && user.userId.trim()
  48. ? user.userId.trim()
  49. : null;
  50. const isAuthenticated = authStatus === "authenticated" && user;
  51. const isAdminLike = isAuthenticated && isAdminLikeRole(user.role);
  52. const parsedUrlState = React.useMemo(() => {
  53. return parseSearchUrlState(searchParams, { routeBranch });
  54. }, [searchParams, routeBranch]);
  55. const urlState = React.useMemo(() => {
  56. return normalizeSearchUrlStateForUser(parsedUrlState, {
  57. routeBranch,
  58. user,
  59. });
  60. }, [parsedUrlState, routeBranch, user]);
  61. const searchKey = React.useMemo(() => {
  62. return buildSearchKey({ routeBranch, urlState });
  63. }, [routeBranch, urlState]);
  64. const [qDraft, setQDraft] = React.useState(urlState.q || "");
  65. React.useEffect(() => {
  66. setQDraft(urlState.q || "");
  67. }, [urlState.q]);
  68. const [recentSearches, setRecentSearches] = React.useState([]);
  69. React.useEffect(() => {
  70. if (!userId) {
  71. setRecentSearches([]);
  72. return;
  73. }
  74. setRecentSearches(loadSearchHistory(userId));
  75. }, [userId]);
  76. const historyWriteGuardRef = React.useRef("");
  77. React.useEffect(() => {
  78. historyWriteGuardRef.current = "";
  79. }, [userId]);
  80. const branchesQuery = useSearchBranches({ enabled: isAdminLike });
  81. const query = useSearchQuery({
  82. searchKey,
  83. urlState,
  84. routeBranch,
  85. user,
  86. limit: urlState.limit,
  87. });
  88. const mappedError = React.useMemo(
  89. () => mapSearchError(query.error),
  90. [query.error],
  91. );
  92. const mappedLoadMoreError = React.useMemo(
  93. () => mapSearchError(query.loadMoreError),
  94. [query.loadMoreError],
  95. );
  96. const localDateValidationError = React.useMemo(() => {
  97. return buildDateFilterValidationError({
  98. from: urlState.from,
  99. to: urlState.to,
  100. });
  101. }, [urlState.from, urlState.to]);
  102. const mappedLocalDateValidation = React.useMemo(() => {
  103. return mapSearchError(localDateValidationError);
  104. }, [localDateValidationError]);
  105. const formValidationError =
  106. mappedError?.kind === "validation"
  107. ? mappedError
  108. : mappedLocalDateValidation?.kind === "validation"
  109. ? mappedLocalDateValidation
  110. : null;
  111. React.useEffect(() => {
  112. // Trigger policy: store only executed searches that completed first-page loading successfully.
  113. if (!userId) return;
  114. if (query.status !== "success") return;
  115. if (typeof urlState.q !== "string" || !urlState.q.trim()) return;
  116. const writeKey = `${userId}|${searchKey}`;
  117. if (historyWriteGuardRef.current === writeKey) return;
  118. const nextEntries = addSearchHistoryEntry(
  119. userId,
  120. {
  121. routeBranch,
  122. q: urlState.q,
  123. scope: urlState.scope,
  124. branches: urlState.branches,
  125. limit: urlState.limit,
  126. from: urlState.from,
  127. to: urlState.to,
  128. createdAt: Date.now(),
  129. },
  130. { maxItems: DEFAULT_SEARCH_HISTORY_MAX_ITEMS },
  131. );
  132. setRecentSearches(nextEntries);
  133. historyWriteGuardRef.current = writeKey;
  134. }, [
  135. userId,
  136. query.status,
  137. searchKey,
  138. routeBranch,
  139. urlState.q,
  140. urlState.scope,
  141. urlState.branches,
  142. urlState.limit,
  143. urlState.from,
  144. urlState.to,
  145. ]);
  146. React.useEffect(() => {
  147. if (mappedError?.kind !== "unauthenticated") return;
  148. const next =
  149. typeof window !== "undefined"
  150. ? `${window.location.pathname}${window.location.search}`
  151. : searchPath(routeBranch);
  152. window.location.replace(
  153. buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next }),
  154. );
  155. }, [mappedError?.kind, routeBranch]);
  156. const pushStateToUrl = React.useCallback(
  157. (nextState) => {
  158. router.push(buildSearchHref({ routeBranch, state: nextState }));
  159. },
  160. [router, routeBranch],
  161. );
  162. const replaceStateToUrl = React.useCallback(
  163. (nextState) => {
  164. router.replace(buildSearchHref({ routeBranch, state: nextState }));
  165. },
  166. [router, routeBranch],
  167. );
  168. const handleSubmit = React.useCallback(() => {
  169. pushStateToUrl({ ...urlState, q: qDraft });
  170. }, [pushStateToUrl, urlState, qDraft]);
  171. const handleScopeChange = React.useCallback(
  172. (nextScope) => {
  173. if (!isAdminLike) return;
  174. replaceStateToUrl(buildNextStateForScopeChange({ urlState, nextScope }));
  175. },
  176. [isAdminLike, urlState, replaceStateToUrl],
  177. );
  178. const handleToggleBranch = React.useCallback(
  179. (branchId) => {
  180. if (!isAdminLike) return;
  181. replaceStateToUrl(buildNextStateForToggleBranch({ urlState, branchId }));
  182. },
  183. [isAdminLike, urlState, replaceStateToUrl],
  184. );
  185. const handleClearAllBranches = React.useCallback(() => {
  186. if (!isAdminLike) return;
  187. replaceStateToUrl(buildNextStateForClearAllBranches({ urlState }));
  188. }, [isAdminLike, urlState, replaceStateToUrl]);
  189. const handleLimitChange = React.useCallback(
  190. (nextLimit) => {
  191. replaceStateToUrl({ ...urlState, limit: nextLimit });
  192. },
  193. [urlState, replaceStateToUrl],
  194. );
  195. const handleSingleBranchChange = React.useCallback(
  196. (nextBranch) => {
  197. if (!isAdminLike) return;
  198. if (!isValidBranchParam(nextBranch)) return;
  199. const href = buildHrefForSingleBranchSwitch({ nextBranch, urlState });
  200. if (!href) return;
  201. router.push(href);
  202. },
  203. [isAdminLike, urlState, router],
  204. );
  205. const handleDateRangeChange = React.useCallback(
  206. ({ from, to }) => {
  207. replaceStateToUrl({
  208. ...urlState,
  209. from: from ?? null,
  210. to: to ?? null,
  211. });
  212. },
  213. [urlState, replaceStateToUrl],
  214. );
  215. const handleSelectRecentSearch = React.useCallback(
  216. (entry) => {
  217. const href = buildSearchHrefFromEntry(entry);
  218. if (!href) return;
  219. router.push(href);
  220. },
  221. [router],
  222. );
  223. const handleClearRecentSearches = React.useCallback(() => {
  224. if (!userId) return;
  225. clearSearchHistory(userId);
  226. setRecentSearches([]);
  227. historyWriteGuardRef.current = "";
  228. }, [userId]);
  229. if (mappedError?.kind === "forbidden") {
  230. return <ForbiddenView attemptedBranch={routeBranch} />;
  231. }
  232. const actions = (
  233. <Button
  234. variant="outline"
  235. size="sm"
  236. onClick={query.retry}
  237. disabled={!urlState.q || query.status === "loading"}
  238. title="Aktualisieren"
  239. >
  240. <RefreshCw className="h-4 w-4" />
  241. Aktualisieren
  242. </Button>
  243. );
  244. const resultsHeaderRight = urlState.q ? (
  245. <span className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
  246. {urlState.q}
  247. </span>
  248. ) : null;
  249. const scopeLabel = getScopeLabel({ routeBranch, urlState });
  250. const resultsDescription = urlState.q
  251. ? `Suchbereich: ${scopeLabel}`
  252. : "Geben Sie einen Suchbegriff ein, um zu starten.";
  253. const needsBranchSelection = needsMultiBranchSelectionHint({
  254. isAdminDev: isAdminLike,
  255. urlState,
  256. });
  257. return (
  258. <ExplorerPageShell
  259. title="Suche"
  260. description={`Lieferscheine durchsuchen • Niederlassung ${routeBranch}`}
  261. actions={actions}
  262. >
  263. <ExplorerSectionCard
  264. title="Suche"
  265. description="Suchbegriff und Suchbereich auswählen."
  266. >
  267. <SearchForm
  268. branch={routeBranch}
  269. qDraft={qDraft}
  270. onQDraftChange={setQDraft}
  271. onSubmit={handleSubmit}
  272. currentQuery={urlState.q}
  273. isSubmitting={query.status === "loading"}
  274. isAdminDev={isAdminLike}
  275. scope={urlState.scope}
  276. onScopeChange={handleScopeChange}
  277. onSingleBranchChange={handleSingleBranchChange}
  278. availableBranches={
  279. branchesQuery.status === BRANCH_LIST_STATE.READY &&
  280. Array.isArray(branchesQuery.branches)
  281. ? branchesQuery.branches
  282. : []
  283. }
  284. branchesStatus={branchesQuery.status}
  285. selectedBranches={urlState.branches}
  286. onToggleBranch={handleToggleBranch}
  287. onClearAllBranches={handleClearAllBranches}
  288. limit={urlState.limit}
  289. onLimitChange={handleLimitChange}
  290. from={urlState.from}
  291. to={urlState.to}
  292. onDateRangeChange={handleDateRangeChange}
  293. validationError={formValidationError}
  294. recentSearches={recentSearches}
  295. onSelectRecentSearch={handleSelectRecentSearch}
  296. onClearRecentSearches={handleClearRecentSearches}
  297. />
  298. </ExplorerSectionCard>
  299. <ExplorerSectionCard
  300. title="Ergebnisse"
  301. description={resultsDescription}
  302. headerRight={resultsHeaderRight}
  303. >
  304. <SearchResults
  305. branch={routeBranch}
  306. status={query.status}
  307. items={query.items}
  308. total={query.total}
  309. error={mappedError}
  310. onRetry={query.retry}
  311. nextCursor={query.nextCursor}
  312. onLoadMore={query.loadMore}
  313. isLoadingMore={query.isLoadingMore}
  314. loadMoreError={mappedLoadMoreError}
  315. needsBranchSelection={needsBranchSelection}
  316. />
  317. </ExplorerSectionCard>
  318. </ExplorerPageShell>
  319. );
  320. }