SearchPage.jsx 7.6 KB

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