615 lines
16 KiB
TypeScript
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;
|