diff --git a/cypress/e2e/tenders/tender-translation-page.cy.ts b/cypress/e2e/tenders/tender-translation-page.cy.ts
index ee1d79d..b14b07d 100644
--- a/cypress/e2e/tenders/tender-translation-page.cy.ts
+++ b/cypress/e2e/tenders/tender-translation-page.cy.ts
@@ -72,6 +72,24 @@ describe("tender details - translation and summary", () => {
});
it("switches language from selector and reloads details with new lang query", () => {
+ cy.intercept(
+ "POST",
+ `**/api/proxy/tenders/${tenderId}/ai-translate`,
+ (req) => {
+ req.reply({
+ statusCode: 200,
+ body: {
+ success: true,
+ message: "ok",
+ data: {
+ notice_id: "NP-123",
+ language: String(req.body?.language ?? ""),
+ },
+ },
+ });
+ },
+ ).as("translateOnLangChange");
+
cy.intercept("GET", `**/api/proxy/tenders/${tenderId}**`, (req) => {
const lang = String(req.query.lang ?? "en");
req.alias = lang === "fr" ? "getTenderDetailsFr" : "getTenderDetailsEn";
@@ -91,18 +109,19 @@ describe("tender details - translation and summary", () => {
cy.get('select[name="lang"]').select("fr");
cy.location("search").should("include", "lang=fr");
+ cy.wait("@translateOnLangChange");
cy.wait("@getTenderDetailsFr");
tenderTitleHeading().should("contain.text", "Titre FR");
cy.contains("Resume FR").should("be.visible");
});
- it("triggers translate endpoint with selected language", () => {
+ it("triggers translate endpoint when language changes", () => {
cy.intercept("GET", `**/api/proxy/tenders/${tenderId}**`, {
statusCode: 200,
body: makeTenderDetailsResponse({
title: "Before translate",
description: "Before translate description",
- language: "de",
+ language: "en",
}),
}).as("getTenderDetails");
@@ -127,9 +146,9 @@ describe("tender details - translation and summary", () => {
},
).as("translateTender");
- cy.visit(`/tenders/${tenderId}?lang=de`);
+ cy.visit(`/tenders/${tenderId}?lang=en`);
cy.wait("@getTenderDetails");
- cy.contains("button", "Translate metadata").click();
+ cy.get('select[name="lang"]').select("de");
cy.wait("@translateTender");
});
diff --git a/package.json b/package.json
index 5da0195..ca297b0 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"test:e2e": "start-server-and-test dev:e2e http://localhost:3000 \"CYPRESS_BASE_URL=http://localhost:3000 cypress run\""
},
"dependencies": {
+ "@lottiefiles/dotlottie-react": "^0.19.2",
"@tanstack/react-query": "^5.85.6",
"@types/js-cookie": "^3.0.6",
"@types/react-paginate": "^7.1.4",
@@ -28,6 +29,7 @@
"gsap": "^3.15.0",
"js-cookie": "^3.0.5",
"jsvectormap": "^1.6.0",
+ "lottie-react": "^2.4.1",
"moment": "^2.30.1",
"next": "15.1.9",
"next-themes": "^0.4.4",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d86c9b2..af34f45 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
+ '@lottiefiles/dotlottie-react':
+ specifier: ^0.19.2
+ version: 0.19.2(react@19.0.0)
'@tanstack/react-query':
specifier: ^5.85.6
version: 5.100.2(react@19.0.0)
@@ -50,6 +53,9 @@ importers:
jsvectormap:
specifier: ^1.6.0
version: 1.7.0
+ lottie-react:
+ specifier: ^2.4.1
+ version: 2.4.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
moment:
specifier: ^2.30.1
version: 2.30.1
@@ -597,6 +603,14 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@lottiefiles/dotlottie-react@0.19.2':
+ resolution: {integrity: sha512-qXvCU5Wm/BNYfoS/+MzeVqn03yu2v3XTJ+ik8kKgyHlbf6Vcgz3byOQk9H/mOciedjhGr+YmZXZ+ItqFUmA3Iw==}
+ peerDependencies:
+ react: ^17 || ^18 || ^19
+
+ '@lottiefiles/dotlottie-web@0.72.1':
+ resolution: {integrity: sha512-TPHCTQHw4oZBiIi/JPd6CtjPzsazpyRVUloO7kA5khF6IX/sCIfBa8l/Tav333pskO6gFMtlnfRPwEOatMZS/A==}
+
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
@@ -2103,6 +2117,15 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
+ lottie-react@2.4.1:
+ resolution: {integrity: sha512-LQrH7jlkigIIv++wIyrOYFLHSKQpEY4zehPicL9bQsrt1rnoKRYCYgpCUe5maqylNtacy58/sQDZTkwMcTRxZw==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ lottie-web@5.13.0:
+ resolution: {integrity: sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==}
+
map-stream@0.1.0:
resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==}
@@ -3588,6 +3611,13 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@lottiefiles/dotlottie-react@0.19.2(react@19.0.0)':
+ dependencies:
+ '@lottiefiles/dotlottie-web': 0.72.1
+ react: 19.0.0
+
+ '@lottiefiles/dotlottie-web@0.72.1': {}
+
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.10.0
@@ -5280,6 +5310,14 @@ snapshots:
dependencies:
js-tokens: 4.0.0
+ lottie-react@2.4.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
+ dependencies:
+ lottie-web: 5.13.0
+ react: 19.0.0
+ react-dom: 19.0.0(react@19.0.0)
+
+ lottie-web@5.13.0: {}
+
map-stream@0.1.0: {}
math-intrinsics@1.1.0: {}
diff --git a/public/lottie/404.lottie b/public/lottie/404.lottie
new file mode 100644
index 0000000..3426fdf
Binary files /dev/null and b/public/lottie/404.lottie differ
diff --git a/public/lottie/loading.lottie b/public/lottie/loading.lottie
new file mode 100644
index 0000000..6c5af1c
Binary files /dev/null and b/public/lottie/loading.lottie differ
diff --git a/src/app/(not-found)/[...not-found]/page.tsx b/src/app/(not-found)/[...not-found]/page.tsx
index 5462ebb..592f708 100644
--- a/src/app/(not-found)/[...not-found]/page.tsx
+++ b/src/app/(not-found)/[...not-found]/page.tsx
@@ -1,37 +1,5 @@
-import Image from "next/image";
-import Link from "next/link";
+import NotFoundContent from "@/components/not-found-content";
-const NotFoundPage = () => {
- return (
-
-
-
-
- Page not found
-
- Sorry, the page you're looking for does not exist or has been
- removed.
-
- You can navigate to Home using
-
- This
-
- link.
-
-
-
- );
-};
-
-export default NotFoundPage;
+export default function NotFoundPage() {
+ return ;
+}
diff --git a/src/app/tenders/[details]/components/tender-details-header.tsx b/src/app/tenders/[details]/components/tender-details-header.tsx
index 25311a8..23f08ba 100644
--- a/src/app/tenders/[details]/components/tender-details-header.tsx
+++ b/src/app/tenders/[details]/components/tender-details-header.tsx
@@ -1,15 +1,13 @@
"use client";
+import { GlobeIcon } from "@/assets/icons";
import { Select } from "@/components/FormElements/select";
-import { Button } from "@/components/ui-elements/button";
import Status from "@/components/ui/Status";
import { Languages } from "@/constants/languages";
import { cn } from "@/lib/utils";
import type { ComponentProps } from "react";
-import { useRef } from "react";
import type { FieldValues } from "react-hook-form";
import { TENDER_HEADER_TITLE_CLASS } from "../constants";
-import { useStackTenderHeaderActions } from "../hooks/use-stack-tender-header-actions";
type LanguageSelectOnChange = NonNullable<
ComponentProps>["onChange"]
@@ -19,7 +17,6 @@ type TenderDetailsHeaderProps = {
title: string;
selectedLanguage: string;
onLanguageChange: (event: React.ChangeEvent) => void;
- onTranslate: () => void;
isTranslating: boolean;
status: string | undefined;
};
@@ -28,94 +25,69 @@ export function TenderDetailsHeader({
title,
selectedLanguage,
onLanguageChange,
- onTranslate,
isTranslating,
status,
}: TenderDetailsHeaderProps) {
- const headerLayoutContainerRef = useRef(null);
- const headerTitleProbeSizerRef = useRef(null);
- const stackActionsBelow = useStackTenderHeaderActions(
- title,
- headerLayoutContainerRef,
- headerTitleProbeSizerRef,
- );
-
return (
-
-
-
+
+
+
+
+
+ Tender
+
+
{status}
+
{title}
-
-
-
-
-
- Language & translation
-
+
-
diff --git a/src/app/tenders/[details]/constants.ts b/src/app/tenders/[details]/constants.ts
index 83dc12e..427ab49 100644
--- a/src/app/tenders/[details]/constants.ts
+++ b/src/app/tenders/[details]/constants.ts
@@ -1,10 +1,7 @@
-/** Matches the tender title `
` in the header — keep in sync for layout probe */
+/** Matches the tender title `` in the header */
export const TENDER_HEADER_TITLE_CLASS =
"text-balance text-xl font-bold leading-tight text-dark dark:text-white sm:text-2xl lg:text-[1.65rem]";
-/** Reserved width for the actions column when deciding side‑by‑side vs stacked (lg+) */
-export const TENDER_HEADER_ACTIONS_RESERVE_PX = 360;
-
export const TENDER_SECTION_IDS = {
procurement: "tender-section-procurement",
lots: "tender-section-lots",
diff --git a/src/app/tenders/[details]/hooks/use-stack-tender-header-actions.ts b/src/app/tenders/[details]/hooks/use-stack-tender-header-actions.ts
deleted file mode 100644
index e256fcb..0000000
--- a/src/app/tenders/[details]/hooks/use-stack-tender-header-actions.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { useLayoutEffect, useState, type RefObject } from "react";
-import { TENDER_HEADER_ACTIONS_RESERVE_PX } from "../constants";
-
-export function useStackTenderHeaderActions(
- title: string,
- containerRef: RefObject,
- probeSizerRef: RefObject,
-) {
- const [stackBelow, setStackBelow] = useState(false);
-
- useLayoutEffect(() => {
- const container = containerRef.current;
- const probeSizer = probeSizerRef.current;
- if (!container || !probeSizer) return;
-
- const run = () => {
- if (!window.matchMedia("(min-width: 1024px)").matches) {
- setStackBelow(false);
- return;
- }
-
- const styles = window.getComputedStyle(container);
- const gapRaw = parseFloat(styles.columnGap || styles.gap);
- const gap = Number.isFinite(gapRaw) ? gapRaw : 32;
- const containerW = container.getBoundingClientRect().width;
- const titleColumnW = Math.max(
- 160,
- containerW - TENDER_HEADER_ACTIONS_RESERVE_PX - gap,
- );
- probeSizer.style.width = `${titleColumnW}px`;
-
- const probeTitle = probeSizer.querySelector("h1");
- if (!probeTitle) return;
-
- const titleStyles = window.getComputedStyle(probeTitle);
- const lineHeightRaw = parseFloat(titleStyles.lineHeight);
- const fontSize = parseFloat(titleStyles.fontSize);
- const lineHeight =
- Number.isFinite(lineHeightRaw) && lineHeightRaw > 0
- ? lineHeightRaw
- : fontSize * 1.25;
- const lines = probeTitle.scrollHeight / lineHeight;
- const next = lines > 1.35;
- setStackBelow((p) => (p === next ? p : next));
- };
-
- run();
- const ro = new ResizeObserver(run);
- ro.observe(container);
- window.addEventListener("resize", run);
- return () => {
- ro.disconnect();
- window.removeEventListener("resize", run);
- };
- }, [title, containerRef, probeSizerRef]);
-
- return stackBelow;
-}
diff --git a/src/app/tenders/[details]/page.tsx b/src/app/tenders/[details]/page.tsx
index 31e4fc5..40f0695 100644
--- a/src/app/tenders/[details]/page.tsx
+++ b/src/app/tenders/[details]/page.tsx
@@ -31,7 +31,7 @@ const TenderDetails = ({ params }: IProps) => {
const selectedLanguage = searchParams.get("lang") || "en";
const { mutate: translateTender, isPending: isTranslating } =
useTranslateTenderMutation();
- const { data, isLoading, isError, refetch } = useTenderDetailQuery(details, {
+ const { data, isLoading, isError } = useTenderDetailQuery(details, {
lang: selectedLanguage,
});
@@ -65,29 +65,21 @@ const TenderDetails = ({ params }: IProps) => {
const activeSectionId = useTenderSectionScrollSpy(sectionIds);
const handleLanguageChange = useCallback(
(event: React.ChangeEvent) => {
- const nextParams = new URLSearchParams(searchParams.toString());
const nextLanguage = event.target.value;
- if (nextLanguage) {
- nextParams.set("lang", nextLanguage);
- } else {
- nextParams.delete("lang");
- }
+ if (!nextLanguage) return;
+
+ const prevLanguage = searchParams.get("lang") || "en";
+ if (nextLanguage === prevLanguage) return;
+
+ const nextParams = new URLSearchParams(searchParams.toString());
+ nextParams.set("lang", nextLanguage);
const queryString = nextParams.toString();
router.replace(queryString ? `${pathname}?${queryString}` : pathname);
- },
- [pathname, router, searchParams],
- );
- const handleTranslate = useCallback(() => {
- translateTender(
- { id: details, language: selectedLanguage },
- {
- onSuccess: async () => {
- await refetch();
- },
- },
- );
- }, [details, refetch, selectedLanguage, translateTender]);
+ translateTender({ id: details, language: nextLanguage });
+ },
+ [details, pathname, router, searchParams, translateTender],
+ );
if (isLoading) return ;
if (isError) {
@@ -106,7 +98,6 @@ const TenderDetails = ({ params }: IProps) => {
title={tender.title}
selectedLanguage={selectedLanguage}
onLanguageChange={handleLanguageChange}
- onTranslate={handleTranslate}
isTranslating={isTranslating}
status={tender.status}
/>
diff --git a/src/assets/lottie/loading-main.json b/src/assets/lottie/loading-main.json
new file mode 100644
index 0000000..552b621
--- /dev/null
+++ b/src/assets/lottie/loading-main.json
@@ -0,0 +1 @@
+{"v":"4.8.0","fr":60,"ip":0,"op":480,"w":200,"h":200,"ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":1,"ty":3,"sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"t":240,"s":[-720]}],"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":97,"s":[70,70,100]},{"t":195,"s":[100,100,100]}],"ix":6}},"ao":0,"ip":0,"op":240,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":0,"parent":1,"refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2},"a":{"a":0,"k":[250,250,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"tm":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":120,"s":[2]}],"ix":2},"w":500,"h":500,"ip":0,"op":245,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"parent":1,"refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":60,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2},"a":{"a":0,"k":[250,250,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"tm":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[0]},{"t":135,"s":[2]}],"ix":2},"w":500,"h":500,"ip":0,"op":255,"st":15,"bm":0},{"ddd":0,"ind":4,"ty":0,"parent":1,"refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":120,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2},"a":{"a":0,"k":[250,250,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"tm":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[0]},{"t":150,"s":[2]}],"ix":2},"w":500,"h":500,"ip":0,"op":270,"st":30,"bm":0},{"ddd":0,"ind":5,"ty":0,"parent":1,"refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":180,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2},"a":{"a":0,"k":[250,250,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"tm":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":45,"s":[0]},{"t":165,"s":[2]}],"ix":2},"w":500,"h":500,"ip":0,"op":285,"st":45,"bm":0},{"ddd":0,"ind":6,"ty":0,"parent":1,"refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":240,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2},"a":{"a":0,"k":[250,250,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"tm":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":60,"s":[0]},{"t":180,"s":[2]}],"ix":2},"w":500,"h":500,"ip":0,"op":300,"st":60,"bm":0},{"ddd":0,"ind":7,"ty":0,"parent":1,"refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":300,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2},"a":{"a":0,"k":[250,250,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"tm":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":75,"s":[0]},{"t":195,"s":[2]}],"ix":2},"w":500,"h":500,"ip":0,"op":315,"st":75,"bm":0}]},{"id":"comp_1","layers":[{"ddd":0,"ind":1,"ty":3,"sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"t":120,"s":[0]}],"ix":10},"p":{"a":0,"k":[250,250,0],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"ip":0,"op":121,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"t":120,"s":[720]}],"ix":10},"p":{"a":0,"k":[50,22.813,0],"ix":2},"a":{"a":0,"k":[250,222.813,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":60,"s":[150,150,100]},{"t":120,"s":[100,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-7.87],[7.87,0],[0,7.87],[-7.87,0]],"o":[[0,7.87],[-7.87,0],[0,-7.87],[7.87,0]],"v":[[14.25,0],[0,14.25],[-14.25,0],[0,-14.25]],"c":true},"ix":2},"hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"hd":false},{"ty":"tr","p":{"a":0,"k":[250,195.626],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5}}],"np":2,"cix":2,"bm":0,"ix":2,"hd":false}],"ip":0,"op":121,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":3,"sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":479,"s":[1080]}],"ix":10},"p":{"a":0,"k":[100,100,0],"ix":2},"a":{"a":0,"k":[50,50,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":118,"s":[70,70,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":240,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":359,"s":[70,70,100]},{"t":481,"s":[100,100,100]}],"ix":6}},"ao":0,"ip":0,"op":480,"st":0,"bm":0,"nm":"lottielab.com"},{"ddd":0,"ind":2,"ty":0,"parent":1,"refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":180,"ix":10},"p":{"a":0,"k":[50,50,0],"ix":2},"a":{"a":0,"k":[250,250,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":500,"h":500,"ip":240,"op":480,"st":240,"bm":0},{"ddd":0,"ind":3,"ty":0,"parent":1,"refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[50,50,0],"ix":2},"a":{"a":0,"k":[250,250,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":500,"h":500,"ip":0,"op":240,"st":0,"bm":0}]}
\ No newline at end of file
diff --git a/src/components/FormElements/DatePicker/DatePicker.tsx b/src/components/FormElements/DatePicker/DatePicker.tsx
index be88dec..c411966 100644
--- a/src/components/FormElements/DatePicker/DatePicker.tsx
+++ b/src/components/FormElements/DatePicker/DatePicker.tsx
@@ -139,7 +139,7 @@ const DatePickerRHFInput = ({
}
onlyYearPicker={onlyYearPicker}
multiple={multiple}
- {...(portal ? { portal: true } : {})}
+ {...(portal ? { portal: true, zIndex: 99999 } : {})}
value={pickerValue}
onChange={(date: DateObject | DateObject[] | null) => {
if (!date) {
diff --git a/src/components/FormElements/select.tsx b/src/components/FormElements/select.tsx
index 8769e2a..d1d1ce6 100644
--- a/src/components/FormElements/select.tsx
+++ b/src/components/FormElements/select.tsx
@@ -26,6 +26,7 @@ type PropsType = {
onClear?: () => void;
value?: string;
dataCy?: string;
+ disabled?: boolean;
} & (
| { placeholder?: string; defaultValue?: string }
| { placeholder: string; defaultValue?: string }
@@ -50,6 +51,7 @@ export function Select({
onClear,
value: controlledValue,
dataCy,
+ disabled = false,
...props
}: PropsType) {
const id = useId();
@@ -100,6 +102,7 @@ export function Select({
id={id}
name={name}
ref={ref}
+ disabled={disabled}
value={value}
onChange={(e) => {
if (controlledValue === undefined) {
@@ -113,6 +116,8 @@ export function Select({
className={cn(
"w-full appearance-none rounded-xl border border-stroke/70 bg-white/90 px-5.5 py-3 shadow-[0_1px_2px_rgba(16,24,40,0.04),0_8px_20px_rgba(16,24,40,0.04)] outline-none backdrop-blur-[1px] transition-all duration-200 focus:border-primary/70 focus:bg-white focus:shadow-[0_0_0_2px_rgba(59,130,246,0.14),0_10px_24px_rgba(59,130,246,0.08)] active:border-primary/70 dark:border-dark-3/80 dark:bg-dark-2/85 dark:focus:border-primary/80 dark:focus:shadow-[0_0_0_2px_rgba(59,130,246,0.2),0_10px_24px_rgba(15,23,42,0.45)] [&>option]:text-dark-5 dark:[&>option]:text-dark-6",
isOptionSelected && "text-dark dark:text-white",
+ disabled &&
+ "cursor-not-allowed opacity-60 hover:border-stroke/70 dark:hover:border-dark-3/80",
error &&
"border-red/80 focus:border-red focus:shadow-[0_0_0_2px_rgba(239,68,68,0.16),0_10px_24px_rgba(239,68,68,0.1)] dark:border-red/80 dark:focus:shadow-[0_0_0_2px_rgba(239,68,68,0.24),0_10px_24px_rgba(127,29,29,0.45)]",
prefixIcon && "pl-11.5",
diff --git a/src/components/Tables/admins/AdminListFilters.tsx b/src/components/Tables/admins/AdminListFilters.tsx
index 1e8e601..a3195a0 100644
--- a/src/components/Tables/admins/AdminListFilters.tsx
+++ b/src/components/Tables/admins/AdminListFilters.tsx
@@ -1,7 +1,13 @@
+"use client";
+
+import { SearchIcon } from "@/assets/icons";
import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select";
+import { Button } from "@/components/ui-elements/button";
+import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import { AdminStatus } from "@/constants/enums";
import { getEnumAsArray } from "@/utils/shared";
+import { useMemo } from "react";
import {
FieldValues,
UseFormHandleSubmit,
@@ -12,103 +18,113 @@ import {
interface AdminListFiltersProps {
filterRegister: UseFormRegister;
- setIsFilterModalOpen: (isOpen: boolean) => void;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit;
search: (data: any) => void;
watch: UseFormWatch;
setValue: UseFormSetValue;
+ onClearAll: () => void;
}
-const AdminListFilters = ({
+function countActiveAdminFilters(values: Record): number {
+ let n = 0;
+ const searchVal = values.search;
+ if (typeof searchVal === "string" && searchVal.trim()) n++;
+ const statusVal = values.status;
+ if (
+ statusVal !== undefined &&
+ statusVal !== null &&
+ statusVal !== "" &&
+ String(statusVal).trim()
+ ) {
+ n++;
+ }
+ return n;
+}
+
+export default function AdminListFilters({
filterRegister,
- setIsFilterModalOpen,
isMutating,
handleFilterSubmit,
search,
watch,
setValue,
-}: AdminListFiltersProps) => {
- return (
-
+ onClearAll,
+}: AdminListFiltersProps) {
+ const watched = watch();
+ const activeCount = useMemo(
+ () => countActiveAdminFilters(watched as Record),
+ [watched],
);
-};
-export default AdminListFilters;
+ return (
+
+
+
+ );
+}
diff --git a/src/components/Tables/admins/index.tsx b/src/components/Tables/admins/index.tsx
index 9d03eae..f44c5af 100644
--- a/src/components/Tables/admins/index.tsx
+++ b/src/components/Tables/admins/index.tsx
@@ -20,7 +20,6 @@ import {
import TableSkeleton from "@/components/ui/TableSkeleton";
import { AdminStatus } from "@/constants/enums";
import { _TooltipDefaultParams } from "@/constants/tooltip";
-import { getPaginatedRowNumber } from "@/utils/shared";
import { Tooltip } from "react-tooltip";
import AdminListFilters from "./AdminListFilters";
import ChangeStatusModalContent from "./ChangeStatusModalContent";
@@ -28,8 +27,7 @@ import { useAdminsPresenter } from "./useAdminsPresenter";
const AdminsTable = () => {
const {
- allUsers,
- router,
+ usersList,
isPending,
onConfirmDelete,
isModalOpen,
@@ -37,9 +35,6 @@ const AdminsTable = () => {
isChangeStatusModalOpen,
setIsChangeStatusModalOpen,
columns,
- setCurrentUser,
- isFilterModalOpen,
- setIsFilterModalOpen,
changeStatus,
currentUser,
setStatus,
@@ -50,120 +45,136 @@ const AdminsTable = () => {
filterRegister,
isMutating,
watch,
- metadata,
- setParams,
shouldShowPagination,
+ clearAllFilters,
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
+ getRowNumber,
+ pagination,
+ handlePaginationChange,
+ navigateToEditAdmin,
+ openDeleteModalForUser,
+ openChangeStatusModalForUser,
} = useAdminsPresenter();
return (
setIsFilterModalOpen(true)}
+ hasFilter={false}
createButtonText="Create Admin"
dataCyPrefix="admins"
/>
-
-
-
- {columns.map((column, index) => (
-
- {column}
-
- ))}
-
-
- {isPending && }
-
-
- {allUsers.map((item, index) => (
-
-
- {getPaginatedRowNumber({
- index,
- page: metadata?.page,
- limit: metadata?.limit,
- })}
-
-
- {item?.full_name}
-
-
- {item?.email}
-
+
+
+
+
+
+ {columns.map((column, index) => (
+
+ {column}
+
+ ))}
+
+
-
- {item?.username}
-
-
- {item?.status}
-
+ {isPending ? (
+
+ ) : (
+
+
+ {usersList.map((item, index) => (
+
+
+ {getRowNumber(index)}
+
+
+ {item?.full_name}
+
+
+ {item?.email}
+
-
-
-
-
-
-
-
-
- ))}
-
-
-
+
+ {item?.username}
+
+
+ {item?.status}
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
setIsChangeStatusModalOpen(false)}
@@ -179,22 +190,6 @@ const AdminsTable = () => {
/>
-
setIsFilterModalOpen(false)}
- classNames="w-full xl:w-1/2"
- showButtons={false}
- >
-
-
{isModalOpen && (
{
)}
{
- setParams((currentParams) => ({
- ...currentParams,
- offset: e.selected * (metadata?.limit ?? 10),
- limit: metadata?.limit ?? 10,
- }));
- }}
+ currentPage={pagination.currentPage}
+ totalPages={pagination.totalPages}
+ onPageChange={handlePaginationChange}
/>
diff --git a/src/components/Tables/admins/useAdminsPresenter.ts b/src/components/Tables/admins/useAdminsPresenter.ts
index 1d9666f..2b83def 100644
--- a/src/components/Tables/admins/useAdminsPresenter.ts
+++ b/src/components/Tables/admins/useAdminsPresenter.ts
@@ -8,15 +8,22 @@ import {
useGetAdminListQuery,
} from "@/hooks/queries";
import { IUser, TChangeAdminStatusCredentials } from "@/lib/api";
-import { deleteEmptyKeys } from "@/utils/shared";
+import { deleteEmptyKeys, getPaginatedRowNumber } from "@/utils/shared";
+import gsap from "gsap";
import { useIsMutating } from "@tanstack/react-query";
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";
export const useAdminsPresenter = () => {
const [isChangeStatusModalOpen, setIsChangeStatusModalOpen] = useState(false);
- const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const [currentUser, setCurrentUser] = useState(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [status, setStatus] = useState(
@@ -46,8 +53,12 @@ export const useAdminsPresenter = () => {
const [params, setParams] = useState>({});
const pathName = usePathname();
+ const tableSectionRef = useRef(null);
+ const skeletonTbodyRef = useRef(null);
+ const dataTbodyRef = useRef(null);
+
useEffect(() => {
- const newParams: Record = {
+ const newParams: Record = {
...apiDefaultParams,
offset: 0,
limit: 10,
@@ -58,12 +69,112 @@ export const useAdminsPresenter = () => {
setParams(newParams);
filterFormReset(newParams);
}, [searchParams, filterFormReset]);
+
const { data, isPending } = useGetAdminListQuery({
...apiDefaultParams,
...params,
});
+
const shouldShowPagination =
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
+
+ const usersList = data?.data?.users ?? [];
+ const metadata = data?.meta;
+
+ const pagination = useMemo(
+ () => ({
+ currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
+ totalPages: data?.meta?.pages ?? 1,
+ }),
+ [data?.meta?.page, data?.meta?.pages],
+ );
+
+ const getRowNumber = useCallback(
+ (index: number) =>
+ getPaginatedRowNumber({
+ index,
+ page: data?.meta?.page,
+ limit: data?.meta?.limit,
+ }),
+ [data?.meta?.limit, data?.meta?.page],
+ );
+
+ 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 navigateToEditAdmin = useCallback(
+ (userId: string) => {
+ router.push(`/admins/edit/${userId}`);
+ },
+ [router],
+ );
+
+ const openDeleteModalForUser = useCallback((user: IUser) => {
+ setCurrentUser(user);
+ setIsModalOpen(true);
+ }, []);
+
+ const openChangeStatusModalForUser = useCallback((user: IUser) => {
+ setCurrentUser(user);
+ setIsChangeStatusModalOpen(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 { mutate: deleteAdmin } = useDeleteAdmin(() => {
setIsModalOpen(false);
});
@@ -73,6 +184,7 @@ export const useAdminsPresenter = () => {
deleteAdmin(currentUser.id);
}
};
+
const { mutate: changeStatus } = useChangeAdminStatus({
id: currentUser?.id ?? "",
successCallback: () => {
@@ -80,15 +192,17 @@ export const useAdminsPresenter = () => {
},
});
- const search = (data: any) => {
- const newParams = { ...params, ...data, offset: 0 };
+ const search = (formData: any) => {
+ const newParams = { ...params, ...formData, offset: 0 };
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = new URLSearchParams(cleanedParams).toString();
router.push(`${pathName}?${queryString}`);
- setIsFilterModalOpen(false);
};
- const allUsers = data?.data?.users ?? [];
- const metadata = data?.meta;
+
+ const clearAllFilters = useCallback(() => {
+ router.push(pathName);
+ }, [pathName, router]);
+
const columns = [
"row",
"full name",
@@ -97,8 +211,10 @@ export const useAdminsPresenter = () => {
"status",
"actions",
];
+
return {
- allUsers,
+ allUsers: usersList,
+ usersList,
setFilterValue,
metadata,
columns,
@@ -107,7 +223,6 @@ export const useAdminsPresenter = () => {
isModalOpen,
setIsModalOpen,
isMutating,
- router,
setCurrentUser,
pathName,
status,
@@ -119,13 +234,21 @@ export const useAdminsPresenter = () => {
register,
errors,
handleSubmit,
- isFilterModalOpen,
- setIsFilterModalOpen,
setParams,
filterRegister,
handleFilterSubmit,
search,
watch,
shouldShowPagination,
+ clearAllFilters,
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
+ getRowNumber,
+ pagination,
+ handlePaginationChange,
+ navigateToEditAdmin,
+ openDeleteModalForUser,
+ openChangeStatusModalForUser,
};
};
diff --git a/src/components/Tables/cms/CmsListFilters.tsx b/src/components/Tables/cms/CmsListFilters.tsx
index c3d8167..7d07afa 100644
--- a/src/components/Tables/cms/CmsListFilters.tsx
+++ b/src/components/Tables/cms/CmsListFilters.tsx
@@ -1,4 +1,8 @@
+"use client";
+
import InputGroup from "@/components/FormElements/InputGroup";
+import { Button } from "@/components/ui-elements/button";
+import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import {
FieldValues,
UseFormHandleSubmit,
@@ -6,64 +10,98 @@ import {
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
+import { useMemo } from "react";
interface CmsListFiltersProps {
filterRegister: UseFormRegister;
- setIsFilterModalOpen: (isOpen: boolean) => void;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit;
search: (data: any) => void;
watch: UseFormWatch;
setValue: UseFormSetValue;
+ onClearAll: () => void;
+}
+
+function countActive(values: Record): number {
+ let n = 0;
+ const q = values.q;
+ if (typeof q === "string" && q.trim()) n++;
+ const key = values.key;
+ if (typeof key === "string" && key.trim()) n++;
+ return n;
}
const CmsListFilters = ({
filterRegister,
- setIsFilterModalOpen,
isMutating,
handleFilterSubmit,
search,
+ watch,
+ onClearAll,
}: CmsListFiltersProps) => {
- return (
-
+ return (
+
+
+
);
};
diff --git a/src/components/Tables/cms/index.tsx b/src/components/Tables/cms/index.tsx
index a3ed922..ecb9b8f 100644
--- a/src/components/Tables/cms/index.tsx
+++ b/src/components/Tables/cms/index.tsx
@@ -3,7 +3,7 @@ import { PencilSquareIcon, TrashIcon } from "@/assets/icons";
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
import ConfirmationModal from "@/components/ui/ConfirmationModal";
import ListHeader from "@/components/ui/ListHeader";
-import Modal from "@/components/ui/modal";
+import ListWrapper from "@/components/ui/ListWrapper";
import {
Table,
TableBody,
@@ -18,7 +18,6 @@ import { getPaginatedRowNumber, unixToDate } from "@/utils/shared";
import { Tooltip } from "react-tooltip";
import CmsListFilters from "./CmsListFilters";
import useCmsTablePresenter from "./useCmsTablePresenter";
-import ListWrapper from "@/components/ui/ListWrapper";
const CmsTable = () => {
const {
@@ -32,103 +31,106 @@ const CmsTable = () => {
onConfirmDelete,
router,
pathname,
- isFilterModalOpen,
- setIsFilterModalOpen,
filterRegister,
handleFilterSubmit,
search,
watch,
setFilterValue,
isMutating,
+ clearAllFilters,
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
} = useCmsTablePresenter();
return (
- setIsFilterModalOpen(true)} />
-
-
-
- {columns.map((column) => (
-
- {column}
-
- ))}
-
-
- {isPending && }
-
-
- {data?.data.cms.map((item, index) => (
-
+
+
+
+
+
+ {columns.map((column) => (
+
+ {column}
+
+ ))}
+
+
+ {isPending ? (
+
+ ) : (
+
+
-
- {getPaginatedRowNumber({
- index,
- page: data?.meta?.page,
- limit: data?.meta?.limit,
- })}
-
-
- {item.key}
-
-
- {unixToDate({ unix: item.created_at, hasTime: true })}
-
-
-
-
-
-
-
-
- ))}
-
-
-
-
setIsFilterModalOpen(false)}
- classNames="w-full xl:w-1/2"
- showButtons={false}
- >
-
-
+ {data?.data.cms.map((item, index) => (
+
+
+ {getPaginatedRowNumber({
+ index,
+ page: data?.meta?.page,
+ limit: data?.meta?.limit,
+ })}
+
+
+ {item.key}
+
+
+ {unixToDate({ unix: item.created_at, hasTime: true })}
+
+
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
{isModalOpen && (
{
const [isModalOpen, setIsModalOpen] = useState(false);
- const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const [currentCms, setCurrentCms] = useState(null);
const [params, setParams] = useState>({});
const columns = ["row", "key", "created at", "actions"];
@@ -26,6 +26,10 @@ const useCmsTablePresenter = () => {
setValue: setFilterValue,
} = useForm();
+ const tableSectionRef = useRef(null);
+ const skeletonTbodyRef = useRef(null);
+ const dataTbodyRef = useRef(null);
+
useEffect(() => {
const newParams: Record = {};
for (const [key, value] of searchParams.entries()) {
@@ -37,19 +41,28 @@ const useCmsTablePresenter = () => {
const { isPending, data } = useGetCmsList(params);
+ useTableSectionRowAnimations(isPending, data, {
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
+ });
+
const { mutate: deleteCms } = useDeleteCms(() => {
setIsModalOpen(false);
setCurrentCms(null);
});
- const search = (data: any) => {
- const newParams = { ...params, ...data };
+ const search = (formData: any) => {
+ const newParams = { ...params, ...formData };
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = new URLSearchParams(cleanedParams).toString();
router.push(`${pathname}?${queryString}`);
- setIsFilterModalOpen(false);
};
+ const clearAllFilters = useCallback(() => {
+ router.push(pathname);
+ }, [pathname, router]);
+
const onConfirmDelete = () => {
if (!currentCms?.id) return;
deleteCms(currentCms.id);
@@ -65,14 +78,16 @@ const useCmsTablePresenter = () => {
onConfirmDelete,
router,
pathname,
- isFilterModalOpen,
- setIsFilterModalOpen,
filterRegister,
handleFilterSubmit,
search,
watch,
setFilterValue,
isMutating,
+ clearAllFilters,
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
};
};
diff --git a/src/components/Tables/companies/CompanyListFilters.tsx b/src/components/Tables/companies/CompanyListFilters.tsx
index 0b270f8..90e0259 100644
--- a/src/components/Tables/companies/CompanyListFilters.tsx
+++ b/src/components/Tables/companies/CompanyListFilters.tsx
@@ -1,5 +1,9 @@
+"use client";
+
import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select";
+import { Button } from "@/components/ui-elements/button";
+import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import { Currencies } from "@/constants/enums";
import {
FieldValues,
@@ -8,90 +12,130 @@ import {
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
+import { useMemo } from "react";
interface CompanyListFiltersProps {
filterRegister: UseFormRegister;
- setIsFilterModalOpen: (isOpen: boolean) => void;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit;
search: (data: any) => void;
watch: UseFormWatch;
setValue: UseFormSetValue;
+ onClearAll: () => void;
+}
+
+function countActive(values: Record): number {
+ let n = 0;
+ const keys = ["name", "email", "phone"] as const;
+ for (const k of keys) {
+ const v = values[k];
+ if (typeof v === "string" && v.trim()) n++;
+ }
+ const cur = values.currency;
+ if (cur !== undefined && cur !== null && cur !== "" && String(cur).trim()) {
+ n++;
+ }
+ const ec = values.employee_count;
+ if (ec !== undefined && ec !== null && ec !== "" && String(ec).trim()) {
+ n++;
+ }
+ return n;
}
const CompanyListFilters = ({
filterRegister,
- setIsFilterModalOpen,
isMutating,
handleFilterSubmit,
search,
watch,
setValue,
+ onClearAll,
}: CompanyListFiltersProps) => {
- return (
-
+ return (
+
+
+
);
};
diff --git a/src/components/Tables/companies/company-categories/CompanyCategoryListFilters.tsx b/src/components/Tables/companies/company-categories/CompanyCategoryListFilters.tsx
index 93f6176..db0887d 100644
--- a/src/components/Tables/companies/company-categories/CompanyCategoryListFilters.tsx
+++ b/src/components/Tables/companies/company-categories/CompanyCategoryListFilters.tsx
@@ -1,5 +1,9 @@
+"use client";
+
import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select";
+import { Button } from "@/components/ui-elements/button";
+import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import {
FieldValues,
UseFormHandleSubmit,
@@ -7,77 +11,107 @@ import {
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
+import { useMemo } from "react";
interface CompanyCategoryListFiltersProps {
filterRegister: UseFormRegister;
- setIsFilterModalOpen: (isOpen: boolean) => void;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit;
search: (data: any) => void;
watch: UseFormWatch;
setValue: UseFormSetValue;
+ onClearAll: () => void;
+}
+
+function countActive(values: Record): number {
+ let n = 0;
+ const q = values.q;
+ if (typeof q === "string" && q.trim()) n++;
+ const pub = values.published;
+ if (pub !== undefined && pub !== null && pub !== "" && String(pub).trim()) {
+ n++;
+ }
+ return n;
}
const CompanyCategoryListFilters = ({
filterRegister,
- setIsFilterModalOpen,
isMutating,
handleFilterSubmit,
search,
watch,
setValue,
+ onClearAll,
}: CompanyCategoryListFiltersProps) => {
- return (
-
+ return (
+
+
+
);
};
diff --git a/src/components/Tables/companies/company-categories/index.tsx b/src/components/Tables/companies/company-categories/index.tsx
index fa3ef3e..f51aca5 100644
--- a/src/components/Tables/companies/company-categories/index.tsx
+++ b/src/components/Tables/companies/company-categories/index.tsx
@@ -6,7 +6,7 @@ import ConfirmationModal from "@/components/ui/ConfirmationModal";
import IsVisible from "@/components/ui/IsVisible";
import ListHeader from "@/components/ui/ListHeader";
import ListWrapper from "@/components/ui/ListWrapper";
-import Modal from "@/components/ui/modal";
+import Pagination from "@/components/ui/pagination";
import {
Table,
TableBody,
@@ -16,7 +16,6 @@ import {
TableRow,
} from "@/components/ui/table";
import TableSkeleton from "@/components/ui/TableSkeleton";
-import Pagination from "@/components/ui/pagination";
import { _TooltipDefaultParams } from "@/constants/tooltip";
import { getPaginatedRowNumber, unixToDate } from "@/utils/shared";
import { startTransition } from "react";
@@ -37,8 +36,6 @@ const CompanyCategoriesTable = () => {
setCurrentCategory,
isModalOpen,
setIsModalOpen,
- isFilterModalOpen,
- setIsFilterModalOpen,
filterRegister,
handleFilterSubmit,
search,
@@ -47,133 +44,135 @@ const CompanyCategoriesTable = () => {
columns,
isMutating,
metadata,
- setParams,
shouldShowPagination,
+ clearAllFilters,
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
+ pagination,
+ handlePaginationChange,
} = useCompanyCategoriesPresenter();
return (
setIsFilterModalOpen(true)}
+ hasFilter={false}
createButtonText="Create Category"
dataCyPrefix="company-categories"
/>
-
-
-
- Row
- Title
- Description
- Created at
- Published
- Actions
-
-
- {isLoading && }
-
-
- {optimisticCategories.map((category, index) => (
-
+
+
+
+
+ Row
+ Title
+ Description
+ Created at
+ Published
+ Actions
+
+
+ {isLoading ? (
+
+ ) : (
+
+
-
- {getPaginatedRowNumber({
- index,
- page: metadata?.page,
- limit: metadata?.limit,
- })}
-
- {category.name}
- {category.description}
-
- {unixToDate({ unix: category.created_at, hasTime: true })}
-
-
- {/* Published: flip UI immediately via useOptimistic; if togglePublished rejects, React rolls the optimistic row back. */}
- {}}
- onChange={() => {
- const nextPublished = !category.published;
- startTransition(async () => {
- addOptimisticPublished({
- id: category.id,
- published: nextPublished,
- });
- try {
- await togglePublished(category.id);
- } catch {
- // useOptimistic automatically reverts when the transition ends
- }
- });
- }}
- checked={category.published}
- />
-
-
-
-
-
-
-
-
- ))}
-
-
-
-
setIsFilterModalOpen(false)}
- classNames="w-full xl:w-1/2"
- showButtons={false}
- >
-
-
+ {optimisticCategories.map((category, index) => (
+
+
+ {getPaginatedRowNumber({
+ index,
+ page: metadata?.page,
+ limit: metadata?.limit,
+ })}
+
+ {category.name}
+ {category.description}
+
+ {unixToDate({ unix: category.created_at, hasTime: true })}
+
+
+ {}}
+ onChange={() => {
+ const nextPublished = !category.published;
+ startTransition(async () => {
+ addOptimisticPublished({
+ id: category.id,
+ published: nextPublished,
+ });
+ try {
+ await togglePublished(category.id);
+ } catch {
+ // useOptimistic automatically reverts when the transition ends
+ }
+ });
+ }}
+ checked={category.published}
+ />
+
+
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
{isModalOpen && (
{
)}
{
- setParams((currentParams) => ({
- ...currentParams,
- offset: e.selected * (metadata?.limit ?? 10),
- limit: metadata?.limit ?? 10,
- }));
- }}
+ currentPage={pagination.currentPage}
+ totalPages={pagination.totalPages}
+ onPageChange={handlePaginationChange}
/>
diff --git a/src/components/Tables/companies/company-categories/useCompanyCategoriesPresenter.ts b/src/components/Tables/companies/company-categories/useCompanyCategoriesPresenter.ts
index 5c0b085..972587c 100644
--- a/src/components/Tables/companies/company-categories/useCompanyCategoriesPresenter.ts
+++ b/src/components/Tables/companies/company-categories/useCompanyCategoriesPresenter.ts
@@ -5,18 +5,18 @@ import {
useDeleteCompanyCategoryQuery,
useToggleCompanyCategoryPublished,
} from "@/hooks/queries";
+import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { TCompanyCategory } from "@/lib/api";
import { deleteEmptyKeys } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
-import { useEffect, useOptimistic, useState } from "react";
+import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState } from "react";
import { useForm } from "react-hook-form";
type TogglePublishedOptimistic = Pick;
const useCompanyCategoriesPresenter = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
- const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const [currentCategory, setCurrentCategory] = useState();
const [params, setParams] = useState>({});
const router = useRouter();
@@ -32,6 +32,10 @@ const useCompanyCategoriesPresenter = () => {
setValue: setFilterValue,
} = useForm();
+ const tableSectionRef = useRef(null);
+ const skeletonTbodyRef = useRef(null);
+ const dataTbodyRef = useRef(null);
+
useEffect(() => {
const newParams: Record = {
...apiDefaultParams,
@@ -46,11 +50,39 @@ const useCompanyCategoriesPresenter = () => {
}, [searchParams, filterFormReset]);
const { data, isLoading, isError } = useCompanyCategoriesQuery(params);
+
+ useTableSectionRowAnimations(isLoading, data, {
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
+ });
+
const categories = data?.data.categories;
const metadata = data?.meta;
const shouldShowPagination =
!isLoading &&
(data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
+
+ const pagination = useMemo(
+ () => ({
+ currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
+ totalPages: data?.meta?.pages ?? 1,
+ }),
+ [data?.meta?.page, data?.meta?.pages],
+ );
+
+ const handlePaginationChange = useCallback(
+ (e: { selected: number }) => {
+ const limit = data?.meta?.limit ?? 10;
+ setParams((currentParams) => ({
+ ...currentParams,
+ offset: e.selected * limit,
+ limit,
+ }));
+ },
+ [data?.meta?.limit],
+ );
+
const [optimisticCategories, addOptimisticPublished] = useOptimistic<
TCompanyCategory[],
TogglePublishedOptimistic
@@ -63,14 +95,17 @@ const useCompanyCategoriesPresenter = () => {
const { mutateAsync: togglePublished } =
useToggleCompanyCategoryPublished();
- const search = (data: any) => {
- const newParams = { ...params, ...data, offset: 0 };
+ const search = (formData: any) => {
+ const newParams = { ...params, ...formData, offset: 0 };
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = new URLSearchParams(cleanedParams).toString();
router.push(`${pathName}?${queryString}`);
- setIsFilterModalOpen(false);
};
+ const clearAllFilters = useCallback(() => {
+ router.push(pathName);
+ }, [pathName, router]);
+
const onConfirmDelete = () => {
if (!currentCategory) return;
deleteCategory(currentCategory?.id);
@@ -100,8 +135,6 @@ const useCompanyCategoriesPresenter = () => {
togglePublished,
setCurrentCategory,
onConfirmDelete,
- isFilterModalOpen,
- setIsFilterModalOpen,
filterRegister,
handleFilterSubmit,
search,
@@ -110,6 +143,12 @@ const useCompanyCategoriesPresenter = () => {
isMutating,
metadata,
shouldShowPagination,
+ clearAllFilters,
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
+ pagination,
+ handlePaginationChange,
};
};
diff --git a/src/components/Tables/companies/index.tsx b/src/components/Tables/companies/index.tsx
index b8d93f1..f25678b 100644
--- a/src/components/Tables/companies/index.tsx
+++ b/src/components/Tables/companies/index.tsx
@@ -10,7 +10,6 @@ import ConfirmationModal from "@/components/ui/ConfirmationModal";
import IsVisible from "@/components/ui/IsVisible";
import ListHeader from "@/components/ui/ListHeader";
import ListWrapper from "@/components/ui/ListWrapper";
-import Modal from "@/components/ui/modal";
import Pagination from "@/components/ui/pagination";
import {
Table,
@@ -39,8 +38,6 @@ const CompaniesTable = () => {
setCurrentCompany,
onConfirmDelete,
modalConfig,
- isFilterModalOpen,
- setIsFilterModalOpen,
filterRegister,
handleFilterSubmit,
search,
@@ -48,151 +45,151 @@ const CompaniesTable = () => {
setFilterValue,
isMutating,
metadata,
- setParams,
shouldShowPagination,
+ clearAllFilters,
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
+ pagination,
+ handlePaginationChange,
} = useCompanyListPresenter();
const companies = allCompanies.filter(
(c): c is NonNullable => c != null,
);
return (
- setIsFilterModalOpen(true)}
- createButtonText="Create Company"
+
+
-
-
-
- {columns.map((columns) => (
-
+
+
+
+ {columns.map((col) => (
+
+ {col}
+
+ ))}
+
+
+ {isPending ? (
+
+ ) : (
+
+
- {columns}
-
- ))}
-
-
- {isPending && }
-
-
- {companies.map((company, index) => (
-
-
- {getPaginatedRowNumber({
- index,
- page: metadata?.page,
- limit: metadata?.limit,
- })}
-
-
- {company.name}
-
-
- {company.email}
-
-
- {formatPhoneNumber(company.phone)}
-
-
- {company.address.country}
-
-
- {company.address.state}
-
-
- {company.language}
-
-
- {company.currency}
-
-
- {company.employee_count}
-
-
-
-
-
-
-
-
-
-
- ))}
-
-
-
- setIsFilterModalOpen(false)}
- classNames="w-full xl:w-1/2"
- showButtons={false}
- >
-
-
+ {companies.map((company, index) => (
+
+
+ {getPaginatedRowNumber({
+ index,
+ page: metadata?.page,
+ limit: metadata?.limit,
+ })}
+
+
+ {company.name}
+
+
+ {company.email}
+
+
+ {formatPhoneNumber(company.phone)}
+
+
+ {company.address.country}
+
+
+ {company.address.state}
+
+
+ {company.language}
+
+
+ {company.currency}
+
+
+ {company.employee_count}
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
{isModalOpen && (
{
)}
{
- setParams((currentParams) => ({
- ...currentParams,
- offset: e.selected * (metadata?.limit ?? 10),
- limit: metadata?.limit ?? 10,
- }));
- }}
+ currentPage={pagination.currentPage}
+ totalPages={pagination.totalPages}
+ onPageChange={handlePaginationChange}
/>
diff --git a/src/components/Tables/companies/useCompanyListPresenter.ts b/src/components/Tables/companies/useCompanyListPresenter.ts
index 1877326..b63da18 100644
--- a/src/components/Tables/companies/useCompanyListPresenter.ts
+++ b/src/components/Tables/companies/useCompanyListPresenter.ts
@@ -5,16 +5,16 @@ import {
useCompaniesQuery,
useDeleteCompanyQuery,
} from "@/hooks/queries";
+import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { TCompany } from "@/lib/api";
import { deleteEmptyKeys } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
export const useCompanyListPresenter = () => {
const [currentCompany, setCurrentCompany] = useState(null);
- const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const [modalConfig, setModalConfig] = useState<
Partial
>({});
@@ -33,6 +33,10 @@ export const useCompanyListPresenter = () => {
setValue: setFilterValue,
} = useForm();
+ const tableSectionRef = useRef(null);
+ const skeletonTbodyRef = useRef(null);
+ const dataTbodyRef = useRef(null);
+
const columns = [
"row",
"name",
@@ -61,6 +65,12 @@ export const useCompanyListPresenter = () => {
const { data, error, isPending, isError } = useCompaniesQuery(params);
+ useTableSectionRowAnimations(isPending, data, {
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
+ });
+
const shouldShowPagination =
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
@@ -68,13 +78,37 @@ export const useCompanyListPresenter = () => {
setIsModalOpen(false),
);
- const search = (data: any) => {
- const newParams = { ...params, ...data, offset: 0 };
+ const search = (formData: any) => {
+ const newParams = { ...params, ...formData, offset: 0 };
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = new URLSearchParams(cleanedParams).toString();
router.push(`${pathName}?${queryString}`);
- setIsFilterModalOpen(false);
};
+
+ const clearAllFilters = useCallback(() => {
+ router.push(pathName);
+ }, [pathName, router]);
+
+ const pagination = useMemo(
+ () => ({
+ currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
+ totalPages: data?.meta?.pages ?? 1,
+ }),
+ [data?.meta?.page, data?.meta?.pages],
+ );
+
+ const handlePaginationChange = useCallback(
+ (e: { selected: number }) => {
+ const limit = data?.meta?.limit ?? 10;
+ setParams((currentParams) => ({
+ ...currentParams,
+ offset: e.selected * limit,
+ limit,
+ }));
+ },
+ [data?.meta?.limit],
+ );
+
const onConfirmDelete = () => {
if (currentCompany) {
deleteCompany(currentCompany.id);
@@ -98,8 +132,6 @@ export const useCompanyListPresenter = () => {
router,
isPending,
isError,
- isFilterModalOpen,
- setIsFilterModalOpen,
filterRegister,
handleFilterSubmit,
search,
@@ -108,5 +140,11 @@ export const useCompanyListPresenter = () => {
isMutating,
setParams,
shouldShowPagination,
+ clearAllFilters,
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
+ pagination,
+ handlePaginationChange,
};
};
diff --git a/src/components/Tables/contact-us/ContactUsListFilters.tsx b/src/components/Tables/contact-us/ContactUsListFilters.tsx
new file mode 100644
index 0000000..64c838c
--- /dev/null
+++ b/src/components/Tables/contact-us/ContactUsListFilters.tsx
@@ -0,0 +1,113 @@
+"use client";
+
+import InputGroup from "@/components/FormElements/InputGroup";
+import { Button } from "@/components/ui-elements/button";
+import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
+import {
+ FieldValues,
+ UseFormHandleSubmit,
+ UseFormRegister,
+ UseFormSetValue,
+ UseFormWatch,
+} from "react-hook-form";
+import { useMemo } from "react";
+
+interface ContactUsListFiltersProps {
+ filterRegister: UseFormRegister;
+ isMutating: number;
+ handleFilterSubmit: UseFormHandleSubmit;
+ search: (data: any) => void;
+ watch: UseFormWatch;
+ setValue: UseFormSetValue;
+ onClearAll: () => void;
+}
+
+function countActive(values: Record): number {
+ let n = 0;
+ const q = values.q;
+ if (typeof q === "string" && q.trim()) n++;
+ const status = values.status;
+ if (
+ status !== undefined &&
+ status !== null &&
+ status !== "" &&
+ String(status).trim()
+ ) {
+ n++;
+ }
+ return n;
+}
+
+export default function ContactUsListFilters({
+ filterRegister,
+ isMutating,
+ handleFilterSubmit,
+ search,
+ watch,
+ onClearAll,
+}: ContactUsListFiltersProps) {
+ const watched = watch();
+ const activeCount = useMemo(
+ () => countActive(watched as Record),
+ [watched],
+ );
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/Tables/contact-us/index.tsx b/src/components/Tables/contact-us/index.tsx
index 2c6612c..0cbb593 100644
--- a/src/components/Tables/contact-us/index.tsx
+++ b/src/components/Tables/contact-us/index.tsx
@@ -3,6 +3,7 @@ import { TrashIcon } from "@/assets/icons";
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
import ConfirmationModal from "@/components/ui/ConfirmationModal";
import IsVisible from "@/components/ui/IsVisible";
+import ListHeader from "@/components/ui/ListHeader";
import ListWrapper from "@/components/ui/ListWrapper";
import Pagination from "@/components/ui/pagination";
import {
@@ -17,6 +18,7 @@ import TableSkeleton from "@/components/ui/TableSkeleton";
import { _TooltipDefaultParams } from "@/constants/tooltip";
import { getPaginatedRowNumber, unixToDate } from "@/utils/shared";
import { Tooltip } from "react-tooltip";
+import ContactUsListFilters from "./ContactUsListFilters";
import { useContactUsListPresenter } from "./useContactUsListPresenter";
const ContactUsTable = () => {
@@ -30,83 +32,109 @@ const ContactUsTable = () => {
isModalOpen,
currentContact,
metadata,
- setParams,
shouldShowPagination,
+ filterRegister,
+ handleFilterSubmit,
+ search,
+ watch,
+ setFilterValue,
+ isMutating,
+ clearAllFilters,
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
+ pagination,
+ handlePaginationChange,
} = useContactUsListPresenter();
return (
-
-
-
- {columns.map((columns) => (
-
+
+
+
+
+
+ {columns.map((col) => (
+
+ {col}
+
+ ))}
+
+
+ {isPending ? (
+
+ ) : (
+
+
- {columns}
-
- ))}
-
-
- {isPending && }
-
-
- {contacts?.map((item, index) => (
-
-
- {getPaginatedRowNumber({
- index,
- page: metadata?.page,
- limit: metadata?.limit,
- })}
-
-
- {item?.full_name}
-
-
- {item?.email}
-
-
- {item?.status}
-
-
- {item?.phone}
-
-
- {unixToDate({ unix: item?.created_at })}
-
-
-
-
-
-
-
- ))}
-
-
-
+ {contacts?.map((item, index) => (
+
+
+ {getPaginatedRowNumber({
+ index,
+ page: metadata?.page,
+ limit: metadata?.limit,
+ })}
+
+
+ {item?.full_name}
+
+
+ {item?.email}
+
+
+ {item?.status}
+
+
+ {item?.phone}
+
+
+ {unixToDate({ unix: item?.created_at })}
+
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
{isModalOpen && (
{
)}
{
- setParams((currentParams) => ({
- ...currentParams,
- offset: e.selected * (metadata?.limit ?? 10),
- limit: metadata?.limit ?? 10,
- }));
- }}
+ currentPage={pagination.currentPage}
+ totalPages={pagination.totalPages}
+ onPageChange={handlePaginationChange}
/>
diff --git a/src/components/Tables/contact-us/useContactUsListPresenter.ts b/src/components/Tables/contact-us/useContactUsListPresenter.ts
index c373f5e..a6c2f16 100644
--- a/src/components/Tables/contact-us/useContactUsListPresenter.ts
+++ b/src/components/Tables/contact-us/useContactUsListPresenter.ts
@@ -4,9 +4,13 @@ import {
useDeleteContactUs,
useReadAllContactUs,
} from "@/hooks/queries/useContactUsQuery";
+import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { TContact } from "@/lib/api/types/TContacts";
+import { deleteEmptyKeys } from "@/utils/shared";
+import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useForm } from "react-hook-form";
export const useContactUsListPresenter = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -24,6 +28,19 @@ export const useContactUsListPresenter = () => {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
+ const isMutating = useIsMutating();
+
+ const {
+ register: filterRegister,
+ handleSubmit: handleFilterSubmit,
+ reset: filterFormReset,
+ watch,
+ setValue: setFilterValue,
+ } = useForm();
+
+ const tableSectionRef = useRef(null);
+ const skeletonTbodyRef = useRef(null);
+ const dataTbodyRef = useRef(null);
useEffect(() => {
const newParams: Record = {
@@ -35,9 +52,17 @@ export const useContactUsListPresenter = () => {
newParams[key] = value;
}
setParams(newParams);
- }, [searchParams]);
+ filterFormReset(newParams as any);
+ }, [searchParams, filterFormReset]);
const { data, isPending } = useReadAllContactUs(params);
+
+ useTableSectionRowAnimations(isPending, data, {
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
+ });
+
const { mutate: deleteContactUS } = useDeleteContactUs(() =>
setIsModalOpen(false),
);
@@ -45,6 +70,39 @@ export const useContactUsListPresenter = () => {
const shouldShowPagination =
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
+ const pagination = useMemo(
+ () => ({
+ currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
+ totalPages: data?.meta?.pages ?? 1,
+ }),
+ [data?.meta?.page, data?.meta?.pages],
+ );
+
+ const handlePaginationChange = useCallback(
+ (e: { selected: number }) => {
+ const limit = data?.meta?.limit ?? 10;
+ setParams((currentParams) => ({
+ ...currentParams,
+ offset: e.selected * limit,
+ limit,
+ }));
+ },
+ [data?.meta?.limit],
+ );
+
+ const search = (formData: any) => {
+ const newParams = { ...params, ...formData, offset: 0 };
+ const cleanedParams = deleteEmptyKeys(newParams);
+ const queryString = new URLSearchParams(
+ cleanedParams as Record,
+ ).toString();
+ router.push(`${pathname}?${queryString}`);
+ };
+
+ const clearAllFilters = useCallback(() => {
+ router.push(pathname);
+ }, [pathname, router]);
+
const onConfirmDelete = () => {
if (!currentContact) return;
deleteContactUS(currentContact?.id);
@@ -63,5 +121,17 @@ export const useContactUsListPresenter = () => {
setCurrentContact,
isModalOpen,
currentContact,
+ filterRegister,
+ handleFilterSubmit,
+ search,
+ watch,
+ setFilterValue,
+ isMutating,
+ clearAllFilters,
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
+ pagination,
+ handlePaginationChange,
};
};
diff --git a/src/components/Tables/customers/CustomerListFilters.tsx b/src/components/Tables/customers/CustomerListFilters.tsx
index 1f75d5b..9cc5624 100644
--- a/src/components/Tables/customers/CustomerListFilters.tsx
+++ b/src/components/Tables/customers/CustomerListFilters.tsx
@@ -1,4 +1,8 @@
+"use client";
+
import { Select } from "@/components/FormElements/select";
+import { Button } from "@/components/ui-elements/button";
+import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import { AdminRoles, CustomerStatus, CustomerType } from "@/constants/enums";
import { getEnumAsArray } from "@/utils/shared";
import {
@@ -8,82 +12,113 @@ import {
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
+import { useMemo } from "react";
interface CustomerListFiltersProps {
filterRegister: UseFormRegister;
- setIsFilterModalOpen: (isOpen: boolean) => void;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit;
search: (data: any) => void;
watch: UseFormWatch;
setValue: UseFormSetValue;
+ onClearAll: () => void;
+}
+
+function countActive(values: Record): number {
+ let n = 0;
+ for (const key of ["status", "role", "type"] as const) {
+ const v = values[key];
+ if (v !== undefined && v !== null && v !== "" && String(v).trim()) n++;
+ }
+ return n;
}
const CustomerListFilters = ({
filterRegister,
- setIsFilterModalOpen,
isMutating,
handleFilterSubmit,
search,
watch,
setValue,
+ onClearAll,
}: CustomerListFiltersProps) => {
- return (
-
+ return (
+
+
+
);
};
diff --git a/src/components/Tables/customers/index.tsx b/src/components/Tables/customers/index.tsx
index bc14beb..edf4493 100644
--- a/src/components/Tables/customers/index.tsx
+++ b/src/components/Tables/customers/index.tsx
@@ -1,10 +1,11 @@
"use client";
import { ExclamationIcon, PencilSquareIcon, TrashIcon } from "@/assets/icons";
-import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
import { Building } from "@/components/Layouts/sidebar/icons";
+import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
import ConfirmationModal from "@/components/ui/ConfirmationModal";
import IsVisible from "@/components/ui/IsVisible";
import ListHeader from "@/components/ui/ListHeader";
+import ListWrapper from "@/components/ui/ListWrapper";
import Modal from "@/components/ui/modal";
import Pagination from "@/components/ui/pagination";
import Status from "@/components/ui/Status";
@@ -24,7 +25,6 @@ import { Tooltip } from "react-tooltip";
import AssignToCompanyModalContent from "./AssignToCompanyModalContent";
import CustomerListFilters from "./CustomerListFilters";
import useCustomerListPresenter from "./useCustomerListPresenter";
-import ListWrapper from "@/components/ui/ListWrapper";
const CustomersTable = () => {
const {
@@ -44,179 +44,171 @@ const CustomersTable = () => {
setCurrentCustomer,
isAssignCompanyModalOpen,
setIsAssignCompanyModalOpen,
- isFilterModalOpen,
- setIsFilterModalOpen,
filterRegister,
handleFilterSubmit,
search,
watch,
setFilterValue,
isMutating,
- setParams,
assignCompanyCompaniesQuery,
shouldShowPagination,
+ clearAllFilters,
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
+ pagination,
+ handlePaginationChange,
} = useCustomerListPresenter();
return (
- setIsFilterModalOpen(true)}
- createButtonText="Create Customer"
+
+
-
-
-
- {columns.map((columns) => (
-
+
+
+
+ {columns.map((col) => (
+
+ {col}
+
+ ))}
+
+
+ {isPending ? (
+
+ ) : (
+
+
- {columns}
-
- ))}
-
-
- {isPending && }
-
-
- {allCustomers?.map((customer, index) => (
-
-
- {getPaginatedRowNumber({
- index,
- page: metadata?.page,
- limit: metadata?.limit,
- })}
-
- {customer?.full_name}
- {customer?.email}
-
- {unixToDate({ unix: customer?.created_at, hasTime: true })}
-
+ {allCustomers?.map((customer, index) => (
+
+
+ {getPaginatedRowNumber({
+ index,
+ page: metadata?.page,
+ limit: metadata?.limit,
+ })}
+
+ {customer?.full_name}
+ {customer?.email}
+
+ {unixToDate({ unix: customer?.created_at, hasTime: true })}
+
-
- {customer?.type}
-
-
- {customer?.status}
-
-
- {customer?.companies?.length ? (
- customer?.companies.map((c) => (
- {c.name},
- ))
- ) : (
- None
- )}
-
-
- {customer?.role ?? "-"}
-
-
-
-
-
-
-
-
-
-
- ))}
-
-
-
+
+ {customer?.type}
+
+
+ {customer?.status}
+
+
+ {customer?.companies?.length ? (
+ customer?.companies.map((c) => (
+ {c.name},
+ ))
+ ) : (
+ None
+ )}
+
+
+ {customer?.role ?? "-"}
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
{
- setParams((currentParams) => ({
- ...currentParams,
- offset: e.selected * (metadata?.limit ?? 10),
- limit: metadata?.limit ?? 10,
- }));
- }}
+ currentPage={pagination.currentPage}
+ totalPages={pagination.totalPages}
+ onPageChange={handlePaginationChange}
/>
-
setIsFilterModalOpen(false)}
- classNames="w-full xl:w-1/2"
- showButtons={false}
- >
-
-
{
if (!selectedCompanies || !currentCustomer) {
toast.error("Invalid credentials");
diff --git a/src/components/Tables/customers/useCustomerListPresenter.ts b/src/components/Tables/customers/useCustomerListPresenter.ts
index 44ca45d..a0a2d18 100644
--- a/src/components/Tables/customers/useCustomerListPresenter.ts
+++ b/src/components/Tables/customers/useCustomerListPresenter.ts
@@ -6,18 +6,18 @@ import {
useCustomersQuery,
useDeleteCustomerQuery,
} from "@/hooks/queries";
+import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { TAssignCustomerToCompanyCredentials, TCustomer } from "@/lib/api";
import { apiDefaultParams } from "@/constants/Api_Params";
import { deleteEmptyKeys } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
-import { usePathname, useRouter } from "next/navigation";
-import { useState } from "react";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
const useCustomerListPresenter = () => {
const [isAssignCompanyModalOpen, setIsAssignCompanyModalOpen] =
useState(false);
- const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const [selectedCompanies, setSelectedCompanies] = useState<
TAssignCustomerToCompanyCredentials | undefined
>();
@@ -27,6 +27,7 @@ const useCustomerListPresenter = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const pathName = usePathname();
+ const searchParams = useSearchParams();
const [params, setParams] = useState>({
...apiDefaultParams,
});
@@ -41,7 +42,31 @@ const useCustomerListPresenter = () => {
setValue: setFilterValue,
} = useForm();
+ const tableSectionRef = useRef(null);
+ const skeletonTbodyRef = useRef(null);
+ const dataTbodyRef = useRef(null);
+
+ useEffect(() => {
+ const newParams: Record = {
+ ...apiDefaultParams,
+ offset: 0,
+ limit: 10,
+ };
+ for (const [key, value] of searchParams.entries()) {
+ newParams[key] = value;
+ }
+ setParams(newParams);
+ filterFormReset(newParams);
+ }, [searchParams, filterFormReset]);
+
const { data, isPending } = useCustomersQuery(params);
+
+ useTableSectionRowAnimations(isPending, data, {
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
+ });
+
const assignCompanyCompaniesQuery = useCompanyFullList({
enabled: isAssignCompanyModalOpen,
});
@@ -55,15 +80,17 @@ const useCustomerListPresenter = () => {
},
);
- const search = (data: any) => {
- const newParams = { ...params, ...data, offset: 0 };
+ const search = (formData: any) => {
+ const newParams = { ...params, ...formData, offset: 0 };
const cleanedParams = deleteEmptyKeys(newParams);
- setParams(cleanedParams);
const queryString = new URLSearchParams(cleanedParams).toString();
router.push(`${pathName}?${queryString}`);
- setIsFilterModalOpen(false);
};
+ const clearAllFilters = useCallback(() => {
+ router.push(pathName);
+ }, [pathName, router]);
+
const columns = [
"row",
"full name",
@@ -85,6 +112,26 @@ const useCustomerListPresenter = () => {
const shouldShowPagination =
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
+ const pagination = useMemo(
+ () => ({
+ currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
+ totalPages: data?.meta?.pages ?? 1,
+ }),
+ [data?.meta?.page, data?.meta?.pages],
+ );
+
+ const handlePaginationChange = useCallback(
+ (e: { selected: number }) => {
+ const limit = data?.meta?.limit ?? 10;
+ setParams((currentParams) => ({
+ ...currentParams,
+ offset: e.selected * limit,
+ limit,
+ }));
+ },
+ [data?.meta?.limit],
+ );
+
return {
allCustomers: customers,
metadata: data?.meta,
@@ -102,8 +149,6 @@ const useCustomerListPresenter = () => {
router,
isModalOpen,
setIsModalOpen,
- isFilterModalOpen,
- setIsFilterModalOpen,
filterRegister,
handleFilterSubmit,
search,
@@ -113,6 +158,12 @@ const useCustomerListPresenter = () => {
setParams,
assignCompanyCompaniesQuery,
shouldShowPagination,
+ clearAllFilters,
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
+ pagination,
+ handlePaginationChange,
};
};
diff --git a/src/components/Tables/feedback-table/FeedbackListFilters.tsx b/src/components/Tables/feedback-table/FeedbackListFilters.tsx
new file mode 100644
index 0000000..ef6f5e2
--- /dev/null
+++ b/src/components/Tables/feedback-table/FeedbackListFilters.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import InputGroup from "@/components/FormElements/InputGroup";
+import { Button } from "@/components/ui-elements/button";
+import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
+import {
+ FieldValues,
+ UseFormHandleSubmit,
+ UseFormRegister,
+ UseFormWatch,
+} from "react-hook-form";
+import { useMemo } from "react";
+
+interface FeedbackListFiltersProps {
+ filterRegister: UseFormRegister;
+ isMutating: number;
+ handleFilterSubmit: UseFormHandleSubmit;
+ search: (data: any) => void;
+ watch: UseFormWatch;
+ onClearAll: () => void;
+}
+
+function countActive(values: Record): number {
+ const q = values.q;
+ return typeof q === "string" && q.trim() ? 1 : 0;
+}
+
+export default function FeedbackListFilters({
+ filterRegister,
+ isMutating,
+ handleFilterSubmit,
+ search,
+ watch,
+ onClearAll,
+}: FeedbackListFiltersProps) {
+ const watched = watch();
+ const activeCount = useMemo(
+ () => countActive(watched as Record),
+ [watched],
+ );
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/Tables/feedback-table/index.tsx b/src/components/Tables/feedback-table/index.tsx
index e076a10..b21b70e 100644
--- a/src/components/Tables/feedback-table/index.tsx
+++ b/src/components/Tables/feedback-table/index.tsx
@@ -15,6 +15,7 @@ import {
import TableSkeleton from "@/components/ui/TableSkeleton";
import { cn } from "@/lib/utils";
import { capitalize, getPaginatedRowNumber, unixToDate } from "@/utils/shared";
+import FeedbackListFilters from "./FeedbackListFilters";
import useFeedbackTablePresenter from "./useFeedbackTablePresenter";
interface IProps {
@@ -30,8 +31,18 @@ const FeedbackTable = ({ id, paramKey }: IProps) => {
router,
getShowcaseSectionTitle,
metadata,
- setParams,
shouldShowPagination,
+ filterRegister,
+ handleFilterSubmit,
+ search,
+ watch,
+ clearAllFilters,
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
+ pagination,
+ handlePaginationChange,
+ isMutating,
} = useFeedbackTablePresenter({ id, paramKey });
return (
@@ -42,76 +53,83 @@ const FeedbackTable = ({ id, paramKey }: IProps) => {
}
className="flex flex-col rounded-[10px] bg-white px-7.5 pb-4 pt-7.5 capitalize shadow-1 dark:bg-gray-dark dark:shadow-card"
>
-
-
-
- {columns.map((column) => (
-
- {column}
-
- ))}
-
-
- {isLoading && }
-
-
-
- {(feedback ?? []).map((item, index) => (
-
+
+
+
+
+ {columns.map((column) => (
+
+ {column}
+
+ ))}
+
+
+ {isLoading ? (
+
+ ) : (
+
+
-
- {getPaginatedRowNumber({
- index,
- page: metadata?.page,
- limit: metadata?.limit,
- })}
-
-
- {item.feedback_type}
-
-
- {unixToDate({ unix: item.updated_at, hasTime: true })}
-
-
-
- {unixToDate({ unix: item.created_at, hasTime: true })}
-
-
-
-
-
- ))}
-
-
-
+
+ {getPaginatedRowNumber({
+ index,
+ page: metadata?.page,
+ limit: metadata?.limit,
+ })}
+
+
+ {item.feedback_type}
+
+
+ {unixToDate({ unix: item.updated_at, hasTime: true })}
+
+
+
+ {unixToDate({ unix: item.created_at, hasTime: true })}
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
{
- setParams((currentParams) => ({
- ...currentParams,
- offset: e.selected * (metadata?.limit ?? 10),
- limit: metadata?.limit ?? 10,
- }));
- }}
+ currentPage={pagination.currentPage}
+ totalPages={pagination.totalPages}
+ onPageChange={handlePaginationChange}
/>
diff --git a/src/components/Tables/feedback-table/useFeedbackTablePresenter.ts b/src/components/Tables/feedback-table/useFeedbackTablePresenter.ts
index 3e375da..1fe9d7b 100644
--- a/src/components/Tables/feedback-table/useFeedbackTablePresenter.ts
+++ b/src/components/Tables/feedback-table/useFeedbackTablePresenter.ts
@@ -1,8 +1,12 @@
"use client";
import { apiDefaultParams } from "@/constants/Api_Params";
import { useGetFeedback } from "@/hooks/queries";
+import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
+import { deleteEmptyKeys } from "@/utils/shared";
+import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
-import { useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useForm } from "react-hook-form";
interface IProps {
id: string;
@@ -14,6 +18,19 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
const searchParams = useSearchParams();
const router = useRouter();
const pathName = usePathname();
+ const isMutating = useIsMutating();
+
+ const {
+ register: filterRegister,
+ handleSubmit: handleFilterSubmit,
+ reset: filterFormReset,
+ watch,
+ setValue: setFilterValue,
+ } = useForm();
+
+ const tableSectionRef = useRef
(null);
+ const skeletonTbodyRef = useRef(null);
+ const dataTbodyRef = useRef(null);
useEffect(() => {
const newParams: Record = {
@@ -25,7 +42,8 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
newParams[key] = value;
}
setParams(newParams);
- }, [searchParams]);
+ filterFormReset(newParams as any);
+ }, [searchParams, filterFormReset]);
const queryParams = useMemo(
() => ({ ...params, [paramKey]: id }),
@@ -33,6 +51,13 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
);
const { data, isLoading } = useGetFeedback(queryParams);
+
+ useTableSectionRowAnimations(isLoading, data, {
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
+ });
+
const columns = [
"row",
"feedback type",
@@ -45,6 +70,39 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
!isLoading &&
(data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
+ const pagination = useMemo(
+ () => ({
+ currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
+ totalPages: data?.meta?.pages ?? 1,
+ }),
+ [data?.meta?.page, data?.meta?.pages],
+ );
+
+ const handlePaginationChange = useCallback(
+ (e: { selected: number }) => {
+ const limit = data?.meta?.limit ?? 10;
+ setParams((currentParams) => ({
+ ...currentParams,
+ offset: e.selected * limit,
+ limit,
+ }));
+ },
+ [data?.meta?.limit],
+ );
+
+ const search = (formData: any) => {
+ const next = { ...params, ...formData, offset: 0 };
+ const cleaned = deleteEmptyKeys(next);
+ const queryString = new URLSearchParams(
+ cleaned as Record,
+ ).toString();
+ router.push(`${pathName}?${queryString}`);
+ };
+
+ const clearAllFilters = useCallback(() => {
+ router.push(pathName);
+ }, [pathName, router]);
+
const getShowcaseSectionTitle = () => {
if (paramKey === "tender_id") {
const title = data?.data?.[0]?.tender?.title;
@@ -62,6 +120,18 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
metadata: data?.meta,
setParams,
shouldShowPagination,
+ filterRegister,
+ handleFilterSubmit,
+ search,
+ watch,
+ setFilterValue,
+ clearAllFilters,
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
+ pagination,
+ handlePaginationChange,
+ isMutating,
};
};
diff --git a/src/components/Tables/inquiries/InquiriesListFilters.tsx b/src/components/Tables/inquiries/InquiriesListFilters.tsx
index 173eb22..a0257c2 100644
--- a/src/components/Tables/inquiries/InquiriesListFilters.tsx
+++ b/src/components/Tables/inquiries/InquiriesListFilters.tsx
@@ -1,5 +1,9 @@
+"use client";
+
import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select";
+import { Button } from "@/components/ui-elements/button";
+import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import { INQUIRY_STATUS } from "@/constants/enums";
import {
FieldValues,
@@ -8,15 +12,16 @@ import {
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
+import { useMemo } from "react";
interface InquiriesListFiltersProps {
filterRegister: UseFormRegister;
- setIsFilterModalOpen: (isOpen: boolean) => void;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit;
search: (data: any) => void;
watch: UseFormWatch;
setValue: UseFormSetValue;
+ onClearAll: () => void;
}
const statusOptions = Object.values(INQUIRY_STATUS).map((status) => ({
@@ -24,58 +29,97 @@ const statusOptions = Object.values(INQUIRY_STATUS).map((status) => ({
value: status,
}));
+function countActive(values: Record): number {
+ let n = 0;
+ const q = values.q;
+ if (typeof q === "string" && q.trim()) n++;
+ const status = values.status;
+ if (
+ status !== undefined &&
+ status !== null &&
+ status !== "" &&
+ String(status).trim()
+ ) {
+ n++;
+ }
+ return n;
+}
+
const InquiriesListFilters = ({
filterRegister,
- setIsFilterModalOpen,
isMutating,
handleFilterSubmit,
search,
setValue,
watch,
+ onClearAll,
}: InquiriesListFiltersProps) => {
- return (
-
+ return (
+
+
+
);
};
diff --git a/src/components/Tables/inquiries/index.tsx b/src/components/Tables/inquiries/index.tsx
index 5b85976..26ded93 100644
--- a/src/components/Tables/inquiries/index.tsx
+++ b/src/components/Tables/inquiries/index.tsx
@@ -34,8 +34,6 @@ const InquiriesTable = () => {
columns,
inquiries,
allInquiries,
- isFilterModalOpen,
- setIsFilterModalOpen,
isPending,
currentInquiry,
isDeleteModalOpen,
@@ -51,130 +49,117 @@ const InquiriesTable = () => {
watch,
setFilterValue,
isMutating,
- setParams,
shouldShowPagination,
+ clearAllFilters,
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
+ pagination,
+ handlePaginationChange,
} = useInquiriesListPresenter();
return (
- setIsFilterModalOpen(true)}
- hasCreate={false}
+
+
-
-
-
- {columns.map((column: string) => (
-
- {column}
-
- ))}
-
-
- {isPending && }
-
-
- {inquiries?.map((inquiry: TInquiries, index: number) => (
-
-
- {getPaginatedRowNumber({
- index,
- page: allInquiries?.meta?.page,
- limit: allInquiries?.meta?.limit,
- })}
-
-
- {inquiry?.company_name}
-
-
- {inquiry?.full_name}
-
-
- {formatPhoneNumber(inquiry?.phone_number)}
-
-
-
- {inquiry?.status}
-
-
-
- {unixToDate({ unix: inquiry.created_at, hasTime: true })}
-
-
-
-
-
-
-
-
- ))}
-
-
-
- setIsFilterModalOpen(false)}
- classNames="w-full xl:w-1/2"
- showButtons={false}
- >
-
-
-
- {
- setParams((currentParams) => ({
- ...currentParams,
- offset: e.selected * (allInquiries?.meta?.limit ?? 10),
- limit: allInquiries?.meta?.limit ?? 10,
- }));
- }}
- />
-
+
+
+
+
+ {columns.map((column: string) => (
+
+ {column}
+
+ ))}
+
+
+ {isPending ? (
+
+ ) : (
+
+
+ {inquiries?.map((inquiry: TInquiries, index: number) => (
+
+
+ {getPaginatedRowNumber({
+ index,
+ page: allInquiries?.meta?.page,
+ limit: allInquiries?.meta?.limit,
+ })}
+
+
+ {inquiry?.company_name}
+
+
+ {inquiry?.full_name}
+
+
+ {formatPhoneNumber(inquiry?.phone_number)}
+
+
+
+ {inquiry?.status}
+
+
+
+ {unixToDate({ unix: inquiry.created_at, hasTime: true })}
+
+
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
setIsChangeStatusModalOpen(false)}
@@ -192,6 +177,13 @@ const InquiriesTable = () => {
currentStatus={currentInquiry?.status ?? "pending"}
/>
+
+
+
{isDeleteModalOpen && (
{
- const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isChangeStatusModalOpen, setIsChangeStatusModalOpen] = useState(false);
const [currentInquiry, setCurrentInquiry] = useState(null);
@@ -39,6 +39,10 @@ const useInquiriesListPresenter = () => {
setValue: setFilterValue,
} = useForm();
+ const tableSectionRef = useRef(null);
+ const skeletonTbodyRef = useRef(null);
+ const dataTbodyRef = useRef(null);
+
useEffect(() => {
const newParams: Record = {
offset: 0,
@@ -52,30 +56,61 @@ const useInquiriesListPresenter = () => {
}, [searchParams, filterFormReset]);
const { data, isPending } = useGetInquiries(params);
+
+ useTableSectionRowAnimations(isPending, data, {
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
+ });
+
const shouldShowPagination =
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
+
+ const pagination = useMemo(
+ () => ({
+ currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
+ totalPages: data?.meta?.pages ?? 1,
+ }),
+ [data?.meta?.page, data?.meta?.pages],
+ );
+
+ const handlePaginationChange = useCallback(
+ (e: { selected: number }) => {
+ const limit = data?.meta?.limit ?? 10;
+ setParams((currentParams) => ({
+ ...currentParams,
+ offset: e.selected * limit,
+ limit,
+ }));
+ },
+ [data?.meta?.limit],
+ );
+
const { mutate } = useDeleteInquiry(() => setIsDeleteModalOpen(false));
const { mutate: changeStatus } =
useChangeInquiryStatus(() => setIsChangeStatusModalOpen(false));
- const search = (data: any) => {
- const newParams = { ...params, ...data, offset: 0 };
+ const search = (formData: any) => {
+ const newParams = { ...params, ...formData, offset: 0 };
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = new URLSearchParams(cleanedParams).toString();
router.push(`${pathname}?${queryString}`);
- setIsFilterModalOpen(false);
};
+ const clearAllFilters = useCallback(() => {
+ router.push(pathname);
+ }, [pathname, router]);
+
const deleteInquiry = () => {
if (!currentInquiry?.id) return;
mutate(currentInquiry.id);
};
- const handleChangeStatus = (data: any) => {
+ const handleChangeStatus = (payload: any) => {
if (!currentInquiry?.id) return;
changeStatus({
id: currentInquiry.id,
- credentials: data,
+ credentials: payload,
});
};
@@ -83,8 +118,6 @@ const useInquiriesListPresenter = () => {
columns,
inquiries: data?.data.inquiries,
allInquiries: data,
- isFilterModalOpen,
- setIsFilterModalOpen,
isPending,
isDeleteModalOpen,
setIsDeleteModalOpen,
@@ -103,6 +136,12 @@ const useInquiriesListPresenter = () => {
isMutating,
setParams,
shouldShowPagination,
+ clearAllFilters,
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
+ pagination,
+ handlePaginationChange,
};
};
diff --git a/src/components/Tables/notification-history/NotificationHistoryListFilters.tsx b/src/components/Tables/notification-history/NotificationHistoryListFilters.tsx
index ac3f397..813b4c2 100644
--- a/src/components/Tables/notification-history/NotificationHistoryListFilters.tsx
+++ b/src/components/Tables/notification-history/NotificationHistoryListFilters.tsx
@@ -1,5 +1,9 @@
+"use client";
+
import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select";
+import { Button } from "@/components/ui-elements/button";
+import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import {
FieldValues,
UseFormHandleSubmit,
@@ -7,66 +11,111 @@ import {
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
+import { useMemo } from "react";
interface NotificationHistoryListFiltersProps {
filterRegister: UseFormRegister;
- setIsFilterModalOpen: (isOpen: boolean) => void;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit;
search: (data: any) => void;
watch: UseFormWatch;
setValue: UseFormSetValue;
+ onClearAll: () => void;
+}
+
+function countActive(values: Record): number {
+ let n = 0;
+ const seen = values.seen;
+ if (
+ seen !== undefined &&
+ seen !== null &&
+ seen !== "" &&
+ String(seen).trim()
+ ) {
+ n++;
+ }
+ const searchVal = values.search;
+ if (typeof searchVal === "string" && searchVal.trim()) n++;
+ return n;
}
const NotificationHistoryListFilters = ({
filterRegister,
- setIsFilterModalOpen,
isMutating,
handleFilterSubmit,
search,
+ watch,
+ setValue,
+ onClearAll,
}: NotificationHistoryListFiltersProps) => {
- return (
-
+ return (
+
+
+
);
};
diff --git a/src/components/Tables/notification-history/index.tsx b/src/components/Tables/notification-history/index.tsx
index 4e5aca9..1af8ab0 100644
--- a/src/components/Tables/notification-history/index.tsx
+++ b/src/components/Tables/notification-history/index.tsx
@@ -24,7 +24,6 @@ import { Tooltip } from "react-tooltip";
import Boolean from "@/components/ui/Boolean";
import ListHeader from "@/components/ui/ListHeader";
import ListWrapper from "@/components/ui/ListWrapper";
-import Modal from "@/components/ui/modal";
import NotificationHistoryListFilters from "./NotificationHistoryListFilters";
import useNotificationHistoryTablePresenter from "./useNotificationHistoryTablePresenter";
@@ -36,130 +35,119 @@ const NotificationHistoryTable = () => {
notificationHistory,
pathName,
router,
- isFilterModalOpen,
- setIsFilterModalOpen,
- setParams,
- handleSubmit,
- register,
+ handleFilterSubmit,
+ filterRegister,
setValue,
watch,
search,
isMutating,
shouldShowPagination,
+ clearAllFilters,
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
+ pagination,
+ handlePaginationChange,
} = useNotificationHistoryTablePresenter();
return (
<>
- setIsFilterModalOpen(true)}
+
+
-
-
-
- {columns.map((column) => (
-
- {column}
-
- ))}
-
-
- {isLoading && (
-
- )}
-
-
- {(notificationHistory ?? []).map((item, index) => (
-
-
- {getPaginatedRowNumber({
- index,
- page: metadata?.page,
- limit: metadata?.limit,
- })}
-
- {item.title}
- {item.event_type}
-
- {unixToDate({
- hasTime: true,
- unix: item.created_at,
- })}
-
-
- {item.recipient?.full_name}
-
-
- {truncateString(item.message, 20, 20)}
-
-
- {item.priority}
-
-
- {item.type}
-
-
-
-
-
- {item.status}
-
-
-
+ {item.title}
+ {item.event_type}
+
+ {unixToDate({
+ hasTime: true,
+ unix: item.created_at,
+ })}
+
+ {item.recipient?.full_name}
+
+ {truncateString(item.message, 20, 20)}
+
+ {item.priority}
+ {item.type}
+
+
+
+
+ {item.status}
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
{
- setParams((currentParams: Record) => ({
- ...currentParams,
- offset: e.selected * (metadata?.limit ?? 10),
- limit: metadata?.limit ?? 10,
- }));
- }}
+ currentPage={pagination.currentPage}
+ totalPages={pagination.totalPages}
+ onPageChange={handlePaginationChange}
/>
- setIsFilterModalOpen(false)}
- showButtons={false}
- >
-
-
>
);
};
diff --git a/src/components/Tables/notification-history/useNotificationHistoryTablePresenter.ts b/src/components/Tables/notification-history/useNotificationHistoryTablePresenter.ts
index 6e251c4..6bc2f93 100644
--- a/src/components/Tables/notification-history/useNotificationHistoryTablePresenter.ts
+++ b/src/components/Tables/notification-history/useNotificationHistoryTablePresenter.ts
@@ -1,19 +1,37 @@
"use client";
import { apiDefaultParams } from "@/constants/Api_Params";
import { useGetNotificationHistoryQuery } from "@/hooks/queries/useNotificationQueries";
+import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { deleteEmptyKeys } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
-import { usePathname, useRouter } from "next/navigation";
-import { useState } from "react";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
const useNotificationHistoryTablePresenter = () => {
- const { register, handleSubmit, watch, setValue } = useForm();
- const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
+ const {
+ register: filterRegister,
+ handleSubmit,
+ watch,
+ setValue,
+ reset: filterFormReset,
+ } = useForm();
const [params, setParams] = useState>({
...apiDefaultParams,
});
+ const searchParams = useSearchParams();
+ const tableSectionRef = useRef(null);
+ const skeletonTbodyRef = useRef(null);
+ const dataTbodyRef = useRef(null);
+
const { data, isLoading } = useGetNotificationHistoryQuery(params);
+
+ useTableSectionRowAnimations(isLoading, data, {
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
+ });
+
const shouldShowPagination =
!isLoading && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
const isMutating = useIsMutating();
@@ -32,14 +50,51 @@ const useNotificationHistoryTablePresenter = () => {
];
const pathName = usePathname();
const router = useRouter();
- const search = (data: any) => {
- const newParams = { ...params, ...data, offset: 0 };
+
+ useEffect(() => {
+ const newParams: Record = {
+ ...apiDefaultParams,
+ offset: 0,
+ limit: 10,
+ };
+ for (const [key, value] of searchParams.entries()) {
+ newParams[key] = value;
+ }
+ setParams(newParams);
+ filterFormReset(newParams);
+ }, [searchParams, filterFormReset]);
+
+ const pagination = useMemo(
+ () => ({
+ currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
+ totalPages: data?.meta?.pages ?? 1,
+ }),
+ [data?.meta?.page, data?.meta?.pages],
+ );
+
+ const handlePaginationChange = useCallback(
+ (e: { selected: number }) => {
+ const limit = data?.meta?.limit ?? 10;
+ setParams((currentParams: Record) => ({
+ ...currentParams,
+ offset: e.selected * limit,
+ limit,
+ }));
+ },
+ [data?.meta?.limit],
+ );
+
+ const search = (formData: any) => {
+ const newParams = { ...params, ...formData, offset: 0 };
const cleanedParams = deleteEmptyKeys(newParams);
- setParams(cleanedParams);
const queryString = new URLSearchParams(cleanedParams).toString();
router.push(`${pathName}?${queryString}`);
- setIsFilterModalOpen(false);
};
+
+ const clearAllFilters = useCallback(() => {
+ router.push(pathName);
+ }, [pathName, router]);
+
return {
notificationHistory: data?.data,
metadata: data?.meta,
@@ -48,15 +103,20 @@ const useNotificationHistoryTablePresenter = () => {
router,
pathName,
setParams,
- register,
+ filterRegister,
handleSubmit,
+ handleFilterSubmit: handleSubmit,
watch,
setValue,
- isFilterModalOpen,
- setIsFilterModalOpen,
isMutating,
search,
shouldShowPagination,
+ clearAllFilters,
+ tableSectionRef,
+ skeletonTbodyRef,
+ dataTbodyRef,
+ pagination,
+ handlePaginationChange,
};
};
diff --git a/src/components/Tables/tenders/TenderListFilters.tsx b/src/components/Tables/tenders/TenderListFilters.tsx
index 48265a7..bff496e 100644
--- a/src/components/Tables/tenders/TenderListFilters.tsx
+++ b/src/components/Tables/tenders/TenderListFilters.tsx
@@ -1,12 +1,14 @@
"use client";
-import { ChevronUpIcon, GlobeIcon, SearchIcon } from "@/assets/icons";
+import { GlobeIcon, SearchIcon } from "@/assets/icons";
import DatePicker from "@/components/FormElements/DatePicker/DatePicker";
import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select";
import { Button } from "@/components/ui-elements/button";
+import CollapsibleFilterSection from "@/components/ui/CollapsibleFilterSection";
+import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import { Countries } from "@/constants/countries";
-import { cn } from "@/lib/utils";
+import { useMemo } from "react";
import {
Control,
FieldValues,
@@ -15,8 +17,6 @@ import {
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
-import gsap from "gsap";
-import { type ReactNode, useLayoutEffect, useMemo, useRef, useState } from "react";
interface TenderListFiltersProps {
filterRegister: UseFormRegister;
@@ -71,69 +71,6 @@ function countActiveFilters(values: Record): number {
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 (
-
-
-
-
- );
-}
-
const TenderListFilters = ({
filterRegister,
control,
@@ -145,363 +82,198 @@ const TenderListFilters = ({
onClearAll,
}: TenderListFiltersProps) => {
const watched = watch();
- const activeCount = useMemo(() => countActiveFilters(watched as Record), [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(null);
- const panelInnerRef = useRef(null);
- const chevronRef = useRef(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]);
+ const activeCount = useMemo(
+ () => countActiveFilters(watched as Record),
+ [watched],
+ );
return (
-
-
-
-
-
+
+
+
+
+ {...filterRegister("country")}
+ name="country"
+ label="Country"
+ items={Countries}
+ placeholder="Any country"
+ clearable
+ prefixIcon={}
+ value={watch("country")}
+ onClear={() => setValue("country", "")}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
};
diff --git a/src/components/Tables/tenders/useTenderListPresenter.ts b/src/components/Tables/tenders/useTenderListPresenter.ts
index 161db69..2f6dd28 100644
--- a/src/components/Tables/tenders/useTenderListPresenter.ts
+++ b/src/components/Tables/tenders/useTenderListPresenter.ts
@@ -101,6 +101,37 @@ const buildTenderFilterFormValues = (params: Record
) => ({
),
});
+/** 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: "",
+ 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 };
@@ -250,7 +281,7 @@ const useTenderListPresenter = () => {
newParams[key] = value;
}
setParams(newParams);
- filterFormReset(buildTenderFilterFormValues(newParams));
+ filterFormReset(mergeTenderFilterFormState(newParams));
}, [searchParams, filterFormReset]);
const { data, error, isPending } = useTendersQuery(params);
@@ -377,8 +408,14 @@ const useTenderListPresenter = () => {
};
const clearAllFilters = useCallback(() => {
+ const cleared: Record = {
+ ...apiDefaultParams,
+ offset: 0,
+ limit: 10,
+ };
+ filterFormReset(mergeTenderFilterFormState(cleared));
router.push(pathName);
- }, [pathName, router]);
+ }, [pathName, router, filterFormReset]);
const columns = [
"row",
"title",
diff --git a/src/components/loading.tsx b/src/components/loading.tsx
index dd4374c..e27de97 100644
--- a/src/components/loading.tsx
+++ b/src/components/loading.tsx
@@ -1,106 +1,79 @@
+"use client";
+
+import rawLoadingMain from "@/assets/lottie/loading-main.json";
+import { applyLoadingLottieTheme } from "@/lib/lottie-theme";
import { cn } from "@/lib/utils";
+import Lottie from "lottie-react";
+import { useTheme } from "next-themes";
+import { useEffect, useMemo, useState } from "react";
interface IProps {
className?: string | null;
}
/**
- * Full-screen route-style loader (orbs, rings, shimmer). Pass `className` to adapt
- * the shell—for example `min-h-48 flex-none` inside compact panels.
+ * Extract of `animations/main.json` from `public/lottie/loading.lottie` (regenerate when
+ * the .lottie changes: `unzip -p public/lottie/loading.lottie animations/main.json > src/assets/lottie/loading-main.json`).
+ */
+const LOTTIE_RAW = rawLoadingMain as object;
+
+/**
+ * Route-level loader: SVG Lottie via `lottie-react` (reliable on refresh). Source animation
+ * stays in sync with `public/lottie/loading.lottie` via the bundled extract above.
*/
const Loading = ({ className }: IProps) => {
+ const { resolvedTheme } = useTheme();
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ const mode = mounted && resolvedTheme === "dark" ? "dark" : "light";
+
+ const animationData = useMemo(
+ () => applyLoadingLottieTheme(LOTTIE_RAW, mode),
+ [mode],
+ );
+
return (
- <>
-
+
- {/* Soft gradient wash */}
-
+ className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_75%_55%_at_50%_45%,transparent,rgba(17,25,40,0.06))] dark:bg-[radial-gradient(ellipse_75%_55%_at_50%_45%,transparent,rgba(0,0,0,0.45))]"
+ aria-hidden
+ />
- {/* Floating orbs */}
-
-
-
+
+
- {/* Subtle grid */}
-
-
-
-
- {/* Outer glow */}
-
-
- {/* Concentric rings */}
-
-
+
+
+
-
-
- {/* Core */}
-
-
- OL
-
-
-
-
-
-
-
-
-
-
-
- Preparing your workspace…
-
-
-
- {/* Shimmer bar */}
-
+
+ Loading
+
- >
+
+
Loading, please wait.
+
);
};
diff --git a/src/components/not-found-content.tsx b/src/components/not-found-content.tsx
new file mode 100644
index 0000000..9b76a42
--- /dev/null
+++ b/src/components/not-found-content.tsx
@@ -0,0 +1,185 @@
+"use client";
+
+import { DotLottieReact } from "@lottiefiles/dotlottie-react";
+import { cn } from "@/lib/utils";
+import Link from "next/link";
+import { useTheme } from "next-themes";
+import { useEffect, useState } from "react";
+
+/** Served from `public/lottie/404.lottie` (mirrored in `src/assets/lottie/404.lottie` for the repo). */
+const LOTTIE_URL = "/lottie/404.lottie";
+
+export default function NotFoundContent() {
+ const { resolvedTheme } = useTheme();
+ const [mounted, setMounted] = useState(false);
+ const [loadError, setLoadError] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ const mode = mounted && resolvedTheme === "dark" ? "dark" : "light";
+
+ return (
+
+ {/* Ambient mesh */}
+
+
+
+
+ {/* Vignette + fine grid */}
+
+
+
+ {/* Frame accents */}
+
+
+
+
+
+ {/* Gradient hairline frame */}
+
+
+
+
+
+
+
+
+ Route not found
+
+
+
+
+ 4
+
+
+ 0
+
+
+ 4
+
+
+
+
+ This URL is not mapped in the panel. The page may have moved
+ or the link could be mistyped.
+
+
+
+
+
+
+ {loadError ? (
+
+
+ Could not load illustration
+
+
+ ) : (
+
setLoadError(true)}
+ />
+ )}
+
+
+
+
+
+ Back to dashboard
+
+
+
+
+
+
+
+
+ Opp lens · admin
+
+
+
+
+ );
+}
diff --git a/src/components/ui/CollapsibleFilterSection.tsx b/src/components/ui/CollapsibleFilterSection.tsx
new file mode 100644
index 0000000..e2d6db9
--- /dev/null
+++ b/src/components/ui/CollapsibleFilterSection.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { ChevronUpIcon } from "@/assets/icons";
+import { cn } from "@/lib/utils";
+import { type ReactNode, useState } from "react";
+
+interface CollapsibleFilterSectionProps {
+ title: string;
+ subtitle?: string;
+ badge?: string;
+ defaultOpen?: boolean;
+ children: ReactNode;
+}
+
+/**
+ * Nested accordion block for long filter forms (e.g. tender advanced fields).
+ * Uses CSS grid height — independent from the main InlineListFiltersPanel GSAP shell.
+ */
+export default function CollapsibleFilterSection({
+ title,
+ subtitle,
+ badge,
+ defaultOpen,
+ children,
+}: CollapsibleFilterSectionProps) {
+ const [open, setOpen] = useState(Boolean(defaultOpen));
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/ui/InlineListFiltersPanel.tsx b/src/components/ui/InlineListFiltersPanel.tsx
new file mode 100644
index 0000000..8873b02
--- /dev/null
+++ b/src/components/ui/InlineListFiltersPanel.tsx
@@ -0,0 +1,235 @@
+"use client";
+
+import { ChevronUpIcon, SearchIcon } from "@/assets/icons";
+import { cn } from "@/lib/utils";
+import gsap from "gsap";
+import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
+import IsVisible from "./IsVisible";
+
+export interface InlineListFiltersPanelProps {
+ title: string;
+ description?: string;
+ /** Computed by the parent from watched form values */
+ activeFilterCount: number;
+ children: ReactNode;
+ /** Initial collapsed state (default false) */
+ defaultOpen?: boolean;
+ panelToggleDataCy?: string;
+ expandAriaLabel: string;
+ collapseAriaLabel: string;
+ className?: string;
+}
+
+/**
+ * Shared inline filter shell: collapsible header + GSAP height animation for the panel body.
+ * Wrap your `
` (or fragment) as `children`.
+ */
+export default function InlineListFiltersPanel({
+ title,
+ description,
+ activeFilterCount,
+ children,
+ defaultOpen = false,
+ panelToggleDataCy,
+ expandAriaLabel,
+ collapseAriaLabel,
+ className,
+}: InlineListFiltersPanelProps) {
+ const [panelOpen, setPanelOpen] = useState(Boolean(defaultOpen));
+ /** Header stays square-bottom until collapse GSAP finishes so radius does not pop early */
+ const [headerFlushBottom, setHeaderFlushBottom] = useState(false);
+
+ const panelOuterRef = useRef
(null);
+ const panelInnerRef = useRef(null);
+ const chevronRef = useRef(null);
+ const prevPanelOpen = useRef(panelOpen);
+
+ 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 -- mount sync only
+ }, []);
+
+ 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 (
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/ListHeader.tsx b/src/components/ui/ListHeader.tsx
index 011c71f..59fc933 100644
--- a/src/components/ui/ListHeader.tsx
+++ b/src/components/ui/ListHeader.tsx
@@ -23,8 +23,18 @@ const ListHeader = ({
}: IProps) => {
const pathName = usePathname();
const router = useRouter();
+
+ const rowJustify =
+ hasFilter && hasCreate
+ ? "justify-between"
+ : hasCreate && !hasFilter
+ ? "justify-center md:justify-end"
+ : "justify-start";
+
return (
-
+