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 ( -
-
- 404 -
-
-
- not connected cable -
-
-
-

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}

-
-
-
-

- Tender -

-

{title}

-
-
-

- Language & translation -

+
- - name="lang" - label="Display language" - items={Languages} - className={cn( - "w-full shrink-0 space-y-0 sm:w-[min(100%,11.5rem)]", - "[&_label]:sr-only", - )} - value={selectedLanguage} - onChange={onLanguageChange as LanguageSelectOnChange} +
-
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 ( -
- setValue("role", undefined)} - /> */} - {/* */} - {/* */} - -
- - -
- + onClearAll, +}: AdminListFiltersProps) { + const watched = watch(); + const activeCount = useMemo( + () => countActiveAdminFilters(watched as Record), + [watched], ); -}; -export default AdminListFilters; + return ( + +
+
+
+ + } + iconPosition="left" + dataCy="admins-filter-search-input" + className="[&_input]:rounded-2xl [&_input]:border-stroke/70 [&_input]:bg-white/90 [&_input]:py-3.5 [&_input]:text-sm dark:[&_input]:border-dark-3 dark:[&_input]:bg-dark-2/80" + /> +
+
+ +
+
+ +
+ + {...filterRegister("status")} + items={getEnumAsArray(AdminStatus)} + label="Status" + name="status" + placeholder="Any status" + clearable + value={watch("status")} + onClear={() => setValue("status", undefined)} + dataCy="admins-filter-status-select" + /> +
+
+
+ ); +} 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 ( -
- - + const watched = watch(); + const activeCount = useMemo( + () => countActive(watched as Record), + [watched], + ); -
- - -
- + 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 ( -
- - - - setValue("currency", undefined)} + /> + +
+
+ +
+ + ); }; 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 ( -
- - setValue("published", undefined)} + dataCy="company-categories-filter-published-select" + /> +
+
+ +
+ + ); }; 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 ( -
- setValue("role", undefined)} - /> - setValue("status", undefined)} + /> + setValue("type", undefined)} + /> +
+
+ +
+ + ); }; 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 ( -
- - setValue("status", "")} + dataCy="inquiries-filter-status-select" + /> + +
+ +
+ + ); }; 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 ( -
- setValue("seen", undefined)} + /> + + +
+ +
+ + ); }; 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} - - -
+ + + {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} + + + + + + ))} + + + )} + + { - 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 ( -
- -
-
-
- {children} -
-
-
-
- ); -} - 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 ( -
-
- - -
-
-
- {/* Hero search */} -
-
- } - iconPosition="left" - className="[&_input]:rounded-2xl [&_input]:border-stroke/70 [&_input]:bg-white/90 [&_input]:py-3.5 [&_input]:text-sm dark:[&_input]:border-dark-3 dark:[&_input]:bg-dark-2/80" - /> -
-
- -
-
- - + + {/* Hero search */} +
+
+ + } + iconPosition="left" + className="[&_input]:rounded-2xl [&_input]:border-stroke/70 [&_input]:bg-white/90 [&_input]:py-3.5 [&_input]:text-sm dark:[&_input]:border-dark-3 dark:[&_input]:bg-dark-2/80" + /> +
+
+
+ Reset all +
-
+ + +
+ + {...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 ( +
+ +
+
+
+ {children} +
+
+
+
+ ); +} 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 ( +
+
+ + +
+
+ {children} +
+
+
+
+ ); +} 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 ( -
+