From 25f8bde2294ee19c2ea7e7a7820f6d746b9b90de Mon Sep 17 00:00:00 2001 From: AmirReza Jamali Date: Wed, 13 May 2026 17:07:47 +0330 Subject: [PATCH] feat(dependencies): add Lottie animation libraries and refactor NotFoundPage - Added `@lottiefiles/dotlottie-react` and `lottie-react` dependencies for enhanced animation capabilities. - Refactored the NotFoundPage component to utilize a dedicated NotFoundContent component, improving code organization and readability. - Updated package.json and pnpm-lock.yaml to reflect new dependencies and their versions. --- .../e2e/tenders/tender-translation-page.cy.ts | 27 +- package.json | 2 + pnpm-lock.yaml | 38 ++ public/lottie/404.lottie | Bin 0 -> 12194 bytes public/lottie/loading.lottie | Bin 0 -> 1185 bytes src/app/(not-found)/[...not-found]/page.tsx | 40 +- .../components/tender-details-header.tsx | 134 ++-- src/app/tenders/[details]/constants.ts | 5 +- .../hooks/use-stack-tender-header-actions.ts | 58 -- src/app/tenders/[details]/page.tsx | 33 +- src/assets/lottie/loading-main.json | 1 + .../FormElements/DatePicker/DatePicker.tsx | 2 +- src/components/FormElements/select.tsx | 5 + .../Tables/admins/AdminListFilters.tsx | 186 +++--- src/components/Tables/admins/index.tsx | 257 ++++---- .../Tables/admins/useAdminsPresenter.ts | 149 ++++- src/components/Tables/cms/CmsListFilters.tsx | 120 ++-- src/components/Tables/cms/index.tsx | 180 +++--- .../Tables/cms/useCmsTablePresenter.ts | 29 +- .../Tables/companies/CompanyListFilters.tsx | 174 +++-- .../CompanyCategoryListFilters.tsx | 138 ++-- .../companies/company-categories/index.tsx | 253 ++++---- .../useCompanyCategoriesPresenter.ts | 53 +- src/components/Tables/companies/index.tsx | 287 ++++---- .../companies/useCompanyListPresenter.ts | 52 +- .../contact-us/ContactUsListFilters.tsx | 113 ++++ src/components/Tables/contact-us/index.tsx | 184 +++--- .../contact-us/useContactUsListPresenter.ts | 74 ++- .../Tables/customers/CustomerListFilters.tsx | 149 +++-- src/components/Tables/customers/index.tsx | 300 +++++---- .../customers/useCustomerListPresenter.ts | 69 +- .../feedback-table/FeedbackListFilters.tsx | 92 +++ .../Tables/feedback-table/index.tsx | 152 +++-- .../useFeedbackTablePresenter.ts | 74 ++- .../Tables/inquiries/InquiriesListFilters.tsx | 132 ++-- src/components/Tables/inquiries/index.tsx | 232 ++++--- .../inquiries/useInquiriesListPresenter.ts | 57 +- .../NotificationHistoryListFilters.tsx | 135 ++-- .../Tables/notification-history/index.tsx | 204 +++--- .../useNotificationHistoryTablePresenter.ts | 82 ++- .../Tables/tenders/TenderListFilters.tsx | 610 ++++++------------ .../Tables/tenders/useTenderListPresenter.ts | 41 +- src/components/loading.tsx | 143 ++-- src/components/not-found-content.tsx | 185 ++++++ .../ui/CollapsibleFilterSection.tsx | 75 +++ src/components/ui/InlineListFiltersPanel.tsx | 235 +++++++ src/components/ui/ListHeader.tsx | 12 +- src/hooks/useTableSectionRowAnimations.ts | 69 ++ src/lib/lottie-theme.ts | 66 ++ 49 files changed, 3511 insertions(+), 2197 deletions(-) create mode 100644 public/lottie/404.lottie create mode 100644 public/lottie/loading.lottie delete mode 100644 src/app/tenders/[details]/hooks/use-stack-tender-header-actions.ts create mode 100644 src/assets/lottie/loading-main.json create mode 100644 src/components/Tables/contact-us/ContactUsListFilters.tsx create mode 100644 src/components/Tables/feedback-table/FeedbackListFilters.tsx create mode 100644 src/components/not-found-content.tsx create mode 100644 src/components/ui/CollapsibleFilterSection.tsx create mode 100644 src/components/ui/InlineListFiltersPanel.tsx create mode 100644 src/hooks/useTableSectionRowAnimations.ts create mode 100644 src/lib/lottie-theme.ts 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 0000000000000000000000000000000000000000..3426fdf1c6f5fe2ec32f2996ce397970a57ca39d GIT binary patch literal 12194 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 literal 0 HcmV?d00001 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 ( -
+