'use client'; import { useState, useCallback, useMemo } from 'react'; import { useQuery, useQueryClient, type QueryKey } from '@tanstack/react-query'; import { useSearchParams, useRouter, usePathname } from 'next/navigation'; import { apiFetch } from '@/lib/api/client'; import type { PaginatedResponse } from '@/types/api'; import { serializeFiltersToParams, deserializeFiltersFromParams, type FilterDefinition, type FilterValues, } from '@/components/shared/filter-bar'; interface UsePaginatedQueryOptions { queryKey: QueryKey; endpoint: string; initialPage?: number; initialPageSize?: number; filterDefinitions?: FilterDefinition[]; } export function usePaginatedQuery({ queryKey, endpoint, initialPage = 1, initialPageSize = 25, filterDefinitions = [], }: UsePaginatedQueryOptions) { const searchParams = useSearchParams(); const router = useRouter(); const pathname = usePathname(); const queryClient = useQueryClient(); // Read initial state from URL const pageFromUrl = Number(searchParams.get('page')) || initialPage; const pageSizeFromUrl = Number(searchParams.get('limit')) || initialPageSize; const sortFieldFromUrl = searchParams.get('sort') ?? undefined; const sortOrderFromUrl = (searchParams.get('order') as 'asc' | 'desc') ?? 'desc'; const [page, setPageState] = useState(pageFromUrl); const [pageSize, setPageSizeState] = useState(pageSizeFromUrl); const [sort, setSortState] = useState<{ field: string; direction: 'asc' | 'desc' } | undefined>( sortFieldFromUrl ? { field: sortFieldFromUrl, direction: sortOrderFromUrl } : undefined, ); const [filters, setFiltersState] = useState(() => deserializeFiltersFromParams(searchParams, filterDefinitions), ); // Sync state to URL const syncUrl = useCallback( (p: number, ps: number, s?: typeof sort, f?: FilterValues) => { const params = new URLSearchParams(); if (p !== 1) params.set('page', String(p)); if (ps !== initialPageSize) params.set('limit', String(ps)); if (s) { params.set('sort', s.field); params.set('order', s.direction); } if (f) { const filterParams = serializeFiltersToParams(f); filterParams.forEach((value, key) => params.set(key, value)); } // Keep existing tab param const tab = searchParams.get('tab'); if (tab) params.set('tab', tab); const qs = params.toString(); // eslint-disable-next-line @typescript-eslint/no-explicit-any router.replace(`${pathname}${qs ? `?${qs}` : ''}` as any, { scroll: false }); }, [pathname, router, searchParams, initialPageSize], ); function setPage(p: number) { setPageState(p); syncUrl(p, pageSize, sort, filters); } function setPageSize(ps: number) { setPageSizeState(ps); setPageState(1); syncUrl(1, ps, sort, filters); } function setSort(field: string, direction: 'asc' | 'desc') { const newSort = { field, direction }; setSortState(newSort); setPageState(1); syncUrl(1, pageSize, newSort, filters); } function setFilter(key: string, value: unknown) { const newFilters = { ...filters }; if (value === undefined || value === null) { delete newFilters[key]; } else { newFilters[key] = value; } setFiltersState(newFilters); setPageState(1); syncUrl(1, pageSize, sort, newFilters); } function clearFilters() { setFiltersState({}); setPageState(1); syncUrl(1, pageSize, sort, {}); } // Build query string for API const apiParams = useMemo(() => { const params = new URLSearchParams(); params.set('page', String(page)); params.set('limit', String(pageSize)); if (sort) { params.set('sort', sort.field); params.set('order', sort.direction); } const filterParams = serializeFiltersToParams(filters); filterParams.forEach((value, key) => params.set(key, value)); return params.toString(); }, [page, pageSize, sort, filters]); const fullQueryKey = [...queryKey, apiParams]; // Endpoints that already carry a query string (e.g. `/api/v1/documents?tab=eoi_queue`) // need our pagination params merged with `&`, not a second `?`. Without this guard // the URL becomes `…?tab=foo?page=1` and the API rejects it as 400. const separator = endpoint.includes('?') ? '&' : '?'; const { data, isLoading, isFetching } = useQuery>({ queryKey: fullQueryKey, queryFn: () => apiFetch>(`${endpoint}${separator}${apiParams}`), }); const pagination = data?.pagination ? { page: data.pagination.page, pageSize: data.pagination.pageSize, total: data.pagination.total, totalPages: data.pagination.totalPages, } : { page, pageSize, total: 0, totalPages: 0 }; /** * Optimistically removes an item from the cached list (e.g., after archive). */ function optimisticRemove(id: string) { queryClient.setQueryData>(fullQueryKey, (old) => { if (!old) return old; return { ...old, data: old.data.filter((item) => (item as Record).id !== id), pagination: { ...old.pagination, total: old.pagination.total - 1, }, }; }); } return { data: data?.data ?? [], pagination, isLoading, isFetching, sort, setSort, page, setPage, pageSize, setPageSize, filters, setFilter, clearFilters, optimisticRemove, }; }