SearchPage.jsx 7.9 KB

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