SearchPage.jsx 8.2 KB

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