"use client"; import type { PoResult } from "@/app/api/po"; import type { RecordsRes } from "@/app/api/utils"; import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; import { mapPoResultToListRow } from "@/components/PoWorkbench/search/poWorkbenchMapPoResult"; import { buildWorkbenchPoListSearchParams, PO_WORKBENCH_LIST_PAGE_SIZE, } from "@/components/PoWorkbench/search/poWorkbenchPoListQuery"; import type { PoWorkbenchAdvancedFilters, PoWorkbenchListRow, } from "@/components/PoWorkbench/types"; import { useCallback, useEffect, useRef, useState } from "react"; const PO_LIST_PATH = "/po/list"; /** Bumps after each keystroke delay; triggers a new `/po/list` first page. */ function useDebouncedValue(value: string, delayMs: number): string { const [debounced, setDebounced] = useState(value); useEffect(() => { const id = window.setTimeout(() => setDebounced(value), delayMs); return () => window.clearTimeout(id); }, [value, delayMs]); return debounced; } async function fetchPoListPage( poNumber: string, advanced: PoWorkbenchAdvancedFilters, pageNum: number, pageSize: number, signal?: AbortSignal, ): Promise<{ rows: PoWorkbenchListRow[]; total: number }> { const params = buildWorkbenchPoListSearchParams( poNumber, advanced, pageNum, pageSize, ); const url = `${NEXT_PUBLIC_API_URL}${PO_LIST_PATH}?${params.toString()}`; const response = await clientAuthFetch(url, { method: "GET", signal }); if (!response.ok) { throw new Error(`PO list failed: HTTP ${response.status}`); } const body = (await response.json()) as RecordsRes; const records = Array.isArray(body.records) ? body.records : []; const total = typeof body.total === "number" ? body.total : records.length; return { rows: records.map(mapPoResultToListRow), total, }; } export interface UsePoWorkbenchListSearchArgs { poNumberQuery: string; advancedFilters: PoWorkbenchAdvancedFilters; /** Delay after typing in the PO field before hitting the API (ms). */ poNumberDebounceMs?: number; pageSize?: number; } export interface UsePoWorkbenchListSearchResult { listRows: readonly PoWorkbenchListRow[]; /** Total rows matching filters (from API), not how many are loaded yet. */ totalMatches: number; isLoading: boolean; isLoadingMore: boolean; loadError: string | null; hasMore: boolean; /** Loads the next page; no-op if nothing more to load or a request is in flight. */ loadMore: () => void; } /** * Fetches `/po/list` for the workbench: first page when debounced filters change, then `loadMore` appends pages. * Stale HTTP responses are ignored via `searchGenerationRef`. Does not use legacy `PoSearch`. */ export function usePoWorkbenchListSearch({ poNumberQuery, advancedFilters, poNumberDebounceMs = 350, pageSize = PO_WORKBENCH_LIST_PAGE_SIZE, }: UsePoWorkbenchListSearchArgs): UsePoWorkbenchListSearchResult { const debouncedPoNumber = useDebouncedValue( poNumberQuery, poNumberDebounceMs, ); const [listRows, setListRows] = useState([]); const [totalMatches, setTotalMatches] = useState(0); const [isLoading, setIsLoading] = useState(true); const [isLoadingMore, setIsLoadingMore] = useState(false); const [loadError, setLoadError] = useState(null); /** Highest 1-based page number successfully merged into listRows. */ const loadedPageRef = useRef(0); const loadMoreInFlightRef = useRef(false); /** Incremented on each new search so stale responses never mutate state. */ const searchGenerationRef = useRef(0); const abortRef = useRef(null); useEffect(() => { abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; const generation = ++searchGenerationRef.current; loadedPageRef.current = 0; loadMoreInFlightRef.current = false; setIsLoading(true); setLoadError(null); setListRows([]); setTotalMatches(0); (async () => { try { const { rows, total } = await fetchPoListPage( debouncedPoNumber, advancedFilters, 1, pageSize, controller.signal, ); if (generation !== searchGenerationRef.current) { return; } loadedPageRef.current = 1; setListRows(rows); setTotalMatches(total); } catch (e) { if ( controller.signal.aborted || generation !== searchGenerationRef.current ) { return; } const message = e instanceof Error ? e.message : "Unknown error"; setLoadError(message); setListRows([]); setTotalMatches(0); } finally { if (generation === searchGenerationRef.current) { setIsLoading(false); } } })(); return () => { controller.abort(); }; }, [advancedFilters, debouncedPoNumber, pageSize]); const hasMore = listRows.length < totalMatches; const loadMore = useCallback(async () => { if (!hasMore || isLoading || loadMoreInFlightRef.current) { return; } const generation = searchGenerationRef.current; const nextPage = loadedPageRef.current + 1; loadMoreInFlightRef.current = true; setIsLoadingMore(true); setLoadError(null); try { const { rows } = await fetchPoListPage( debouncedPoNumber, advancedFilters, nextPage, pageSize, ); if (generation !== searchGenerationRef.current) { return; } loadedPageRef.current = nextPage; setListRows((prev) => { const seen = new Set(prev.map((r) => r.id)); const merged = [...prev]; for (const row of rows) { if (!seen.has(row.id)) { seen.add(row.id); merged.push(row); } } return merged; }); } catch (e) { if (generation === searchGenerationRef.current) { const message = e instanceof Error ? e.message : "Unknown error"; setLoadError(message); } } finally { loadMoreInFlightRef.current = false; if (generation === searchGenerationRef.current) { setIsLoadingMore(false); } } }, [advancedFilters, debouncedPoNumber, hasMore, isLoading, pageSize]); return { listRows, totalMatches, isLoading, isLoadingMore, loadError, hasMore, loadMore, }; }