FPSMS-frontend
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 

213 строки
6.4 KiB

  1. "use client";
  2. import type { PoResult } from "@/app/api/po";
  3. import type { RecordsRes } from "@/app/api/utils";
  4. import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
  5. import { NEXT_PUBLIC_API_URL } from "@/config/api";
  6. import { mapPoResultToListRow } from "@/components/PoWorkbench/search/poWorkbenchMapPoResult";
  7. import {
  8. buildWorkbenchPoListSearchParams,
  9. PO_WORKBENCH_LIST_PAGE_SIZE,
  10. } from "@/components/PoWorkbench/search/poWorkbenchPoListQuery";
  11. import type {
  12. PoWorkbenchAdvancedFilters,
  13. PoWorkbenchListRow,
  14. } from "@/components/PoWorkbench/types";
  15. import { useCallback, useEffect, useRef, useState } from "react";
  16. const PO_LIST_PATH = "/po/list";
  17. /** Bumps after each keystroke delay; triggers a new `/po/list` first page. */
  18. function useDebouncedValue(value: string, delayMs: number): string {
  19. const [debounced, setDebounced] = useState(value);
  20. useEffect(() => {
  21. const id = window.setTimeout(() => setDebounced(value), delayMs);
  22. return () => window.clearTimeout(id);
  23. }, [value, delayMs]);
  24. return debounced;
  25. }
  26. async function fetchPoListPage(
  27. poNumber: string,
  28. advanced: PoWorkbenchAdvancedFilters,
  29. pageNum: number,
  30. pageSize: number,
  31. signal?: AbortSignal,
  32. ): Promise<{ rows: PoWorkbenchListRow[]; total: number }> {
  33. const params = buildWorkbenchPoListSearchParams(
  34. poNumber,
  35. advanced,
  36. pageNum,
  37. pageSize,
  38. );
  39. const url = `${NEXT_PUBLIC_API_URL}${PO_LIST_PATH}?${params.toString()}`;
  40. const response = await clientAuthFetch(url, { method: "GET", signal });
  41. if (!response.ok) {
  42. throw new Error(`PO list failed: HTTP ${response.status}`);
  43. }
  44. const body = (await response.json()) as RecordsRes<PoResult[]>;
  45. const records = Array.isArray(body.records) ? body.records : [];
  46. const total = typeof body.total === "number" ? body.total : records.length;
  47. return {
  48. rows: records.map(mapPoResultToListRow),
  49. total,
  50. };
  51. }
  52. export interface UsePoWorkbenchListSearchArgs {
  53. poNumberQuery: string;
  54. advancedFilters: PoWorkbenchAdvancedFilters;
  55. /** Delay after typing in the PO field before hitting the API (ms). */
  56. poNumberDebounceMs?: number;
  57. pageSize?: number;
  58. }
  59. export interface UsePoWorkbenchListSearchResult {
  60. listRows: readonly PoWorkbenchListRow[];
  61. /** Total rows matching filters (from API), not how many are loaded yet. */
  62. totalMatches: number;
  63. isLoading: boolean;
  64. isLoadingMore: boolean;
  65. loadError: string | null;
  66. hasMore: boolean;
  67. /** Loads the next page; no-op if nothing more to load or a request is in flight. */
  68. loadMore: () => void;
  69. }
  70. /**
  71. * Fetches `/po/list` for the workbench: first page when debounced filters change, then `loadMore` appends pages.
  72. * Stale HTTP responses are ignored via `searchGenerationRef`. Does not use legacy `PoSearch`.
  73. */
  74. export function usePoWorkbenchListSearch({
  75. poNumberQuery,
  76. advancedFilters,
  77. poNumberDebounceMs = 350,
  78. pageSize = PO_WORKBENCH_LIST_PAGE_SIZE,
  79. }: UsePoWorkbenchListSearchArgs): UsePoWorkbenchListSearchResult {
  80. const debouncedPoNumber = useDebouncedValue(
  81. poNumberQuery,
  82. poNumberDebounceMs,
  83. );
  84. const [listRows, setListRows] = useState<PoWorkbenchListRow[]>([]);
  85. const [totalMatches, setTotalMatches] = useState(0);
  86. const [isLoading, setIsLoading] = useState(true);
  87. const [isLoadingMore, setIsLoadingMore] = useState(false);
  88. const [loadError, setLoadError] = useState<string | null>(null);
  89. /** Highest 1-based page number successfully merged into listRows. */
  90. const loadedPageRef = useRef(0);
  91. const loadMoreInFlightRef = useRef(false);
  92. /** Incremented on each new search so stale responses never mutate state. */
  93. const searchGenerationRef = useRef(0);
  94. const abortRef = useRef<AbortController | null>(null);
  95. useEffect(() => {
  96. abortRef.current?.abort();
  97. const controller = new AbortController();
  98. abortRef.current = controller;
  99. const generation = ++searchGenerationRef.current;
  100. loadedPageRef.current = 0;
  101. loadMoreInFlightRef.current = false;
  102. setIsLoading(true);
  103. setLoadError(null);
  104. setListRows([]);
  105. setTotalMatches(0);
  106. (async () => {
  107. try {
  108. const { rows, total } = await fetchPoListPage(
  109. debouncedPoNumber,
  110. advancedFilters,
  111. 1,
  112. pageSize,
  113. controller.signal,
  114. );
  115. if (generation !== searchGenerationRef.current) {
  116. return;
  117. }
  118. loadedPageRef.current = 1;
  119. setListRows(rows);
  120. setTotalMatches(total);
  121. } catch (e) {
  122. if (
  123. controller.signal.aborted ||
  124. generation !== searchGenerationRef.current
  125. ) {
  126. return;
  127. }
  128. const message = e instanceof Error ? e.message : "Unknown error";
  129. setLoadError(message);
  130. setListRows([]);
  131. setTotalMatches(0);
  132. } finally {
  133. if (generation === searchGenerationRef.current) {
  134. setIsLoading(false);
  135. }
  136. }
  137. })();
  138. return () => {
  139. controller.abort();
  140. };
  141. }, [advancedFilters, debouncedPoNumber, pageSize]);
  142. const hasMore = listRows.length < totalMatches;
  143. const loadMore = useCallback(async () => {
  144. if (!hasMore || isLoading || loadMoreInFlightRef.current) {
  145. return;
  146. }
  147. const generation = searchGenerationRef.current;
  148. const nextPage = loadedPageRef.current + 1;
  149. loadMoreInFlightRef.current = true;
  150. setIsLoadingMore(true);
  151. setLoadError(null);
  152. try {
  153. const { rows } = await fetchPoListPage(
  154. debouncedPoNumber,
  155. advancedFilters,
  156. nextPage,
  157. pageSize,
  158. );
  159. if (generation !== searchGenerationRef.current) {
  160. return;
  161. }
  162. loadedPageRef.current = nextPage;
  163. setListRows((prev) => {
  164. const seen = new Set(prev.map((r) => r.id));
  165. const merged = [...prev];
  166. for (const row of rows) {
  167. if (!seen.has(row.id)) {
  168. seen.add(row.id);
  169. merged.push(row);
  170. }
  171. }
  172. return merged;
  173. });
  174. } catch (e) {
  175. if (generation === searchGenerationRef.current) {
  176. const message = e instanceof Error ? e.message : "Unknown error";
  177. setLoadError(message);
  178. }
  179. } finally {
  180. loadMoreInFlightRef.current = false;
  181. if (generation === searchGenerationRef.current) {
  182. setIsLoadingMore(false);
  183. }
  184. }
  185. }, [advancedFilters, debouncedPoNumber, hasMore, isLoading, pageSize]);
  186. return {
  187. listRows,
  188. totalMatches,
  189. isLoading,
  190. isLoadingMore,
  191. loadError,
  192. hasMore,
  193. loadMore,
  194. };
  195. }