"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, getPaginatedRowNumber, 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) => { const normalized: Record = {}; 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, 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) => ({ ...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 = { 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) => { 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) => { 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([ "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) => SORT_TARGET_BY_RANGE_KEY.find((target) => isDateRangeActive(formData[target.rangeKey]), ) ?? null; const useTenderListPresenter = () => { const [isModalOpen, setIsModalOpen] = useState(false); const [currentTender, setCurrentTender] = useState(null); const [modalConfig, setModalConfig] = useState< Partial >({}); const [params, setParams] = useState>({}); const router = useRouter(); const pathName = usePathname(); const searchParams = useSearchParams(); const isMutating = useIsMutating(); const tableSectionRef = useRef(null); const skeletonTbodyRef = useRef(null); const dataTbodyRef = useRef(null); const { register: filterRegister, handleSubmit: handleFilterSubmit, reset: filterFormReset, watch, setValue: setFilterValue, control, } = useForm(); useEffect(() => { const newParams: Record = { ...apiDefaultParams, offset: 0, limit: 10, }; for (const key of searchParams.keys()) { if (key === "cpv_codes") { 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 } = useHybridPagination({ meta: data?.meta, params, setParams, isError, error, }); const MAX_PAGE_JUMP = 10; 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 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) => getPaginatedRowNumber({ index, page: data?.meta?.page, limit: data?.meta?.limit, }), [data?.meta?.limit, data?.meta?.page], ); 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 = { ...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: 10, }); 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 = { 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 = { 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: handleWindowedPaginationChange, buildWindowedPageLabel, getRowNumber, formatDateTimeCell, pagination: windowedPagination, absoluteCurrentPage: pagination.currentPage, }; }; export default useTenderListPresenter;