feat(admin): optimize tender list pagination
continuous-integration/drone/push Build is passing

This commit is contained in:
AmirReza Jamali
2026-07-11 15:46:19 +03:30
parent 624614f1a9
commit a762a14026
7 changed files with 136 additions and 92 deletions
+33 -12
View File
@@ -5,7 +5,6 @@ import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
import IsVisible from "@/components/ui/IsVisible"; import IsVisible from "@/components/ui/IsVisible";
import ListHeader from "@/components/ui/ListHeader"; import ListHeader from "@/components/ui/ListHeader";
import ListWrapper from "@/components/ui/ListWrapper"; import ListWrapper from "@/components/ui/ListWrapper";
import Pagination from "@/components/ui/pagination";
import Status from "@/components/ui/Status"; import Status from "@/components/ui/Status";
import { import {
Table, Table,
@@ -49,7 +48,8 @@ const TendersTable = () => {
navigateToTenderDetails, navigateToTenderDetails,
navigateToTenderFeedback, navigateToTenderFeedback,
handlePaginationChange, handlePaginationChange,
buildWindowedPageLabel, hasNext,
hasPrevious,
getRowNumber, getRowNumber,
formatDateTimeCell, formatDateTimeCell,
pagination, pagination,
@@ -204,16 +204,37 @@ const TendersTable = () => {
/> />
</div> </div>
<IsVisible condition={shouldShowPagination}> <IsVisible condition={shouldShowPagination}>
<Pagination <nav
key={`pagination-tenders-${absoluteCurrentPage}`} className="mt-2 flex w-full items-center justify-center gap-3 text-sm"
currentPage={pagination.currentPage} aria-label="Tender pagination"
totalPages={pagination.totalPages} >
onPageChange={handlePaginationChange} <button
pageRangeDisplayed={3} type="button"
marginPagesDisplayed={1} disabled={!hasPrevious}
pageLabelBuilder={buildWindowedPageLabel} onClick={() =>
variant="tenders" handlePaginationChange({ selected: absoluteCurrentPage - 1 })
/> }
className="shadow-theme-xs rounded-xl border border-stroke/80 bg-white/70 px-4 py-2 font-semibold text-dark transition hover:border-primary/35 hover:text-primary disabled:cursor-not-allowed disabled:opacity-40 dark:border-dark-3 dark:bg-dark-2/70 dark:text-white"
>
Previous
</button>
<span
className="text-gray-600 dark:text-gray-300"
aria-current="page"
>
Page {pagination.currentPage + 1}
</span>
<button
type="button"
disabled={!hasNext}
onClick={() =>
handlePaginationChange({ selected: absoluteCurrentPage + 1 })
}
className="shadow-theme-xs rounded-xl border border-stroke/80 bg-white/70 px-4 py-2 font-semibold text-dark transition hover:border-primary/35 hover:text-primary disabled:cursor-not-allowed disabled:opacity-40 dark:border-dark-3 dark:bg-dark-2/70 dark:text-white"
>
Next
</button>
</nav>
</IsVisible> </IsVisible>
</ListWrapper> </ListWrapper>
); );
@@ -8,14 +8,20 @@ import { TCustomer } from "@/lib/api";
import { import {
buildQueryString, buildQueryString,
deleteEmptyKeys, deleteEmptyKeys,
getPaginatedRowNumber,
isDateRangeActive, isDateRangeActive,
unixToDate, unixToDate,
} from "@/utils/shared"; } from "@/utils/shared";
import gsap from "gsap"; import gsap from "gsap";
import { useIsMutating } from "@tanstack/react-query"; import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
const COMMA_SEPARATED_FILTER_KEYS = new Set([ const COMMA_SEPARATED_FILTER_KEYS = new Set([
@@ -356,11 +362,10 @@ const useTenderListPresenter = () => {
useEffect(() => { useEffect(() => {
const newParams: Record<string, any> = { const newParams: Record<string, any> = {
...apiDefaultParams, ...apiDefaultParams,
offset: 0, limit: 20,
limit: 10,
}; };
for (const key of searchParams.keys()) { for (const key of searchParams.keys()) {
if (key === "cpv_codes") { if (key === "cpv_codes" || key === "include_total" || key === "cursor") {
continue; continue;
} }
newParams[key] = searchParams.get(key); newParams[key] = searchParams.get(key);
@@ -376,53 +381,21 @@ const useTenderListPresenter = () => {
const { data, error, isPending, isError } = useTendersQuery(params); const { data, error, isPending, isError } = useTendersQuery(params);
const { pagination, handlePaginationChange, buildFirstPageParams } = const {
useHybridPagination({ pagination,
meta: data?.meta, handlePaginationChange,
params, buildFirstPageParams,
setParams, hasNext,
isError, hasPrevious,
error, } = useHybridPagination({
}); meta: data?.meta,
params,
setParams,
isError,
error,
});
const MAX_PAGE_JUMP = 10; const shouldShowPagination = !isPending && (hasPrevious || hasNext);
const paginationWindow = useMemo(() => {
const windowStart = Math.max(0, pagination.currentPage - MAX_PAGE_JUMP);
const windowEnd = Math.min(
Math.max(pagination.totalPages - 1, 0),
pagination.currentPage + MAX_PAGE_JUMP,
);
return {
windowStart,
virtualCurrent: pagination.currentPage - windowStart,
virtualTotal: Math.max(windowEnd - windowStart + 1, 1),
};
}, [pagination.currentPage, pagination.totalPages]);
const handleWindowedPaginationChange = useCallback(
(e: { selected: number }) => {
handlePaginationChange({
selected: e.selected + paginationWindow.windowStart,
});
},
[handlePaginationChange, paginationWindow.windowStart],
);
const buildWindowedPageLabel = useCallback(
(virtualPage: number) => String(virtualPage + paginationWindow.windowStart),
[paginationWindow.windowStart],
);
const windowedPagination = useMemo(
() => ({
currentPage: paginationWindow.virtualCurrent,
totalPages: paginationWindow.virtualTotal,
}),
[paginationWindow.virtualCurrent, paginationWindow.virtualTotal],
);
const shouldShowPagination =
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
const tendersList = data?.data?.tenders ?? []; const tendersList = data?.data?.tenders ?? [];
@@ -449,12 +422,11 @@ const useTenderListPresenter = () => {
const getRowNumber = useCallback( const getRowNumber = useCallback(
(index: number) => (index: number) =>
getPaginatedRowNumber({ pagination.currentPage *
index, (data?.meta?.limit ?? Number(params.limit) ?? 20) +
page: data?.meta?.page, index +
limit: data?.meta?.limit, 1,
}), [data?.meta?.limit, pagination.currentPage, params.limit],
[data?.meta?.limit, data?.meta?.page],
); );
const formatDateTimeCell = useCallback((unix: number) => { const formatDateTimeCell = useCallback((unix: number) => {
@@ -541,7 +513,7 @@ const useTenderListPresenter = () => {
const clearAllFilters = useCallback(() => { const clearAllFilters = useCallback(() => {
const cleared = buildFirstPageParams({ const cleared = buildFirstPageParams({
...apiDefaultParams, ...apiDefaultParams,
limit: 10, limit: 20,
}); });
filterFormReset(mergeTenderFilterFormState(cleared)); filterFormReset(mergeTenderFilterFormState(cleared));
router.push(pathName); router.push(pathName);
@@ -629,11 +601,12 @@ const useTenderListPresenter = () => {
tendersList, tendersList,
navigateToTenderDetails, navigateToTenderDetails,
navigateToTenderFeedback, navigateToTenderFeedback,
handlePaginationChange: handleWindowedPaginationChange, handlePaginationChange,
buildWindowedPageLabel, hasNext,
hasPrevious,
getRowNumber, getRowNumber,
formatDateTimeCell, formatDateTimeCell,
pagination: windowedPagination, pagination,
absoluteCurrentPage: pagination.currentPage, absoluteCurrentPage: pagination.currentPage,
}; };
}; };
+7 -6
View File
@@ -1,9 +1,10 @@
import { API_ENDPOINTS, tendersService } from "@/lib/api"; import { API_ENDPOINTS, tendersService } from "@/lib/api";
import { TTenderListParams } from "@/lib/api/types/Tenders";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react"; import { useMemo } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
export const useTendersQuery = (params?: Record<string, any>) => { export const useTendersQuery = (params?: TTenderListParams) => {
const queryKey = useMemo( const queryKey = useMemo(
() => [API_ENDPOINTS.TENDERS.READ_ALL, "read-tenders", params], () => [API_ENDPOINTS.TENDERS.READ_ALL, "read-tenders", params],
[params], [params],
@@ -15,7 +16,10 @@ export const useTendersQuery = (params?: Record<string, any>) => {
}); });
}; };
export const useTenderDetailQuery = (id: string, params?: Record<string, any>) => { export const useTenderDetailQuery = (
id: string,
params?: Record<string, any>,
) => {
const queryKey = useMemo( const queryKey = useMemo(
() => [API_ENDPOINTS.TENDERS.DETAILS(id), params], () => [API_ENDPOINTS.TENDERS.DETAILS(id), params],
[id, params], [id, params],
@@ -52,10 +56,7 @@ export const useGetTenderDocumentsQuery = (
id: string, id: string,
options?: { enabled?: boolean }, options?: { enabled?: boolean },
) => { ) => {
const queryKey = useMemo( const queryKey = useMemo(() => [API_ENDPOINTS.TENDERS.DOCUMENTS(id)], [id]);
() => [API_ENDPOINTS.TENDERS.DOCUMENTS(id)],
[id],
);
return useQuery({ return useQuery({
queryKey, queryKey,
queryFn: () => tendersService.getDocuments(id), queryFn: () => tendersService.getDocuments(id),
+21 -4
View File
@@ -1,6 +1,5 @@
"use client"; "use client";
import { IMetaData } from "@/lib/api/types/Factory";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
@@ -12,7 +11,14 @@ const isInvalidCursorError = (error: unknown) => {
}; };
type UseHybridPaginationOptions = { type UseHybridPaginationOptions = {
meta?: IMetaData; meta?: {
limit: number;
offset: number;
page?: number;
pages?: number;
has_more?: boolean;
next_cursor?: string;
};
params: Record<string, unknown>; params: Record<string, unknown>;
setParams: React.Dispatch<React.SetStateAction<Record<string, any>>>; setParams: React.Dispatch<React.SetStateAction<Record<string, any>>>;
isError?: boolean; isError?: boolean;
@@ -91,7 +97,14 @@ export const useHybridPagination = ({
if (meta?.page != null) return meta.page - 1; if (meta?.page != null) return meta.page - 1;
return 0; return 0;
}, [cursorStack, params.cursor, params.offset, meta?.offset, meta?.page, limit]); }, [
cursorStack,
params.cursor,
params.offset,
meta?.offset,
meta?.page,
limit,
]);
const hasPrevious = cursorStack.length > 0 || currentPageIndex > 0; const hasPrevious = cursorStack.length > 0 || currentPageIndex > 0;
@@ -129,7 +142,11 @@ export const useHybridPagination = ({
{ cursor: nextCursor, fromPage: fromPage1Based }, { cursor: nextCursor, fromPage: fromPage1Based },
]); ]);
setParams((current) => { setParams((current) => {
const next: Record<string, any> = { ...current, cursor: nextCursor, limit }; const next: Record<string, any> = {
...current,
cursor: nextCursor,
limit,
};
delete next.offset; delete next.offset;
return next; return next;
}); });
+13 -5
View File
@@ -5,14 +5,16 @@ import { ApiResponse } from "../types";
import { import {
TTenderDetails, TTenderDetails,
TTenderDocumentsResponse, TTenderDocumentsResponse,
TTenderListParams,
TTenderListMeta,
TTenderResponse, TTenderResponse,
TTenderTranslationResponse, TTenderTranslationResponse,
} from "../types/Tenders"; } from "../types/Tenders";
export const tendersService = { export const tendersService = {
tendersList: async ( tendersList: async (
params?: Record<string, any>, params?: TTenderListParams,
): Promise<ApiResponse<TTenderResponse>> => { ): Promise<ApiResponse<TTenderResponse, TTenderListMeta>> => {
try { try {
return ( return (
await api.get(API_ENDPOINTS.TENDERS.READ_ALL, { await api.get(API_ENDPOINTS.TENDERS.READ_ALL, {
@@ -30,7 +32,8 @@ export const tendersService = {
params?: Record<string, any>, params?: Record<string, any>,
): Promise<ApiResponse<TTenderDetails>> => { ): Promise<ApiResponse<TTenderDetails>> => {
try { try {
return (await api.get(API_ENDPOINTS.TENDERS.DETAILS(id), { params })).data; return (await api.get(API_ENDPOINTS.TENDERS.DETAILS(id), { params }))
.data;
} catch (error) { } catch (error) {
console.error( console.error(
"ERROR caught in Tender Service => tender details: ", "ERROR caught in Tender Service => tender details: ",
@@ -53,12 +56,17 @@ export const tendersService = {
}) })
).data; ).data;
} catch (error) { } catch (error) {
console.error("ERROR caught in Tender Service => translate tender: ", error); console.error(
"ERROR caught in Tender Service => translate tender: ",
error,
);
throw error; throw error;
} }
}, },
getDocuments: async (id: string): Promise<ApiResponse<TTenderDocumentsResponse[]>> => { getDocuments: async (
id: string,
): Promise<ApiResponse<TTenderDocumentsResponse[]>> => {
try { try {
return (await api.get(API_ENDPOINTS.TENDERS.DOCUMENTS(id))).data; return (await api.get(API_ENDPOINTS.TENDERS.DOCUMENTS(id))).data;
} catch (error) { } catch (error) {
+25 -1
View File
@@ -123,9 +123,33 @@ export const TendersResponse = z.object({
tenders: z.array(TenderSchema).or(z.null()), tenders: z.array(TenderSchema).or(z.null()),
}); });
export type TTenderDocumentsResponse = z.infer<typeof TenderDocumentsResponseSchema>; export type TTenderDocumentsResponse = z.infer<
typeof TenderDocumentsResponseSchema
>;
export type TTenderResponse = z.infer<typeof TendersResponse>; export type TTenderResponse = z.infer<typeof TendersResponse>;
export type TTenderDetails = z.infer<typeof TenderSchema>; export type TTenderDetails = z.infer<typeof TenderSchema>;
export interface TTenderListParams {
limit?: number;
offset?: number;
cursor?: string;
include_total?: boolean;
sort_by?: string;
sort_order?: "asc" | "desc";
q?: string;
status?: string[];
country?: string;
documents_scraped?: boolean;
[key: string]: unknown;
}
export interface TTenderListMeta {
limit: number;
offset: number;
has_more: boolean;
next_cursor?: string;
total?: number;
page?: number;
pages?: number;
}
export type TTenderTranslationResponse = z.infer< export type TTenderTranslationResponse = z.infer<
typeof TenderTranslationResponseSchema typeof TenderTranslationResponseSchema
>; >;
+2 -2
View File
@@ -1,9 +1,9 @@
import { IMetaData } from "./Factory"; import { IMetaData } from "./Factory";
export interface ApiResponse<T> { export interface ApiResponse<T, TMeta = IMetaData> {
data: T; data: T;
message: string; message: string;
code?: number; code?: number;
meta?: IMetaData; meta?: TMeta;
success: boolean; success: boolean;
} }