Files
tm_panel/src/components/Tables/tenders/useTenderListPresenter.ts
T
AmirReza Jamali a762a14026
continuous-integration/drone/push Build is passing
feat(admin): optimize tender list pagination
2026-07-11 15:46:19 +03:30

615 lines
16 KiB
TypeScript

"use client";
import { ConfirmationModalProps } from "@/components/ui/ConfirmationModal";
import { SortDirection } from "@/components/ui/tableSortContext";
import { apiDefaultParams } from "@/constants/Api_Params";
import { useTendersQuery } from "@/hooks/queries";
import { useHybridPagination } from "@/hooks/useHybridPagination";
import { TCustomer } from "@/lib/api";
import {
buildQueryString,
deleteEmptyKeys,
isDateRangeActive,
unixToDate,
} from "@/utils/shared";
import gsap from "gsap";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useForm } from "react-hook-form";
const COMMA_SEPARATED_FILTER_KEYS = new Set([
"country_codes",
"notice_types",
"form_types",
"classifications",
]);
const parseCpvCodesFilter = (value: string): string[] =>
value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
const normalizeFilterValues = (values: Record<string, any>) => {
const normalized: Record<string, any> = {};
for (const [key, value] of Object.entries(values)) {
if (value === undefined || value === null) {
continue;
}
if (typeof value === "string") {
const trimmedValue = value.trim();
if (!trimmedValue) {
continue;
}
if (key === "cpv_codes") {
const codes = parseCpvCodesFilter(trimmedValue);
if (codes.length) {
normalized[key] = codes;
}
continue;
}
if (COMMA_SEPARATED_FILTER_KEYS.has(key)) {
normalized[key] = trimmedValue
.split(",")
.map((item) => item.trim())
.filter(Boolean)
.join(",");
continue;
}
normalized[key] = trimmedValue;
continue;
}
normalized[key] = value;
}
return normalized;
};
const parseUnixParam = (v: unknown): number | undefined => {
if (v === undefined || v === null || v === "") return undefined;
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? n : undefined;
};
const buildRangeFormValue = (
params: Record<string, any>,
fromKey: string,
toKey: string,
legacy?: { from: string; to: string },
): number[] | undefined => {
let from = parseUnixParam(params[fromKey]);
let to = parseUnixParam(params[toKey]);
if (legacy) {
if (from === undefined) from = parseUnixParam(params[legacy.from]);
if (to === undefined) to = parseUnixParam(params[legacy.to]);
}
if (from !== undefined && to !== undefined) return [from, to];
if (from !== undefined) return [from];
if (to !== undefined) return [to];
return undefined;
};
const formatCpvCodesForForm = (value: unknown): string => {
if (Array.isArray(value)) {
return value.map(String).filter(Boolean).join(", ");
}
return typeof value === "string" ? value : "";
};
const parseCpvCodesFromSearchParams = (
searchParams: URLSearchParams,
): string[] | undefined => {
const values = searchParams.getAll("cpv_codes").filter(Boolean);
if (!values.length) {
return undefined;
}
if (values.length === 1 && values[0].includes(",")) {
const legacy = parseCpvCodesFilter(values[0]);
return legacy.length ? legacy : undefined;
}
return values;
};
const buildTenderFilterFormValues = (params: Record<string, any>) => ({
...params,
documents_scraped:
params.documents_scraped === true || params.documents_scraped === "true",
cpv_codes: formatCpvCodesForForm(params.cpv_codes),
created_at_range: buildRangeFormValue(
params,
"created_at_from",
"created_at_to",
),
tender_deadline_range: buildRangeFormValue(
params,
"tender_deadline_from",
"tender_deadline_to",
{ from: "deadline_from", to: "deadline_to" },
),
publication_date_range: buildRangeFormValue(
params,
"publication_date_from",
"publication_date_to",
),
submission_deadline_range: buildRangeFormValue(
params,
"submission_deadline_from",
"submission_deadline_to",
{ from: "submission_date_from", to: "submission_date_to" },
),
});
/** RHF `reset` does not drop fields omitted from the payload — merge empties so URL sync / clear-all match the UI. */
const TENDER_FILTER_FORM_DEFAULTS: Record<string, any> = {
q: "",
title: "",
description: "",
country: "",
country_codes: "",
notice_type: "",
notice_types: "",
form_types: "",
main_classification: "",
classifications: "",
cpv_codes: "",
documents_scraped: false,
created_at_range: [],
tender_deadline_range: [],
publication_date_range: [],
submission_deadline_range: [],
};
const mergeTenderFilterFormState = (params: Record<string, any>) => {
const built = buildTenderFilterFormValues(params);
return {
...TENDER_FILTER_FORM_DEFAULTS,
...built,
created_at_range: built.created_at_range ?? [],
tender_deadline_range: built.tender_deadline_range ?? [],
publication_date_range: built.publication_date_range ?? [],
submission_deadline_range: built.submission_deadline_range ?? [],
};
};
const mapDateRangeFilters = (values: Record<string, any>) => {
const mappedValues = { ...values };
const mapRange = ({
key,
fromKey,
toKey,
aliases = [],
}: {
key: string;
fromKey: string;
toKey: string;
aliases?: Array<{ from: string; to: string }>;
}) => {
const rangeValue = mappedValues[key];
const isCleared =
rangeValue === "" ||
rangeValue === null ||
(Array.isArray(rangeValue) && rangeValue.length === 0);
if (isCleared) {
delete mappedValues[key];
delete mappedValues[fromKey];
delete mappedValues[toKey];
aliases.forEach((alias) => {
delete mappedValues[alias.from];
delete mappedValues[alias.to];
});
return;
}
if (!Array.isArray(rangeValue)) {
return;
}
const [from, to] = rangeValue;
if (from !== undefined && from !== null && from !== "") {
mappedValues[fromKey] = from;
aliases.forEach((alias) => {
mappedValues[alias.from] = from;
});
}
if (to !== undefined && to !== null && to !== "") {
mappedValues[toKey] = to;
aliases.forEach((alias) => {
mappedValues[alias.to] = to;
});
}
delete mappedValues[key];
};
mapRange({
key: "created_at_range",
fromKey: "created_at_from",
toKey: "created_at_to",
});
mapRange({
key: "tender_deadline_range",
fromKey: "tender_deadline_from",
toKey: "tender_deadline_to",
aliases: [{ from: "deadline_from", to: "deadline_to" }],
});
mapRange({
key: "publication_date_range",
fromKey: "publication_date_from",
toKey: "publication_date_to",
});
mapRange({
key: "submission_deadline_range",
fromKey: "submission_deadline_from",
toKey: "submission_deadline_to",
aliases: [{ from: "submission_date_from", to: "submission_date_to" }],
});
return mappedValues;
};
const TENDER_RANGE_PARAM_KEYS = [
"created_at_from",
"created_at_to",
"tender_deadline_from",
"tender_deadline_to",
"deadline_from",
"deadline_to",
"publication_date_from",
"publication_date_to",
"submission_deadline_from",
"submission_deadline_to",
"submission_date_from",
"submission_date_to",
"created_at_range",
"tender_deadline_range",
"publication_date_range",
"submission_deadline_range",
] as const;
/** Query keys owned by TenderListFilters — drop from URL when cleared (not in normalized submit). */
const TENDER_FILTER_MODAL_KEYS = new Set<string>([
"q",
"title",
"description",
"country",
"country_codes",
"notice_type",
"notice_types",
"form_types",
"main_classification",
"classifications",
"cpv_codes",
...TENDER_RANGE_PARAM_KEYS,
]);
/**
* Applying a date-range filter retargets the table sort to that same field, so the
* results lead with the dates the user just narrowed to. Ordered by precedence — the
* first active range wins when several are applied at once. `sortOrder` mirrors each
* column's default click direction (see `columnDefaultOrder`).
*/
const SORT_TARGET_BY_RANGE_KEY: Array<{
rangeKey: string;
sortBy: string;
sortOrder: SortDirection;
}> = [
{
rangeKey: "publication_date_range",
sortBy: "publication_date",
sortOrder: "desc",
},
{
rangeKey: "submission_deadline_range",
sortBy: "submission_deadline",
sortOrder: "asc",
},
{
rangeKey: "tender_deadline_range",
sortBy: "tender_deadline",
sortOrder: "asc",
},
{ rangeKey: "created_at_range", sortBy: "created_at", sortOrder: "desc" },
];
const resolveSortFromActiveDateFilters = (formData: Record<string, any>) =>
SORT_TARGET_BY_RANGE_KEY.find((target) =>
isDateRangeActive(formData[target.rangeKey]),
) ?? null;
const useTenderListPresenter = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [currentTender, setCurrentTender] = useState<TCustomer | null>(null);
const [modalConfig, setModalConfig] = useState<
Partial<ConfirmationModalProps>
>({});
const [params, setParams] = useState<Record<string, any>>({});
const router = useRouter();
const pathName = usePathname();
const searchParams = useSearchParams();
const isMutating = useIsMutating();
const tableSectionRef = useRef<HTMLDivElement>(null);
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
const {
register: filterRegister,
handleSubmit: handleFilterSubmit,
reset: filterFormReset,
watch,
setValue: setFilterValue,
control,
} = useForm();
useEffect(() => {
const newParams: Record<string, any> = {
...apiDefaultParams,
limit: 20,
};
for (const key of searchParams.keys()) {
if (key === "cpv_codes" || key === "include_total" || key === "cursor") {
continue;
}
newParams[key] = searchParams.get(key);
}
const cpvCodes = parseCpvCodesFromSearchParams(searchParams);
if (cpvCodes) {
newParams.cpv_codes = cpvCodes;
}
setParams(newParams);
filterFormReset(mergeTenderFilterFormState(newParams));
}, [searchParams, filterFormReset]);
const { data, error, isPending, isError } = useTendersQuery(params);
const {
pagination,
handlePaginationChange,
buildFirstPageParams,
hasNext,
hasPrevious,
} = useHybridPagination({
meta: data?.meta,
params,
setParams,
isError,
error,
});
const shouldShowPagination = !isPending && (hasPrevious || hasNext);
const tendersList = data?.data?.tenders ?? [];
const langQuerySuffix = useMemo(() => {
const lang = params?.lang;
return typeof lang === "string" && lang.trim()
? `?lang=${encodeURIComponent(lang)}`
: "";
}, [params?.lang]);
const navigateToTenderDetails = useCallback(
(tenderId: string) => {
router.push(`${pathName}/${tenderId}${langQuerySuffix}`);
},
[pathName, router, langQuerySuffix],
);
const navigateToTenderFeedback = useCallback(
(tenderId: string) => {
router.push(`${pathName}/feedback/${tenderId}${langQuerySuffix}`);
},
[pathName, router, langQuerySuffix],
);
const getRowNumber = useCallback(
(index: number) =>
pagination.currentPage *
(data?.meta?.limit ?? Number(params.limit) ?? 20) +
index +
1,
[data?.meta?.limit, pagination.currentPage, params.limit],
);
const formatDateTimeCell = useCallback((unix: number) => {
return unixToDate({ unix, hasTime: true });
}, []);
useLayoutEffect(() => {
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const ctx = gsap.context(() => {
if (reduced) return;
if (isPending) {
const tbody = skeletonTbodyRef.current;
if (!tbody) return;
const rows = tbody.querySelectorAll("tr");
gsap.fromTo(
rows,
{ opacity: 0.35, y: 14 },
{
opacity: 1,
y: 0,
duration: 0.42,
stagger: 0.032,
ease: "power2.out",
},
);
return;
}
const tbody = dataTbodyRef.current;
if (!tbody) return;
const rows = tbody.querySelectorAll("tr");
if (!rows.length) return;
gsap.fromTo(
rows,
{ opacity: 0, y: 18 },
{
opacity: 1,
y: 0,
duration: 0.46,
stagger: 0.05,
ease: "power3.out",
clearProps: "opacity,transform",
},
);
}, tableSectionRef);
return () => ctx.revert();
}, [isPending, data]);
const search = (data: any) => {
const mappedDateRanges = mapDateRangeFilters(data);
const normalizedData = normalizeFilterValues(mappedDateRanges);
const merged: Record<string, any> = { ...params };
for (const key of TENDER_FILTER_MODAL_KEYS) {
if (!(key in normalizedData)) {
delete merged[key];
}
}
// Retarget the sort to a freshly applied date filter, but only while the user is
// on the implicit default sort — never clobber a column they sorted on by hand.
const sortTarget = resolveSortFromActiveDateFilters(data);
const currentSortBy = params.sort_by as string | undefined;
const isDefaultSort = !currentSortBy || currentSortBy === "created_at";
const sortOverride =
sortTarget && isDefaultSort
? { sort_by: sortTarget.sortBy, sort_order: sortTarget.sortOrder }
: {};
const newParams = buildFirstPageParams({
...merged,
...normalizedData,
...sortOverride,
});
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = buildQueryString(cleanedParams);
router.push(`${pathName}?${queryString}`);
};
const clearAllFilters = useCallback(() => {
const cleared = buildFirstPageParams({
...apiDefaultParams,
limit: 20,
});
filterFormReset(mergeTenderFilterFormState(cleared));
router.push(pathName);
}, [buildFirstPageParams, pathName, router, filterFormReset]);
const columns = [
"row",
"title",
"country code",
"publication date",
"submission deadline",
"tender deadline",
"created at",
"status",
"actions",
];
/** Column label → BSON `sort_by` field (see bp-panel-list-sorting.md). */
const columnSortKeys: Record<string, string> = {
title: "title",
"country code": "country_code",
"publication date": "publication_date",
"submission deadline": "submission_deadline",
"tender deadline": "tender_deadline",
"created at": "created_at",
status: "status",
};
/** First-click direction per column, following the BP sorting UX guide. */
const columnDefaultOrder: Record<string, SortDirection> = {
title: "asc",
"submission deadline": "asc",
"tender deadline": "asc",
};
const sortBy = (params.sort_by as string) ?? "created_at";
const sortOrder = (params.sort_order as SortDirection) ?? "desc";
const handleSortChange = useCallback(
(key: string, order: SortDirection) => {
const newParams = buildFirstPageParams({
...params,
sort_by: key,
sort_order: order,
});
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = buildQueryString(cleanedParams);
router.push(`${pathName}?${queryString}`);
},
[buildFirstPageParams, params, pathName, router],
);
return {
currentTender,
columns,
columnSortKeys,
columnDefaultOrder,
sortBy,
sortOrder,
handleSortChange,
setCurrentTender,
router,
allTenders: data,
error,
isPending,
setParams,
params,
data,
pathName,
isModalOpen,
setIsModalOpen,
modalConfig,
setModalConfig,
clearAllFilters,
filterRegister,
handleFilterSubmit,
search,
watch,
setFilterValue,
control,
isMutating,
shouldShowPagination,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
tendersList,
navigateToTenderDetails,
navigateToTenderFeedback,
handlePaginationChange,
hasNext,
hasPrevious,
getRowNumber,
formatDateTimeCell,
pagination,
absoluteCurrentPage: pagination.currentPage,
};
};
export default useTenderListPresenter;