feat(tender-list): add GSAP for animations and enhance table functionality
- Integrated GSAP for improved animations in the tender list components, enhancing user experience during data loading and transitions. - Refactored TendersTable and TenderListFilters to utilize new animation features, providing smoother interactions. - Updated TableBody and TableSkeleton components to support ref forwarding, improving flexibility in rendering and animations. - Enhanced useTenderListPresenter to manage loading states and pagination more effectively, ensuring better data handling and user feedback.
This commit is contained in:
@@ -25,6 +25,7 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"firebase": "^12.3.0",
|
"firebase": "^12.3.0",
|
||||||
"flatpickr": "^4.6.13",
|
"flatpickr": "^4.6.13",
|
||||||
|
"gsap": "^3.15.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jsvectormap": "^1.6.0",
|
"jsvectormap": "^1.6.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
|
|||||||
Generated
+8
@@ -41,6 +41,9 @@ importers:
|
|||||||
flatpickr:
|
flatpickr:
|
||||||
specifier: ^4.6.13
|
specifier: ^4.6.13
|
||||||
version: 4.6.13
|
version: 4.6.13
|
||||||
|
gsap:
|
||||||
|
specifier: ^3.15.0
|
||||||
|
version: 3.15.0
|
||||||
js-cookie:
|
js-cookie:
|
||||||
specifier: ^3.0.5
|
specifier: ^3.0.5
|
||||||
version: 3.0.5
|
version: 3.0.5
|
||||||
@@ -1751,6 +1754,9 @@ packages:
|
|||||||
graceful-fs@4.2.11:
|
graceful-fs@4.2.11:
|
||||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||||
|
|
||||||
|
gsap@3.15.0:
|
||||||
|
resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
|
||||||
|
|
||||||
has-bigints@1.1.0:
|
has-bigints@1.1.0:
|
||||||
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
|
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4937,6 +4943,8 @@ snapshots:
|
|||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
|
gsap@3.15.0: {}
|
||||||
|
|
||||||
has-bigints@1.1.0: {}
|
has-bigints@1.1.0: {}
|
||||||
|
|
||||||
has-flag@4.0.0: {}
|
has-flag@4.0.0: {}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { GlobeIcon } from "@/assets/icons";
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronUpIcon, GlobeIcon, SearchIcon } from "@/assets/icons";
|
||||||
import DatePicker from "@/components/FormElements/DatePicker/DatePicker";
|
import DatePicker from "@/components/FormElements/DatePicker/DatePicker";
|
||||||
import InputGroup from "@/components/FormElements/InputGroup";
|
import InputGroup from "@/components/FormElements/InputGroup";
|
||||||
import { Select } from "@/components/FormElements/select";
|
import { Select } from "@/components/FormElements/select";
|
||||||
import { Button } from "@/components/ui-elements/button";
|
import { Button } from "@/components/ui-elements/button";
|
||||||
import { Countries } from "@/constants/countries";
|
import { Countries } from "@/constants/countries";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
Control,
|
Control,
|
||||||
FieldValues,
|
FieldValues,
|
||||||
@@ -12,164 +15,493 @@ import {
|
|||||||
UseFormSetValue,
|
UseFormSetValue,
|
||||||
UseFormWatch,
|
UseFormWatch,
|
||||||
} from "react-hook-form";
|
} from "react-hook-form";
|
||||||
|
import gsap from "gsap";
|
||||||
|
import { type ReactNode, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
interface TenderListFiltersProps {
|
interface TenderListFiltersProps {
|
||||||
filterRegister: UseFormRegister<FieldValues>;
|
filterRegister: UseFormRegister<FieldValues>;
|
||||||
control: Control<FieldValues>;
|
control: Control<FieldValues>;
|
||||||
setIsFilterModalOpen: (isOpen: boolean) => void;
|
|
||||||
isMutating: number;
|
isMutating: number;
|
||||||
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
|
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
|
||||||
search: (data: any) => void;
|
search: (data: any) => void;
|
||||||
watch: UseFormWatch<FieldValues>;
|
watch: UseFormWatch<FieldValues>;
|
||||||
setValue: UseFormSetValue<any>;
|
setValue: UseFormSetValue<any>;
|
||||||
|
onClearAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANGE_KEYS = [
|
||||||
|
"created_at_range",
|
||||||
|
"tender_deadline_range",
|
||||||
|
"publication_date_range",
|
||||||
|
"submission_deadline_range",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function isRangeActive(value: unknown): boolean {
|
||||||
|
if (!Array.isArray(value) || value.length === 0) return false;
|
||||||
|
return value.some((x) => {
|
||||||
|
if (x === undefined || x === null || x === "") return false;
|
||||||
|
const n = typeof x === "number" ? x : Number(x);
|
||||||
|
return Number.isFinite(n) && n > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function countActiveFilters(values: Record<string, unknown>): number {
|
||||||
|
let n = 0;
|
||||||
|
const textKeys = [
|
||||||
|
"q",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"country_codes",
|
||||||
|
"notice_type",
|
||||||
|
"notice_types",
|
||||||
|
"form_types",
|
||||||
|
"main_classification",
|
||||||
|
"classifications",
|
||||||
|
"cpv_codes",
|
||||||
|
];
|
||||||
|
for (const key of textKeys) {
|
||||||
|
const v = values[key];
|
||||||
|
if (typeof v === "string" && v.trim()) n++;
|
||||||
|
}
|
||||||
|
const country = values.country;
|
||||||
|
if (typeof country === "string" && country.trim()) n++;
|
||||||
|
for (const key of RANGE_KEYS) {
|
||||||
|
if (isRangeActive(values[key])) n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleFilterCard({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
badge,
|
||||||
|
defaultOpen,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
badge?: string;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(Boolean(defaultOpen));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden rounded-2xl border border-stroke/50 bg-white/50 backdrop-blur-md dark:border-white/[0.08] dark:bg-dark-2/45",
|
||||||
|
open && "ring-1 ring-primary/15 dark:ring-primary/25",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="flex w-full items-center justify-between gap-3 px-4 py-3.5 text-start transition hover:bg-white/60 dark:hover:bg-white/[0.04]"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-dark dark:text-white">{title}</span>
|
||||||
|
{badge ? (
|
||||||
|
<span className="rounded-full bg-gradient-to-r from-primary/90 to-violet-500/85 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{subtitle ? (
|
||||||
|
<p className="mt-0.5 text-xs text-dark-5 dark:text-dark-6">{subtitle}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<ChevronUpIcon
|
||||||
|
className={cn(
|
||||||
|
"h-5 w-5 shrink-0 text-primary transition-transform duration-300 dark:text-primary/90",
|
||||||
|
!open && "rotate-180",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid transition-[grid-template-rows] duration-300 ease-out",
|
||||||
|
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-h-0 overflow-hidden">
|
||||||
|
<div className="border-t border-stroke/40 px-4 pb-5 pt-1 dark:border-white/[0.06]">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const TenderListFilters = ({
|
const TenderListFilters = ({
|
||||||
filterRegister,
|
filterRegister,
|
||||||
control,
|
control,
|
||||||
setIsFilterModalOpen,
|
|
||||||
isMutating,
|
isMutating,
|
||||||
handleFilterSubmit,
|
handleFilterSubmit,
|
||||||
search,
|
search,
|
||||||
watch,
|
watch,
|
||||||
setValue,
|
setValue,
|
||||||
|
onClearAll,
|
||||||
}: TenderListFiltersProps) => {
|
}: TenderListFiltersProps) => {
|
||||||
|
const watched = watch();
|
||||||
|
const activeCount = useMemo(() => countActiveFilters(watched as Record<string, unknown>), [watched]);
|
||||||
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
|
/** When true, header stays flush (square bottom) with the panel — keep true until collapse GSAP finishes so radius does not pop early. */
|
||||||
|
const [headerFlushBottom, setHeaderFlushBottom] = useState(false);
|
||||||
|
|
||||||
|
const panelOuterRef = useRef<HTMLDivElement>(null);
|
||||||
|
const panelInnerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const chevronRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const prevPanelOpen = useRef(panelOpen);
|
||||||
|
|
||||||
|
/** Initial dimensions without animating (avoids flash + Strict Mode double-invoke issues). */
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const outer = panelOuterRef.current;
|
||||||
|
const inner = panelInnerRef.current;
|
||||||
|
const chevron = chevronRef.current;
|
||||||
|
if (!outer || !inner) return;
|
||||||
|
|
||||||
|
gsap.set(outer, { height: panelOpen ? "auto" : 0 });
|
||||||
|
gsap.set(inner, {
|
||||||
|
opacity: panelOpen ? 1 : 0,
|
||||||
|
y: panelOpen ? 0 : -14,
|
||||||
|
scale: panelOpen ? 1 : 0.995,
|
||||||
|
});
|
||||||
|
if (chevron) gsap.set(chevron, { rotation: panelOpen ? 0 : 180, transformOrigin: "50% 50%" });
|
||||||
|
if (!panelOpen) {
|
||||||
|
setHeaderFlushBottom(false);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- run once on mount for initial `panelOpen`
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const outer = panelOuterRef.current;
|
||||||
|
const inner = panelInnerRef.current;
|
||||||
|
const chevron = chevronRef.current;
|
||||||
|
if (!outer || !inner) return;
|
||||||
|
|
||||||
|
if (prevPanelOpen.current === panelOpen) return;
|
||||||
|
prevPanelOpen.current = panelOpen;
|
||||||
|
|
||||||
|
const reduced =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
|
gsap.killTweensOf([outer, inner, chevron].filter(Boolean));
|
||||||
|
|
||||||
|
if (reduced) {
|
||||||
|
gsap.set(outer, { height: panelOpen ? "auto" : 0 });
|
||||||
|
gsap.set(inner, { opacity: panelOpen ? 1 : 0, y: panelOpen ? 0 : -10, scale: 1 });
|
||||||
|
if (chevron) gsap.set(chevron, { rotation: panelOpen ? 0 : 180 });
|
||||||
|
setHeaderFlushBottom(panelOpen);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
if (panelOpen) {
|
||||||
|
setHeaderFlushBottom(true);
|
||||||
|
const targetH = inner.scrollHeight;
|
||||||
|
gsap.set(outer, { height: 0 });
|
||||||
|
gsap.set(inner, { opacity: 0, y: -36, scale: 0.985 });
|
||||||
|
|
||||||
|
const tl = gsap.timeline({ defaults: { ease: "none" } });
|
||||||
|
tl.to(outer, {
|
||||||
|
height: targetH,
|
||||||
|
duration: 0.62,
|
||||||
|
ease: "power4.out",
|
||||||
|
onComplete: () => {
|
||||||
|
gsap.set(outer, { height: "auto" });
|
||||||
|
},
|
||||||
|
}).to(
|
||||||
|
inner,
|
||||||
|
{
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
duration: 0.52,
|
||||||
|
ease: "power3.out",
|
||||||
|
},
|
||||||
|
"-=0.5",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (chevron) {
|
||||||
|
tl.to(
|
||||||
|
chevron,
|
||||||
|
{ rotation: 0, duration: 0.48, ease: "back.out(1.35)" },
|
||||||
|
"-=0.52",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const currentH = Math.max(outer.offsetHeight, inner.scrollHeight);
|
||||||
|
gsap.set(outer, { height: currentH });
|
||||||
|
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
onComplete: () => {
|
||||||
|
setHeaderFlushBottom(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
tl.to(inner, {
|
||||||
|
opacity: 0,
|
||||||
|
y: -22,
|
||||||
|
scale: 0.992,
|
||||||
|
duration: 0.3,
|
||||||
|
ease: "power2.in",
|
||||||
|
})
|
||||||
|
.to(
|
||||||
|
outer,
|
||||||
|
{
|
||||||
|
height: 0,
|
||||||
|
duration: 0.48,
|
||||||
|
ease: "power3.inOut",
|
||||||
|
},
|
||||||
|
"-=0.12",
|
||||||
|
);
|
||||||
|
if (chevron) {
|
||||||
|
tl.to(chevron, { rotation: 180, duration: 0.42, ease: "power2.inOut" }, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, outer);
|
||||||
|
|
||||||
|
return () => ctx.revert();
|
||||||
|
}, [panelOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="relative mb-8">
|
||||||
|
<div className="relative overflow-hidden rounded-[1.75rem] border border-stroke/60 bg-white dark:border-dark-3 dark:bg-dark-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPanelOpen((o) => !o)}
|
||||||
|
aria-expanded={panelOpen}
|
||||||
|
aria-label={panelOpen ? "Collapse tender filters" : "Expand tender filters"}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-wrap items-start gap-x-3 gap-y-3 px-4 py-4 text-start transition-colors sm:items-center sm:justify-between sm:gap-4 sm:px-6 sm:py-5",
|
||||||
|
"rounded-[1.65rem] border border-transparent",
|
||||||
|
"hover:border-stroke/50 hover:bg-white/70 dark:hover:border-white/[0.08] dark:hover:bg-dark-3/50",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-dark-2",
|
||||||
|
headerFlushBottom &&
|
||||||
|
"rounded-b-none border-b border-stroke/45 dark:border-white/[0.07]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 items-start gap-3 sm:items-center">
|
||||||
|
<span className="mt-0.5 flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-violet-600 text-white sm:mt-0">
|
||||||
|
<SearchIcon className="h-5 w-5 opacity-95" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="block text-lg font-bold tracking-tight text-dark dark:text-white">
|
||||||
|
Refine tenders
|
||||||
|
</span>
|
||||||
|
<p className="mt-0.5 max-w-xl text-sm text-dark-5 dark:text-dark-6">
|
||||||
|
Search and slice the catalogue inline — no popup. Expand sections when you need
|
||||||
|
precision.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||||
|
{activeCount > 0 ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-full border border-primary/25 bg-primary/10 px-3 py-1.5 dark:border-primary/35 dark:bg-primary/15">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-primary">
|
||||||
|
{activeCount} active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center rounded-full border border-stroke/50 bg-white/60 px-3 py-1.5 text-xs font-medium text-dark-5 dark:border-white/10 dark:bg-dark-3/80 dark:text-dark-6">
|
||||||
|
No filters applied
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
ref={chevronRef}
|
||||||
|
className="inline-flex h-5 w-5 shrink-0 text-primary dark:text-primary/90"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={panelOuterRef}
|
||||||
|
className="overflow-hidden"
|
||||||
|
aria-hidden={!panelOpen}
|
||||||
|
>
|
||||||
|
<div ref={panelInnerRef} className="will-change-transform">
|
||||||
<form
|
<form
|
||||||
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
|
className="space-y-4 px-4 pb-6 pt-1 sm:px-6"
|
||||||
onSubmit={handleFilterSubmit(search)}
|
onSubmit={handleFilterSubmit(search)}
|
||||||
>
|
>
|
||||||
|
{/* Hero search */}
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
|
||||||
|
<div className="min-h-[52px] flex-1">
|
||||||
<InputGroup
|
<InputGroup
|
||||||
{...filterRegister("q")}
|
{...filterRegister("q")}
|
||||||
name="q"
|
name="q"
|
||||||
label="keyword"
|
label="Keyword search"
|
||||||
type="text"
|
type="text"
|
||||||
|
placeholder="Title, buyer, CPV — cast a wide net…"
|
||||||
|
icon={<SearchIcon className="text-primary dark:text-primary/90" />}
|
||||||
|
iconPosition="left"
|
||||||
|
className="[&_input]:rounded-2xl [&_input]:border-stroke/70 [&_input]:bg-white/90 [&_input]:py-3.5 [&_input]:text-sm dark:[&_input]:border-dark-3 dark:[&_input]:bg-dark-2/80"
|
||||||
/>
|
/>
|
||||||
<InputGroup
|
</div>
|
||||||
{...filterRegister("title")}
|
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
name="title"
|
<Button
|
||||||
label="title"
|
type="submit"
|
||||||
type="text"
|
size="small"
|
||||||
/>
|
shape="rounded"
|
||||||
<InputGroup
|
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
|
||||||
{...filterRegister("description")}
|
label={
|
||||||
name="description"
|
isMutating ? (
|
||||||
label="description"
|
<span className="inline-flex h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
type="text"
|
) : (
|
||||||
|
<span>Apply filters</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClearAll}
|
||||||
|
className="min-h-[52px] rounded-2xl border border-stroke/70 bg-white/80 px-6 text-sm font-semibold text-dark transition hover:border-primary/40 hover:text-primary dark:border-dark-3 dark:bg-dark-3/60 dark:text-white dark:hover:bg-white/[0.06]"
|
||||||
|
>
|
||||||
|
Reset all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CollapsibleFilterCard
|
||||||
|
title="Scope & geography"
|
||||||
|
subtitle="Country pick or comma-separated codes for multi-region searches."
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
<Select<FieldValues>
|
<Select<FieldValues>
|
||||||
{...filterRegister("country")}
|
{...filterRegister("country")}
|
||||||
name="country"
|
name="country"
|
||||||
label="country"
|
label="Country"
|
||||||
items={Countries}
|
items={Countries}
|
||||||
placeholder="Country"
|
placeholder="Any country"
|
||||||
clearable
|
clearable
|
||||||
prefixIcon={<GlobeIcon />}
|
prefixIcon={<GlobeIcon />}
|
||||||
value={watch("country")}
|
value={watch("country")}
|
||||||
onClear={() => setValue("country", "")}
|
onClear={() => setValue("country", "")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputGroup
|
<InputGroup
|
||||||
{...filterRegister("country_codes")}
|
{...filterRegister("country_codes")}
|
||||||
name="country_codes"
|
name="country_codes"
|
||||||
label="country codes"
|
label="Country codes"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. SWE, DEU"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CollapsibleFilterCard>
|
||||||
|
|
||||||
|
<CollapsibleFilterCard
|
||||||
|
title="Notice & classification"
|
||||||
|
subtitle="TED notice dimensions and CPV-style filters."
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||||
|
<InputGroup
|
||||||
|
{...filterRegister("title")}
|
||||||
|
name="title"
|
||||||
|
label="Title contains"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<InputGroup
|
||||||
|
{...filterRegister("description")}
|
||||||
|
name="description"
|
||||||
|
label="Description contains"
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
<InputGroup
|
<InputGroup
|
||||||
{...filterRegister("notice_type")}
|
{...filterRegister("notice_type")}
|
||||||
name="notice_type"
|
name="notice_type"
|
||||||
label="notice type"
|
label="Notice type"
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
<InputGroup
|
<InputGroup
|
||||||
{...filterRegister("notice_types")}
|
{...filterRegister("notice_types")}
|
||||||
name="notice_types"
|
name="notice_types"
|
||||||
label="notice types"
|
label="Notice types (comma-separated)"
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
<InputGroup
|
<InputGroup
|
||||||
{...filterRegister("form_types")}
|
{...filterRegister("form_types")}
|
||||||
name="form_types"
|
name="form_types"
|
||||||
label="form types"
|
label="Form types (comma-separated)"
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
<InputGroup
|
<InputGroup
|
||||||
{...filterRegister("main_classification")}
|
{...filterRegister("main_classification")}
|
||||||
name="main_classification"
|
name="main_classification"
|
||||||
label="main classification"
|
label="Main classification"
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
<InputGroup
|
<InputGroup
|
||||||
{...filterRegister("classifications")}
|
{...filterRegister("classifications")}
|
||||||
name="classifications"
|
name="classifications"
|
||||||
label="classifications"
|
label="Classifications (comma-separated)"
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
<InputGroup
|
<InputGroup
|
||||||
{...filterRegister("cpv_codes")}
|
{...filterRegister("cpv_codes")}
|
||||||
name="cpv_codes"
|
name="cpv_codes"
|
||||||
label="cpv codes"
|
label="CPV codes (comma-separated)"
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</CollapsibleFilterCard>
|
||||||
|
|
||||||
|
<CollapsibleFilterCard
|
||||||
|
title="Date ranges"
|
||||||
|
subtitle="Unix-backed ranges — pick start/end; cleared ranges are omitted from the query."
|
||||||
|
badge="Time travel"
|
||||||
|
>
|
||||||
|
<div className="grid gap-5 lg:grid-cols-2">
|
||||||
|
<div className="rounded-xl border border-stroke/40 bg-white/60 p-3 dark:border-white/[0.06] dark:bg-dark-3/40">
|
||||||
<DatePicker
|
<DatePicker
|
||||||
control={control}
|
control={control}
|
||||||
name="created_at_range"
|
name="created_at_range"
|
||||||
label="created at range"
|
label="Created at"
|
||||||
range
|
range
|
||||||
timePicker
|
timePicker
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-stroke/40 bg-white/60 p-3 dark:border-white/[0.06] dark:bg-dark-3/40">
|
||||||
<DatePicker
|
<DatePicker
|
||||||
control={control}
|
control={control}
|
||||||
name="tender_deadline_range"
|
name="tender_deadline_range"
|
||||||
label="tender deadline range"
|
label="Tender deadline"
|
||||||
range
|
range
|
||||||
timePicker
|
timePicker
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-stroke/40 bg-white/60 p-3 dark:border-white/[0.06] dark:bg-dark-3/40">
|
||||||
<DatePicker
|
<DatePicker
|
||||||
control={control}
|
control={control}
|
||||||
name="publication_date_range"
|
name="publication_date_range"
|
||||||
label="publication date range"
|
label="Publication date"
|
||||||
range
|
range
|
||||||
timePicker
|
timePicker
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-stroke/40 bg-white/60 p-3 dark:border-white/[0.06] dark:bg-dark-3/40">
|
||||||
<DatePicker
|
<DatePicker
|
||||||
control={control}
|
control={control}
|
||||||
name="submission_deadline_range"
|
name="submission_deadline_range"
|
||||||
label="submission deadline range"
|
label="Submission deadline"
|
||||||
range
|
range
|
||||||
timePicker
|
timePicker
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="col-span-2 flex gap-4">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="small"
|
|
||||||
shape="rounded"
|
|
||||||
className="mt-6 w-fit capitalize"
|
|
||||||
label={
|
|
||||||
isMutating ? (
|
|
||||||
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
|
|
||||||
) : (
|
|
||||||
<span>Search</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="error"
|
|
||||||
size="small"
|
|
||||||
shape="rounded"
|
|
||||||
className="mt-6 w-fit capitalize"
|
|
||||||
onClick={() => setIsFilterModalOpen(false)}
|
|
||||||
label="Close"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleFilterCard>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { ExclamationIcon, EyeIcon } from "@/assets/icons";
|
||||||
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
||||||
import Status from "@/components/ui/Status";
|
import Status from "@/components/ui/Status";
|
||||||
import {
|
import {
|
||||||
@@ -10,32 +11,20 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Tooltip } from "react-tooltip";
|
|
||||||
import useTenderListPresenter from "./useTenderListPresenter";
|
|
||||||
|
|
||||||
import { ExclamationIcon, EyeIcon } from "@/assets/icons";
|
|
||||||
|
|
||||||
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 Modal from "@/components/ui/modal";
|
|
||||||
import Pagination from "@/components/ui/pagination";
|
import Pagination from "@/components/ui/pagination";
|
||||||
import TableSkeleton from "@/components/ui/TableSkeleton";
|
import TableSkeleton from "@/components/ui/TableSkeleton";
|
||||||
import { _TooltipDefaultParams } from "@/constants/tooltip";
|
import { _TooltipDefaultParams } from "@/constants/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { getPaginatedRowNumber, unixToDate } from "@/utils/shared";
|
import { Tooltip } from "react-tooltip";
|
||||||
import TenderListFilters from "./TenderListFilters";
|
import TenderListFilters from "./TenderListFilters";
|
||||||
|
import useTenderListPresenter from "./useTenderListPresenter";
|
||||||
|
|
||||||
const TendersTable = () => {
|
const TendersTable = () => {
|
||||||
const {
|
const {
|
||||||
allTenders,
|
|
||||||
isPending,
|
|
||||||
router,
|
|
||||||
pathName,
|
|
||||||
setParams,
|
|
||||||
params,
|
|
||||||
columns,
|
columns,
|
||||||
isFilterModalOpen,
|
|
||||||
setIsFilterModalOpen,
|
|
||||||
filterRegister,
|
filterRegister,
|
||||||
handleFilterSubmit,
|
handleFilterSubmit,
|
||||||
search,
|
search,
|
||||||
@@ -44,15 +33,38 @@ const TendersTable = () => {
|
|||||||
control,
|
control,
|
||||||
isMutating,
|
isMutating,
|
||||||
shouldShowPagination,
|
shouldShowPagination,
|
||||||
|
clearAllFilters,
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
tendersList,
|
||||||
|
isPending,
|
||||||
|
navigateToTenderDetails,
|
||||||
|
navigateToTenderFeedback,
|
||||||
|
handlePaginationChange,
|
||||||
|
getRowNumber,
|
||||||
|
formatDateTimeCell,
|
||||||
|
pagination,
|
||||||
} = useTenderListPresenter();
|
} = useTenderListPresenter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListWrapper>
|
<ListWrapper>
|
||||||
<ListHeader
|
<ListHeader
|
||||||
onFilter={() => setIsFilterModalOpen(true)}
|
hasFilter={false}
|
||||||
createButtonText="Create Tender"
|
createButtonText="Create Tender"
|
||||||
hasCreate={false}
|
hasCreate={false}
|
||||||
/>
|
/>
|
||||||
|
<TenderListFilters
|
||||||
|
setValue={setFilterValue}
|
||||||
|
filterRegister={filterRegister}
|
||||||
|
control={control}
|
||||||
|
handleFilterSubmit={handleFilterSubmit}
|
||||||
|
isMutating={isMutating as number}
|
||||||
|
search={search}
|
||||||
|
watch={watch}
|
||||||
|
onClearAll={clearAllFilters}
|
||||||
|
/>
|
||||||
|
<div ref={tableSectionRef}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-none uppercase [&>th]:text-start">
|
<TableRow className="border-none uppercase [&>th]:text-start">
|
||||||
@@ -64,25 +76,23 @@ const TendersTable = () => {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
||||||
{isPending && <TableSkeleton column={columns.length} />}
|
{isPending ? (
|
||||||
<TableBody>
|
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
|
||||||
|
) : (
|
||||||
|
<TableBody ref={dataTbodyRef}>
|
||||||
<EmptyListWrapper
|
<EmptyListWrapper
|
||||||
list={allTenders?.data.tenders ?? []}
|
list={tendersList}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
emptyMessage="No tenders were found"
|
emptyMessage="No tenders were found"
|
||||||
isLoading={isPending}
|
isLoading={false}
|
||||||
>
|
>
|
||||||
{(allTenders?.data.tenders ?? []).map((item, index) => (
|
{tendersList.map((item, index) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="odd:bg-gray-2 dark:odd:bg-gray-7"
|
className="odd:bg-gray-2 dark:odd:bg-gray-7"
|
||||||
>
|
>
|
||||||
<TableCell className="text-start" colSpan={100}>
|
<TableCell className="text-start" colSpan={100}>
|
||||||
{getPaginatedRowNumber({
|
{getRowNumber(index)}
|
||||||
index,
|
|
||||||
page: allTenders?.meta?.page,
|
|
||||||
limit: allTenders?.meta?.limit,
|
|
||||||
})}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-start" colSpan={100}>
|
<TableCell className="text-start" colSpan={100}>
|
||||||
{item.title}
|
{item.title}
|
||||||
@@ -91,25 +101,16 @@ const TendersTable = () => {
|
|||||||
{item.country_code}
|
{item.country_code}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-start" colSpan={100}>
|
<TableCell className="text-start" colSpan={100}>
|
||||||
{unixToDate({
|
{formatDateTimeCell(item.publication_date)}
|
||||||
unix: item.publication_date,
|
|
||||||
hasTime: true,
|
|
||||||
})}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-start" colSpan={100}>
|
<TableCell className="text-start" colSpan={100}>
|
||||||
{unixToDate({
|
{formatDateTimeCell(item.submission_deadline)}
|
||||||
unix: item.submission_deadline,
|
|
||||||
hasTime: true,
|
|
||||||
})}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-start" colSpan={100}>
|
<TableCell className="text-start" colSpan={100}>
|
||||||
{unixToDate({ unix: item.tender_deadline, hasTime: true })}
|
{formatDateTimeCell(item.tender_deadline)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-start" colSpan={100}>
|
<TableCell className="text-start" colSpan={100}>
|
||||||
{unixToDate({
|
{formatDateTimeCell(item?.created_at ?? 0)}
|
||||||
unix: item?.created_at ?? 0,
|
|
||||||
hasTime: true,
|
|
||||||
})}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className={cn(`text capitalize`)} colSpan={100}>
|
<TableCell className={cn(`text capitalize`)} colSpan={100}>
|
||||||
@@ -118,15 +119,11 @@ const TendersTable = () => {
|
|||||||
<TableCell className="capitalize" colSpan={100}>
|
<TableCell className="capitalize" colSpan={100}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
data-tooltip-id="details"
|
data-tooltip-id="details"
|
||||||
data-tooltip-content="Details"
|
data-tooltip-content="Details"
|
||||||
data-tooltip-place="top"
|
data-tooltip-place="top"
|
||||||
onClick={() => {
|
onClick={() => navigateToTenderDetails(item.id)}
|
||||||
const queryString = params?.lang
|
|
||||||
? `?lang=${encodeURIComponent(params.lang)}`
|
|
||||||
: "";
|
|
||||||
router.push(`${pathName}/${item.id}${queryString}`);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
{..._TooltipDefaultParams({
|
{..._TooltipDefaultParams({
|
||||||
@@ -136,18 +133,12 @@ const TendersTable = () => {
|
|||||||
<EyeIcon />
|
<EyeIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
data-tooltip-id="feedback"
|
data-tooltip-id="feedback"
|
||||||
data-tooltip-content="Feedback"
|
data-tooltip-content="Feedback"
|
||||||
data-tooltip-place="top"
|
data-tooltip-place="top"
|
||||||
className="text-gray-40 rounded-full bg-gray-dark bg-opacity-5 hover:text-green-dark dark:bg-gray dark:bg-opacity-5"
|
className="text-gray-40 rounded-full bg-gray-dark bg-opacity-5 hover:text-green-dark dark:bg-gray dark:bg-opacity-5"
|
||||||
onClick={() => {
|
onClick={() => navigateToTenderFeedback(item.id)}
|
||||||
const queryString = params?.lang
|
|
||||||
? `?lang=${encodeURIComponent(params.lang)}`
|
|
||||||
: "";
|
|
||||||
router.push(
|
|
||||||
`${pathName}/feedback/${item.id}${queryString}`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Tooltip {..._TooltipDefaultParams({ id: "feedback" })} />
|
<Tooltip {..._TooltipDefaultParams({ id: "feedback" })} />
|
||||||
<ExclamationIcon />
|
<ExclamationIcon />
|
||||||
@@ -158,35 +149,14 @@ const TendersTable = () => {
|
|||||||
))}
|
))}
|
||||||
</EmptyListWrapper>
|
</EmptyListWrapper>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
<Modal
|
</div>
|
||||||
isOpen={isFilterModalOpen}
|
|
||||||
onClose={() => setIsFilterModalOpen(false)}
|
|
||||||
classNames="w-full xl:w-1/2"
|
|
||||||
showButtons={false}
|
|
||||||
>
|
|
||||||
<TenderListFilters
|
|
||||||
setValue={setFilterValue}
|
|
||||||
filterRegister={filterRegister}
|
|
||||||
control={control}
|
|
||||||
handleFilterSubmit={handleFilterSubmit}
|
|
||||||
isMutating={isMutating as number}
|
|
||||||
search={search}
|
|
||||||
setIsFilterModalOpen={setIsFilterModalOpen}
|
|
||||||
watch={watch}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
<IsVisible condition={shouldShowPagination}>
|
<IsVisible condition={shouldShowPagination}>
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={allTenders?.meta?.page ? allTenders?.meta?.page - 1 : 0}
|
currentPage={pagination.currentPage}
|
||||||
totalPages={allTenders?.meta?.pages ?? 1}
|
totalPages={pagination.totalPages}
|
||||||
onPageChange={(e) => {
|
onPageChange={handlePaginationChange}
|
||||||
setParams((currentParams) => ({
|
|
||||||
...currentParams,
|
|
||||||
offset: e.selected * (allTenders?.meta?.limit ?? 10),
|
|
||||||
limit: allTenders?.meta?.limit ?? 10,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</IsVisible>
|
</IsVisible>
|
||||||
</ListWrapper>
|
</ListWrapper>
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { ConfirmationModalProps } from "@/components/ui/ConfirmationModal";
|
|||||||
import { apiDefaultParams } from "@/constants/Api_Params";
|
import { apiDefaultParams } from "@/constants/Api_Params";
|
||||||
import { useTendersQuery } from "@/hooks/queries";
|
import { useTendersQuery } from "@/hooks/queries";
|
||||||
import { TCustomer } from "@/lib/api";
|
import { TCustomer } from "@/lib/api";
|
||||||
import { deleteEmptyKeys } from "@/utils/shared";
|
import { deleteEmptyKeys, getPaginatedRowNumber, unixToDate } from "@/utils/shared";
|
||||||
|
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 { useEffect, 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([
|
||||||
@@ -216,7 +217,6 @@ const TENDER_FILTER_MODAL_KEYS = new Set<string>([
|
|||||||
|
|
||||||
const useTenderListPresenter = () => {
|
const useTenderListPresenter = () => {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
|
|
||||||
const [currentTender, setCurrentTender] = useState<TCustomer | null>(null);
|
const [currentTender, setCurrentTender] = useState<TCustomer | null>(null);
|
||||||
const [modalConfig, setModalConfig] = useState<
|
const [modalConfig, setModalConfig] = useState<
|
||||||
Partial<ConfirmationModalProps>
|
Partial<ConfirmationModalProps>
|
||||||
@@ -227,6 +227,10 @@ const useTenderListPresenter = () => {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const isMutating = useIsMutating();
|
const isMutating = useIsMutating();
|
||||||
|
|
||||||
|
const tableSectionRef = useRef<HTMLDivElement>(null);
|
||||||
|
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||||
|
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register: filterRegister,
|
register: filterRegister,
|
||||||
handleSubmit: handleFilterSubmit,
|
handleSubmit: handleFilterSubmit,
|
||||||
@@ -253,6 +257,110 @@ const useTenderListPresenter = () => {
|
|||||||
const shouldShowPagination =
|
const shouldShowPagination =
|
||||||
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
!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 handlePaginationChange = useCallback(
|
||||||
|
(e: { selected: number }) => {
|
||||||
|
const limit = data?.meta?.limit ?? 10;
|
||||||
|
setParams((currentParams) => ({
|
||||||
|
...currentParams,
|
||||||
|
offset: e.selected * limit,
|
||||||
|
limit,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[data?.meta?.limit, setParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pagination = useMemo(
|
||||||
|
() => ({
|
||||||
|
currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
|
||||||
|
totalPages: data?.meta?.pages ?? 1,
|
||||||
|
}),
|
||||||
|
[data?.meta?.page, data?.meta?.pages],
|
||||||
|
);
|
||||||
|
|
||||||
|
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 search = (data: any) => {
|
||||||
const mappedDateRanges = mapDateRangeFilters(data);
|
const mappedDateRanges = mapDateRangeFilters(data);
|
||||||
const normalizedData = normalizeFilterValues(mappedDateRanges);
|
const normalizedData = normalizeFilterValues(mappedDateRanges);
|
||||||
@@ -266,8 +374,11 @@ const useTenderListPresenter = () => {
|
|||||||
const cleanedParams = deleteEmptyKeys(newParams);
|
const cleanedParams = deleteEmptyKeys(newParams);
|
||||||
const queryString = new URLSearchParams(cleanedParams).toString();
|
const queryString = new URLSearchParams(cleanedParams).toString();
|
||||||
router.push(`${pathName}?${queryString}`);
|
router.push(`${pathName}?${queryString}`);
|
||||||
setIsFilterModalOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearAllFilters = useCallback(() => {
|
||||||
|
router.push(pathName);
|
||||||
|
}, [pathName, router]);
|
||||||
const columns = [
|
const columns = [
|
||||||
"row",
|
"row",
|
||||||
"title",
|
"title",
|
||||||
@@ -295,8 +406,7 @@ const useTenderListPresenter = () => {
|
|||||||
setIsModalOpen,
|
setIsModalOpen,
|
||||||
modalConfig,
|
modalConfig,
|
||||||
setModalConfig,
|
setModalConfig,
|
||||||
isFilterModalOpen,
|
clearAllFilters,
|
||||||
setIsFilterModalOpen,
|
|
||||||
filterRegister,
|
filterRegister,
|
||||||
handleFilterSubmit,
|
handleFilterSubmit,
|
||||||
search,
|
search,
|
||||||
@@ -305,6 +415,16 @@ const useTenderListPresenter = () => {
|
|||||||
control,
|
control,
|
||||||
isMutating,
|
isMutating,
|
||||||
shouldShowPagination,
|
shouldShowPagination,
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
tendersList,
|
||||||
|
navigateToTenderDetails,
|
||||||
|
navigateToTenderFeedback,
|
||||||
|
handlePaginationChange,
|
||||||
|
getRowNumber,
|
||||||
|
formatDateTimeCell,
|
||||||
|
pagination,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import * as React from "react";
|
||||||
import { Skeleton } from "./skeleton";
|
import { Skeleton } from "./skeleton";
|
||||||
import { TableBody, TableCell, TableRow } from "./table";
|
import { TableBody, TableCell, TableRow } from "./table";
|
||||||
|
|
||||||
@@ -7,13 +8,10 @@ interface IProps {
|
|||||||
cellColSpan?: number;
|
cellColSpan?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TableSkeleton = ({
|
const TableSkeleton = React.forwardRef<HTMLTableSectionElement, IProps>(
|
||||||
column,
|
({ column, length = 20, cellColSpan = 100 }, ref) => {
|
||||||
length = 20,
|
|
||||||
cellColSpan = 100,
|
|
||||||
}: IProps) => {
|
|
||||||
return (
|
return (
|
||||||
<TableBody data-cy="table-skeleton">
|
<TableBody ref={ref} data-cy="table-skeleton">
|
||||||
{Array.from({ length }).map((_, i) => (
|
{Array.from({ length }).map((_, i) => (
|
||||||
<TableRow key={i} data-cy={`table-skeleton-row-${i}`}>
|
<TableRow key={i} data-cy={`table-skeleton-row-${i}`}>
|
||||||
{Array.from({ length: column }).map((_, j) => (
|
{Array.from({ length: column }).map((_, j) => (
|
||||||
@@ -25,6 +23,8 @@ const TableSkeleton = ({
|
|||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
);
|
||||||
|
TableSkeleton.displayName = "TableSkeleton";
|
||||||
|
|
||||||
export default TableSkeleton;
|
export default TableSkeleton;
|
||||||
|
|||||||
@@ -30,17 +30,17 @@ export function TableHeader({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableBody({
|
export const TableBody = React.forwardRef<
|
||||||
className,
|
HTMLTableSectionElement,
|
||||||
...props
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
}: React.HTMLAttributes<HTMLTableSectionElement>) {
|
>(({ className, ...props }, ref) => (
|
||||||
return (
|
|
||||||
<tbody
|
<tbody
|
||||||
|
ref={ref}
|
||||||
className={cn("[&_tr:last-child]:border-0 [&_tr]:border-stroke/60 dark:[&_tr]:border-dark-3", className)}
|
className={cn("[&_tr:last-child]:border-0 [&_tr]:border-stroke/60 dark:[&_tr]:border-dark-3", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
));
|
||||||
}
|
TableBody.displayName = "TableBody";
|
||||||
|
|
||||||
export function TableFooter({
|
export function TableFooter({
|
||||||
className,
|
className,
|
||||||
|
|||||||
Reference in New Issue
Block a user