SearchPage.jsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  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 {
  10. parseSearchUrlState,
  11. SEARCH_SCOPE,
  12. } from "@/lib/frontend/search/urlState";
  13. import { normalizeSearchUrlStateForUser } from "@/lib/frontend/search/normalizeState";
  14. import { mapSearchError } from "@/lib/frontend/search/errorMapping";
  15. import { useSearchQuery } from "@/lib/frontend/search/useSearchQuery";
  16. import {
  17. useSearchBranches,
  18. BRANCH_LIST_STATE,
  19. } from "@/lib/frontend/search/useSearchBranches";
  20. import {
  21. buildSearchHref,
  22. buildSearchKey,
  23. getScopeLabel,
  24. needsMultiBranchSelectionHint,
  25. buildNextStateForScopeChange,
  26. buildNextStateForToggleBranch,
  27. buildNextStateForClearAllBranches,
  28. buildHrefForSingleBranchSwitch,
  29. } from "@/lib/frontend/search/pageHelpers";
  30. import ExplorerPageShell from "@/components/explorer/ExplorerPageShell";
  31. import ExplorerSectionCard from "@/components/explorer/ExplorerSectionCard";
  32. import ForbiddenView from "@/components/system/ForbiddenView";
  33. import { Button } from "@/components/ui/button";
  34. import SearchForm from "@/components/search/SearchForm";
  35. import SearchResults from "@/components/search/SearchResults";
  36. export default function SearchPage({ branch: routeBranch }) {
  37. const router = useRouter();
  38. const searchParams = useSearchParams();
  39. const { status: authStatus, user } = useAuth();
  40. const isAuthenticated = authStatus === "authenticated" && user;
  41. const isAdminDev =
  42. isAuthenticated && (user.role === "admin" || user.role === "dev");
  43. const parsedUrlState = React.useMemo(() => {
  44. return parseSearchUrlState(searchParams, { routeBranch });
  45. }, [searchParams, routeBranch]);
  46. const urlState = React.useMemo(() => {
  47. return normalizeSearchUrlStateForUser(parsedUrlState, {
  48. routeBranch,
  49. user,
  50. });
  51. }, [parsedUrlState, routeBranch, user]);
  52. const searchKey = React.useMemo(() => {
  53. return buildSearchKey({ routeBranch, urlState });
  54. }, [routeBranch, urlState]);
  55. const [qDraft, setQDraft] = React.useState(urlState.q || "");
  56. React.useEffect(() => {
  57. setQDraft(urlState.q || "");
  58. }, [urlState.q]);
  59. const branchesQuery = useSearchBranches({ enabled: isAdminDev });
  60. const query = useSearchQuery({
  61. searchKey,
  62. urlState,
  63. routeBranch,
  64. user,
  65. limit: urlState.limit,
  66. });
  67. const mappedError = React.useMemo(
  68. () => mapSearchError(query.error),
  69. [query.error]
  70. );
  71. const mappedLoadMoreError = React.useMemo(
  72. () => mapSearchError(query.loadMoreError),
  73. [query.loadMoreError]
  74. );
  75. React.useEffect(() => {
  76. if (mappedError?.kind !== "unauthenticated") return;
  77. const next =
  78. typeof window !== "undefined"
  79. ? `${window.location.pathname}${window.location.search}`
  80. : searchPath(routeBranch);
  81. window.location.replace(
  82. buildLoginUrl({ reason: LOGIN_REASONS.EXPIRED, next })
  83. );
  84. }, [mappedError?.kind, routeBranch]);
  85. const pushStateToUrl = React.useCallback(
  86. (nextState) => {
  87. router.push(buildSearchHref({ routeBranch, state: nextState }));
  88. },
  89. [router, routeBranch]
  90. );
  91. const replaceStateToUrl = React.useCallback(
  92. (nextState) => {
  93. router.replace(buildSearchHref({ routeBranch, state: nextState }));
  94. },
  95. [router, routeBranch]
  96. );
  97. const handleSubmit = React.useCallback(() => {
  98. pushStateToUrl({ ...urlState, q: qDraft });
  99. }, [pushStateToUrl, urlState, qDraft]);
  100. const handleScopeChange = React.useCallback(
  101. (nextScope) => {
  102. if (!isAdminDev) return;
  103. replaceStateToUrl(buildNextStateForScopeChange({ urlState, nextScope }));
  104. },
  105. [isAdminDev, urlState, replaceStateToUrl]
  106. );
  107. const handleToggleBranch = React.useCallback(
  108. (branchId) => {
  109. if (!isAdminDev) return;
  110. replaceStateToUrl(buildNextStateForToggleBranch({ urlState, branchId }));
  111. },
  112. [isAdminDev, urlState, replaceStateToUrl]
  113. );
  114. const handleClearAllBranches = React.useCallback(() => {
  115. if (!isAdminDev) return;
  116. replaceStateToUrl(buildNextStateForClearAllBranches({ urlState }));
  117. }, [isAdminDev, urlState, replaceStateToUrl]);
  118. const handleLimitChange = React.useCallback(
  119. (nextLimit) => {
  120. replaceStateToUrl({ ...urlState, limit: nextLimit });
  121. },
  122. [urlState, replaceStateToUrl]
  123. );
  124. const handleSingleBranchChange = React.useCallback(
  125. (nextBranch) => {
  126. if (!isAdminDev) return;
  127. if (!isValidBranchParam(nextBranch)) return;
  128. const href = buildHrefForSingleBranchSwitch({ nextBranch, urlState });
  129. if (!href) return;
  130. router.push(href);
  131. },
  132. [isAdminDev, urlState, router]
  133. );
  134. if (mappedError?.kind === "forbidden") {
  135. return <ForbiddenView attemptedBranch={routeBranch} />;
  136. }
  137. const actions = (
  138. <Button
  139. variant="outline"
  140. size="sm"
  141. onClick={query.retry}
  142. disabled={!urlState.q || query.status === "loading"}
  143. title="Aktualisieren"
  144. >
  145. <RefreshCw className="h-4 w-4" />
  146. Aktualisieren
  147. </Button>
  148. );
  149. const resultsHeaderRight = urlState.q ? (
  150. <span className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
  151. {urlState.q}
  152. </span>
  153. ) : null;
  154. const scopeLabel = getScopeLabel({ routeBranch, urlState });
  155. const resultsDescription = urlState.q
  156. ? `Suchbereich: ${scopeLabel}`
  157. : "Geben Sie einen Suchbegriff ein, um zu starten.";
  158. const needsBranchSelection = needsMultiBranchSelectionHint({
  159. isAdminDev,
  160. urlState,
  161. });
  162. return (
  163. /*
  164. RHL-038:
  165. - The Search page felt “too wide” on desktop.
  166. - We intentionally cap the content to ~60% (centered) to improve readability.
  167. - On smaller screens we keep full width for usability.
  168. */
  169. <div className="w-full lg:w-[60%] lg:mx-auto">
  170. <ExplorerPageShell
  171. title="Suche"
  172. description={`Lieferscheine durchsuchen • Niederlassung ${routeBranch}`}
  173. actions={actions}
  174. >
  175. <ExplorerSectionCard
  176. title="Suche"
  177. description="Suchbegriff und Suchbereich auswählen."
  178. >
  179. <SearchForm
  180. branch={routeBranch}
  181. qDraft={qDraft}
  182. onQDraftChange={setQDraft}
  183. onSubmit={handleSubmit}
  184. currentQuery={urlState.q}
  185. isSubmitting={query.status === "loading"}
  186. isAdminDev={isAdminDev}
  187. scope={urlState.scope}
  188. onScopeChange={handleScopeChange}
  189. onSingleBranchChange={handleSingleBranchChange}
  190. availableBranches={
  191. branchesQuery.status === BRANCH_LIST_STATE.READY &&
  192. Array.isArray(branchesQuery.branches)
  193. ? branchesQuery.branches
  194. : []
  195. }
  196. branchesStatus={branchesQuery.status}
  197. selectedBranches={urlState.branches}
  198. onToggleBranch={handleToggleBranch}
  199. onClearAllBranches={handleClearAllBranches}
  200. limit={urlState.limit}
  201. onLimitChange={handleLimitChange}
  202. />
  203. </ExplorerSectionCard>
  204. <ExplorerSectionCard
  205. title="Ergebnisse"
  206. description={resultsDescription}
  207. headerRight={resultsHeaderRight}
  208. >
  209. <SearchResults
  210. branch={routeBranch}
  211. scope={urlState.scope}
  212. status={query.status}
  213. items={query.items}
  214. total={query.total}
  215. error={mappedError}
  216. onRetry={query.retry}
  217. nextCursor={query.nextCursor}
  218. onLoadMore={query.loadMore}
  219. isLoadingMore={query.isLoadingMore}
  220. loadMoreError={mappedLoadMoreError}
  221. needsBranchSelection={needsBranchSelection}
  222. />
  223. </ExplorerSectionCard>
  224. </ExplorerPageShell>
  225. </div>
  226. );
  227. }