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.
This commit is contained in:
@@ -72,6 +72,24 @@ describe("tender details - translation and summary", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("switches language from selector and reloads details with new lang query", () => {
|
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) => {
|
cy.intercept("GET", `**/api/proxy/tenders/${tenderId}**`, (req) => {
|
||||||
const lang = String(req.query.lang ?? "en");
|
const lang = String(req.query.lang ?? "en");
|
||||||
req.alias = lang === "fr" ? "getTenderDetailsFr" : "getTenderDetailsEn";
|
req.alias = lang === "fr" ? "getTenderDetailsFr" : "getTenderDetailsEn";
|
||||||
@@ -91,18 +109,19 @@ describe("tender details - translation and summary", () => {
|
|||||||
|
|
||||||
cy.get('select[name="lang"]').select("fr");
|
cy.get('select[name="lang"]').select("fr");
|
||||||
cy.location("search").should("include", "lang=fr");
|
cy.location("search").should("include", "lang=fr");
|
||||||
|
cy.wait("@translateOnLangChange");
|
||||||
cy.wait("@getTenderDetailsFr");
|
cy.wait("@getTenderDetailsFr");
|
||||||
tenderTitleHeading().should("contain.text", "Titre FR");
|
tenderTitleHeading().should("contain.text", "Titre FR");
|
||||||
cy.contains("Resume FR").should("be.visible");
|
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}**`, {
|
cy.intercept("GET", `**/api/proxy/tenders/${tenderId}**`, {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: makeTenderDetailsResponse({
|
body: makeTenderDetailsResponse({
|
||||||
title: "Before translate",
|
title: "Before translate",
|
||||||
description: "Before translate description",
|
description: "Before translate description",
|
||||||
language: "de",
|
language: "en",
|
||||||
}),
|
}),
|
||||||
}).as("getTenderDetails");
|
}).as("getTenderDetails");
|
||||||
|
|
||||||
@@ -127,9 +146,9 @@ describe("tender details - translation and summary", () => {
|
|||||||
},
|
},
|
||||||
).as("translateTender");
|
).as("translateTender");
|
||||||
|
|
||||||
cy.visit(`/tenders/${tenderId}?lang=de`);
|
cy.visit(`/tenders/${tenderId}?lang=en`);
|
||||||
cy.wait("@getTenderDetails");
|
cy.wait("@getTenderDetails");
|
||||||
cy.contains("button", "Translate metadata").click();
|
cy.get('select[name="lang"]').select("de");
|
||||||
cy.wait("@translateTender");
|
cy.wait("@translateTender");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"test:e2e": "start-server-and-test dev:e2e http://localhost:3000 \"CYPRESS_BASE_URL=http://localhost:3000 cypress run\""
|
"test:e2e": "start-server-and-test dev:e2e http://localhost:3000 \"CYPRESS_BASE_URL=http://localhost:3000 cypress run\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@lottiefiles/dotlottie-react": "^0.19.2",
|
||||||
"@tanstack/react-query": "^5.85.6",
|
"@tanstack/react-query": "^5.85.6",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/react-paginate": "^7.1.4",
|
"@types/react-paginate": "^7.1.4",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"gsap": "^3.15.0",
|
"gsap": "^3.15.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jsvectormap": "^1.6.0",
|
"jsvectormap": "^1.6.0",
|
||||||
|
"lottie-react": "^2.4.1",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"next": "15.1.9",
|
"next": "15.1.9",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
|
|||||||
Generated
+38
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@lottiefiles/dotlottie-react':
|
||||||
|
specifier: ^0.19.2
|
||||||
|
version: 0.19.2(react@19.0.0)
|
||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.85.6
|
specifier: ^5.85.6
|
||||||
version: 5.100.2(react@19.0.0)
|
version: 5.100.2(react@19.0.0)
|
||||||
@@ -50,6 +53,9 @@ importers:
|
|||||||
jsvectormap:
|
jsvectormap:
|
||||||
specifier: ^1.6.0
|
specifier: ^1.6.0
|
||||||
version: 1.7.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:
|
moment:
|
||||||
specifier: ^2.30.1
|
specifier: ^2.30.1
|
||||||
version: 2.30.1
|
version: 2.30.1
|
||||||
@@ -597,6 +603,14 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
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':
|
'@napi-rs/wasm-runtime@0.2.12':
|
||||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||||
|
|
||||||
@@ -2103,6 +2117,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||||
hasBin: true
|
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:
|
map-stream@0.1.0:
|
||||||
resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==}
|
resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==}
|
||||||
|
|
||||||
@@ -3588,6 +3611,13 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@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':
|
'@napi-rs/wasm-runtime@0.2.12':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/core': 1.10.0
|
'@emnapi/core': 1.10.0
|
||||||
@@ -5280,6 +5310,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 4.0.0
|
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: {}
|
map-stream@0.1.0: {}
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1,37 +1,5 @@
|
|||||||
import Image from "next/image";
|
import NotFoundContent from "@/components/not-found-content";
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
const NotFoundPage = () => {
|
export default function NotFoundPage() {
|
||||||
return (
|
return <NotFoundContent />;
|
||||||
<div className="mx-auto flex w-6/12 flex-col gap-12">
|
}
|
||||||
<section className="flex w-full items-center justify-center">
|
|
||||||
<Image src={"/404.svg"} alt="404" width={700} height={1000} />
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<div>
|
|
||||||
<Image
|
|
||||||
src={"/cable.svg"}
|
|
||||||
width={2000}
|
|
||||||
height={3000}
|
|
||||||
alt="not connected cable"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className="mt-10 flex flex-col items-center justify-center gap-10">
|
|
||||||
<h1 className="text-2xl font-bold">Page not found</h1>
|
|
||||||
<p className="text-[16px] font-semibold text-[#77707f]">
|
|
||||||
Sorry, the page you're looking for does not exist or has been
|
|
||||||
removed.
|
|
||||||
<br />
|
|
||||||
You can navigate to Home using
|
|
||||||
<Link href="/" className="mx-1 text-primary">
|
|
||||||
This
|
|
||||||
</Link>
|
|
||||||
link.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NotFoundPage;
|
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { GlobeIcon } from "@/assets/icons";
|
||||||
import { Select } from "@/components/FormElements/select";
|
import { Select } from "@/components/FormElements/select";
|
||||||
import { Button } from "@/components/ui-elements/button";
|
|
||||||
import Status from "@/components/ui/Status";
|
import Status from "@/components/ui/Status";
|
||||||
import { Languages } from "@/constants/languages";
|
import { Languages } from "@/constants/languages";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
import { useRef } from "react";
|
|
||||||
import type { FieldValues } from "react-hook-form";
|
import type { FieldValues } from "react-hook-form";
|
||||||
import { TENDER_HEADER_TITLE_CLASS } from "../constants";
|
import { TENDER_HEADER_TITLE_CLASS } from "../constants";
|
||||||
import { useStackTenderHeaderActions } from "../hooks/use-stack-tender-header-actions";
|
|
||||||
|
|
||||||
type LanguageSelectOnChange = NonNullable<
|
type LanguageSelectOnChange = NonNullable<
|
||||||
ComponentProps<typeof Select<FieldValues>>["onChange"]
|
ComponentProps<typeof Select<FieldValues>>["onChange"]
|
||||||
@@ -19,7 +17,6 @@ type TenderDetailsHeaderProps = {
|
|||||||
title: string;
|
title: string;
|
||||||
selectedLanguage: string;
|
selectedLanguage: string;
|
||||||
onLanguageChange: (event: React.ChangeEvent<HTMLSelectElement>) => void;
|
onLanguageChange: (event: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||||
onTranslate: () => void;
|
|
||||||
isTranslating: boolean;
|
isTranslating: boolean;
|
||||||
status: string | undefined;
|
status: string | undefined;
|
||||||
};
|
};
|
||||||
@@ -28,94 +25,69 @@ export function TenderDetailsHeader({
|
|||||||
title,
|
title,
|
||||||
selectedLanguage,
|
selectedLanguage,
|
||||||
onLanguageChange,
|
onLanguageChange,
|
||||||
onTranslate,
|
|
||||||
isTranslating,
|
isTranslating,
|
||||||
status,
|
status,
|
||||||
}: TenderDetailsHeaderProps) {
|
}: TenderDetailsHeaderProps) {
|
||||||
const headerLayoutContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const headerTitleProbeSizerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const stackActionsBelow = useStackTenderHeaderActions(
|
|
||||||
title,
|
|
||||||
headerLayoutContainerRef,
|
|
||||||
headerTitleProbeSizerRef,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={headerLayoutContainerRef} className="relative">
|
<div className="relative w-full">
|
||||||
<div
|
<div className="flex w-full min-w-0 flex-col gap-5">
|
||||||
className="pointer-events-none absolute inset-x-0 top-0 -z-10 max-h-0 overflow-hidden opacity-0"
|
<div className="w-full min-w-0 space-y-2">
|
||||||
aria-hidden
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||||
>
|
|
||||||
<div ref={headerTitleProbeSizerRef}>
|
|
||||||
<h1 className={TENDER_HEADER_TITLE_CLASS}>{title}</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col gap-5",
|
|
||||||
!stackActionsBelow &&
|
|
||||||
"lg:flex-row lg:items-start lg:justify-between lg:gap-8",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"min-w-0 space-y-2",
|
|
||||||
!stackActionsBelow && "lg:flex-1",
|
|
||||||
stackActionsBelow && "lg:w-full",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">
|
||||||
Tender
|
Tender
|
||||||
</p>
|
</p>
|
||||||
|
<Status status={status ?? "Unknown"}>{status}</Status>
|
||||||
|
</div>
|
||||||
<h1 className={TENDER_HEADER_TITLE_CLASS}>{title}</h1>
|
<h1 className={TENDER_HEADER_TITLE_CLASS}>{title}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex w-full min-w-0 shrink-0 flex-col">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full min-w-0 shrink-0 flex-col gap-3",
|
"relative overflow-hidden rounded-[1.35rem] border border-stroke/55 bg-white/75 p-4 shadow-[0_12px_42px_-14px_rgba(15,23,42,0.14),0_0_0_1px_rgba(87,80,241,0.05)] backdrop-blur-md dark:border-white/[0.09] dark:bg-gradient-to-br dark:from-dark-2/95 dark:via-dark-3/55 dark:to-violet-600/[0.12] dark:shadow-[0_18px_50px_-18px_rgba(0,0,0,0.5),inset_0_1px_0_0_rgba(255,255,255,0.04)] sm:p-5",
|
||||||
!stackActionsBelow && "lg:max-w-2xl lg:items-end",
|
|
||||||
stackActionsBelow && "lg:w-full lg:items-stretch",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"text-[11px] font-semibold uppercase tracking-[0.18em] text-dark-5 dark:text-dark-6 lg:w-full",
|
|
||||||
stackActionsBelow ? "lg:text-start" : "lg:text-end",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute -right-6 -top-10 h-28 w-28 rounded-full bg-gradient-to-br from-primary/25 to-violet-500/10 blur-2xl dark:from-violet-500/30 dark:to-primary/5"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="relative space-y-4">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-primary/20 to-violet-600/10 text-primary shadow-inner ring-1 ring-primary/15 dark:from-primary/30 dark:to-violet-500/15 dark:ring-white/10">
|
||||||
|
<GlobeIcon className="h-5 w-5 opacity-95" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 pt-0.5">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-dark-5 dark:text-dark-6">
|
||||||
Language & translation
|
Language & translation
|
||||||
</p>
|
</p>
|
||||||
<div
|
<p className="mt-1 text-xs leading-relaxed text-dark-5/90 dark:text-dark-6/95">
|
||||||
className={cn(
|
Changing language updates the URL, translates metadata, and
|
||||||
"flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center",
|
reloads this view.
|
||||||
stackActionsBelow
|
</p>
|
||||||
? "sm:justify-start lg:justify-start"
|
</div>
|
||||||
: "sm:justify-end lg:justify-end",
|
</div>
|
||||||
)}
|
<div className="flex flex-col gap-2">
|
||||||
>
|
|
||||||
<Select<FieldValues>
|
<Select<FieldValues>
|
||||||
name="lang"
|
name="lang"
|
||||||
label="Display language"
|
label="Display language"
|
||||||
items={Languages}
|
items={Languages}
|
||||||
|
disabled={isTranslating}
|
||||||
|
prefixIcon={
|
||||||
|
<GlobeIcon className="h-[1.05rem] w-[1.05rem] text-primary dark:text-primary/90" />
|
||||||
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full shrink-0 space-y-0 sm:w-[min(100%,11.5rem)]",
|
"w-full shrink-0 space-y-0",
|
||||||
"[&_label]:sr-only",
|
"[&_label]:sr-only",
|
||||||
|
"[&_select]:h-11 [&_select]:min-h-[2.75rem] [&_select]:rounded-full [&_select]:border-stroke/60 [&_select]:bg-white/95 [&_select]:py-0 [&_select]:text-sm [&_select]:shadow-sm dark:[&_select]:border-white/10 dark:[&_select]:bg-dark-2/92",
|
||||||
)}
|
)}
|
||||||
value={selectedLanguage}
|
value={selectedLanguage}
|
||||||
onChange={onLanguageChange as LanguageSelectOnChange}
|
onChange={onLanguageChange as LanguageSelectOnChange}
|
||||||
/>
|
/>
|
||||||
<Button
|
{isTranslating ? (
|
||||||
type="button"
|
<p className="text-xs font-medium text-primary dark:text-primary/90">
|
||||||
size="small"
|
Translating…
|
||||||
shape="full"
|
</p>
|
||||||
className="w-full shrink-0 whitespace-nowrap sm:min-h-[3.375rem] sm:w-auto"
|
) : null}
|
||||||
label={
|
</div>
|
||||||
isTranslating ? "Translating..." : "Translate metadata"
|
|
||||||
}
|
|
||||||
disabled={isTranslating}
|
|
||||||
onClick={onTranslate}
|
|
||||||
/>
|
|
||||||
<div className="flex shrink-0 items-center sm:min-h-[3.375rem]">
|
|
||||||
<Status status={status ?? "Unknown"}>{status}</Status>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
/** Matches the tender title `<h1>` in the header — keep in sync for layout probe */
|
/** Matches the tender title `<h1>` in the header */
|
||||||
export const TENDER_HEADER_TITLE_CLASS =
|
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]";
|
"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 = {
|
export const TENDER_SECTION_IDS = {
|
||||||
procurement: "tender-section-procurement",
|
procurement: "tender-section-procurement",
|
||||||
lots: "tender-section-lots",
|
lots: "tender-section-lots",
|
||||||
|
|||||||
@@ -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<HTMLDivElement | null>,
|
|
||||||
probeSizerRef: RefObject<HTMLDivElement | null>,
|
|
||||||
) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -31,7 +31,7 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
const selectedLanguage = searchParams.get("lang") || "en";
|
const selectedLanguage = searchParams.get("lang") || "en";
|
||||||
const { mutate: translateTender, isPending: isTranslating } =
|
const { mutate: translateTender, isPending: isTranslating } =
|
||||||
useTranslateTenderMutation();
|
useTranslateTenderMutation();
|
||||||
const { data, isLoading, isError, refetch } = useTenderDetailQuery(details, {
|
const { data, isLoading, isError } = useTenderDetailQuery(details, {
|
||||||
lang: selectedLanguage,
|
lang: selectedLanguage,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,29 +65,21 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
const activeSectionId = useTenderSectionScrollSpy(sectionIds);
|
const activeSectionId = useTenderSectionScrollSpy(sectionIds);
|
||||||
const handleLanguageChange = useCallback(
|
const handleLanguageChange = useCallback(
|
||||||
(event: React.ChangeEvent<HTMLSelectElement>) => {
|
(event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
const nextParams = new URLSearchParams(searchParams.toString());
|
|
||||||
const nextLanguage = event.target.value;
|
const nextLanguage = event.target.value;
|
||||||
if (nextLanguage) {
|
if (!nextLanguage) return;
|
||||||
|
|
||||||
|
const prevLanguage = searchParams.get("lang") || "en";
|
||||||
|
if (nextLanguage === prevLanguage) return;
|
||||||
|
|
||||||
|
const nextParams = new URLSearchParams(searchParams.toString());
|
||||||
nextParams.set("lang", nextLanguage);
|
nextParams.set("lang", nextLanguage);
|
||||||
} else {
|
|
||||||
nextParams.delete("lang");
|
|
||||||
}
|
|
||||||
const queryString = nextParams.toString();
|
const queryString = nextParams.toString();
|
||||||
router.replace(queryString ? `${pathname}?${queryString}` : pathname);
|
router.replace(queryString ? `${pathname}?${queryString}` : pathname);
|
||||||
},
|
|
||||||
[pathname, router, searchParams],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTranslate = useCallback(() => {
|
translateTender({ id: details, language: nextLanguage });
|
||||||
translateTender(
|
|
||||||
{ id: details, language: selectedLanguage },
|
|
||||||
{
|
|
||||||
onSuccess: async () => {
|
|
||||||
await refetch();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
[details, pathname, router, searchParams, translateTender],
|
||||||
);
|
);
|
||||||
}, [details, refetch, selectedLanguage, translateTender]);
|
|
||||||
|
|
||||||
if (isLoading) return <Loading />;
|
if (isLoading) return <Loading />;
|
||||||
if (isError) {
|
if (isError) {
|
||||||
@@ -106,7 +98,6 @@ const TenderDetails = ({ params }: IProps) => {
|
|||||||
title={tender.title}
|
title={tender.title}
|
||||||
selectedLanguage={selectedLanguage}
|
selectedLanguage={selectedLanguage}
|
||||||
onLanguageChange={handleLanguageChange}
|
onLanguageChange={handleLanguageChange}
|
||||||
onTranslate={handleTranslate}
|
|
||||||
isTranslating={isTranslating}
|
isTranslating={isTranslating}
|
||||||
status={tender.status}
|
status={tender.status}
|
||||||
/>
|
/>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -139,7 +139,7 @@ const DatePickerRHFInput = <T extends FieldValues>({
|
|||||||
}
|
}
|
||||||
onlyYearPicker={onlyYearPicker}
|
onlyYearPicker={onlyYearPicker}
|
||||||
multiple={multiple}
|
multiple={multiple}
|
||||||
{...(portal ? { portal: true } : {})}
|
{...(portal ? { portal: true, zIndex: 99999 } : {})}
|
||||||
value={pickerValue}
|
value={pickerValue}
|
||||||
onChange={(date: DateObject | DateObject[] | null) => {
|
onChange={(date: DateObject | DateObject[] | null) => {
|
||||||
if (!date) {
|
if (!date) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type PropsType<T extends FieldValues> = {
|
|||||||
onClear?: () => void;
|
onClear?: () => void;
|
||||||
value?: string;
|
value?: string;
|
||||||
dataCy?: string;
|
dataCy?: string;
|
||||||
|
disabled?: boolean;
|
||||||
} & (
|
} & (
|
||||||
| { placeholder?: string; defaultValue?: string }
|
| { placeholder?: string; defaultValue?: string }
|
||||||
| { placeholder: string; defaultValue?: string }
|
| { placeholder: string; defaultValue?: string }
|
||||||
@@ -50,6 +51,7 @@ export function Select<T extends FieldValues>({
|
|||||||
onClear,
|
onClear,
|
||||||
value: controlledValue,
|
value: controlledValue,
|
||||||
dataCy,
|
dataCy,
|
||||||
|
disabled = false,
|
||||||
...props
|
...props
|
||||||
}: PropsType<T>) {
|
}: PropsType<T>) {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
@@ -100,6 +102,7 @@ export function Select<T extends FieldValues>({
|
|||||||
id={id}
|
id={id}
|
||||||
name={name}
|
name={name}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
disabled={disabled}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (controlledValue === undefined) {
|
if (controlledValue === undefined) {
|
||||||
@@ -113,6 +116,8 @@ export function Select<T extends FieldValues>({
|
|||||||
className={cn(
|
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",
|
"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",
|
isOptionSelected && "text-dark dark:text-white",
|
||||||
|
disabled &&
|
||||||
|
"cursor-not-allowed opacity-60 hover:border-stroke/70 dark:hover:border-dark-3/80",
|
||||||
error &&
|
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)]",
|
"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",
|
prefixIcon && "pl-11.5",
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SearchIcon } from "@/assets/icons";
|
||||||
import InputGroup from "@/components/FormElements/InputGroup";
|
import InputGroup from "@/components/FormElements/InputGroup";
|
||||||
import { Select } from "@/components/FormElements/select";
|
import { Select } from "@/components/FormElements/select";
|
||||||
|
import { Button } from "@/components/ui-elements/button";
|
||||||
|
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
||||||
import { AdminStatus } from "@/constants/enums";
|
import { AdminStatus } from "@/constants/enums";
|
||||||
import { getEnumAsArray } from "@/utils/shared";
|
import { getEnumAsArray } from "@/utils/shared";
|
||||||
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
FieldValues,
|
FieldValues,
|
||||||
UseFormHandleSubmit,
|
UseFormHandleSubmit,
|
||||||
@@ -12,103 +18,113 @@ import {
|
|||||||
|
|
||||||
interface AdminListFiltersProps {
|
interface AdminListFiltersProps {
|
||||||
filterRegister: UseFormRegister<FieldValues>;
|
filterRegister: UseFormRegister<FieldValues>;
|
||||||
setIsFilterModalOpen: (isOpen: boolean) => void;
|
|
||||||
isMutating: number;
|
isMutating: number;
|
||||||
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
|
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
|
||||||
search: (data: any) => void;
|
search: (data: any) => void;
|
||||||
watch: UseFormWatch<FieldValues>;
|
watch: UseFormWatch<FieldValues>;
|
||||||
setValue: UseFormSetValue<any>;
|
setValue: UseFormSetValue<any>;
|
||||||
|
onClearAll: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AdminListFilters = ({
|
function countActiveAdminFilters(values: Record<string, unknown>): 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,
|
filterRegister,
|
||||||
setIsFilterModalOpen,
|
|
||||||
isMutating,
|
isMutating,
|
||||||
handleFilterSubmit,
|
handleFilterSubmit,
|
||||||
search,
|
search,
|
||||||
watch,
|
watch,
|
||||||
setValue,
|
setValue,
|
||||||
}: AdminListFiltersProps) => {
|
onClearAll,
|
||||||
|
}: AdminListFiltersProps) {
|
||||||
|
const watched = watch();
|
||||||
|
const activeCount = useMemo(
|
||||||
|
() => countActiveAdminFilters(watched as Record<string, unknown>),
|
||||||
|
[watched],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<InlineListFiltersPanel
|
||||||
|
title="Filter admins"
|
||||||
|
activeFilterCount={activeCount}
|
||||||
|
expandAriaLabel="Expand admin filters"
|
||||||
|
collapseAriaLabel="Collapse admin filters"
|
||||||
|
panelToggleDataCy="admins-filter-panel-toggle"
|
||||||
|
>
|
||||||
<form
|
<form
|
||||||
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
|
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
|
||||||
onSubmit={handleFilterSubmit(search)}
|
onSubmit={handleFilterSubmit(search)}
|
||||||
data-cy="admins-filter-form"
|
data-cy="admins-filter-form"
|
||||||
>
|
>
|
||||||
<Select
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
|
||||||
|
<div className="min-h-[52px] flex-1">
|
||||||
|
<InputGroup
|
||||||
|
{...filterRegister("search")}
|
||||||
|
name="search"
|
||||||
|
label="Keyword search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Name, email, username…"
|
||||||
|
icon={
|
||||||
|
<SearchIcon className="text-primary dark:text-primary/90" />
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="small"
|
||||||
|
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
|
||||||
|
data-cy="admins-filter-submit-button"
|
||||||
|
label={
|
||||||
|
isMutating ? (
|
||||||
|
<span className="inline-flex h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
|
) : (
|
||||||
|
<span>Apply filters</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-cy="admins-filter-reset-button"
|
||||||
|
onClick={onClearAll}
|
||||||
|
className="min-h-[52px] rounded-2xl border border-stroke/70 bg-white/80 px-6 text-sm font-semibold text-dark transition hover:border-primary/40 hover:text-primary dark:border-dark-3 dark:bg-dark-3/60 dark:text-white dark:hover:bg-white/[0.06]"
|
||||||
|
>
|
||||||
|
Reset all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<Select<FieldValues>
|
||||||
{...filterRegister("status")}
|
{...filterRegister("status")}
|
||||||
items={getEnumAsArray(AdminStatus)}
|
items={getEnumAsArray(AdminStatus)}
|
||||||
label="status"
|
label="Status"
|
||||||
name="status"
|
name="status"
|
||||||
placeholder="Status"
|
placeholder="Any status"
|
||||||
clearable
|
clearable
|
||||||
value={watch("status")}
|
value={watch("status")}
|
||||||
onClear={() => setValue("status", undefined)}
|
onClear={() => setValue("status", undefined)}
|
||||||
dataCy="admins-filter-status-select"
|
dataCy="admins-filter-status-select"
|
||||||
/>
|
/>
|
||||||
<InputGroup
|
|
||||||
{...filterRegister("search")}
|
|
||||||
name="search"
|
|
||||||
label="search"
|
|
||||||
placeholder="search"
|
|
||||||
type="search"
|
|
||||||
dataCy="admins-filter-search-input"
|
|
||||||
/>
|
|
||||||
{/* <InputGroup
|
|
||||||
{...filterRegister("email")}
|
|
||||||
name="email"
|
|
||||||
label="email"
|
|
||||||
placeholder="email"
|
|
||||||
type="email"
|
|
||||||
/> */}
|
|
||||||
{/* <Select
|
|
||||||
{...filterRegister("role")}
|
|
||||||
name="role"
|
|
||||||
items={getEnumAsArray(AdminRoles)}
|
|
||||||
label="Role"
|
|
||||||
placeholder="Role"
|
|
||||||
clearable
|
|
||||||
value={watch("role")}
|
|
||||||
onClear={() => setValue("role", undefined)}
|
|
||||||
/> */}
|
|
||||||
{/* <InputGroup
|
|
||||||
{...filterRegister("username")}
|
|
||||||
label="username"
|
|
||||||
name="username"
|
|
||||||
placeholder="username"
|
|
||||||
type="text"
|
|
||||||
/> */}
|
|
||||||
{/* <InputGroup
|
|
||||||
{...filterRegister("full_name")}
|
|
||||||
label="full name"
|
|
||||||
name="full_name"
|
|
||||||
placeholder="full name"
|
|
||||||
type="text"
|
|
||||||
/> */}
|
|
||||||
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
data-cy="admins-filter-submit-button"
|
|
||||||
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
|
|
||||||
>
|
|
||||||
{isMutating ? (
|
|
||||||
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
|
|
||||||
) : (
|
|
||||||
<span>Search</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-cy="admins-filter-close-button"
|
|
||||||
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
|
|
||||||
onClick={() => setIsFilterModalOpen(false)}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</InlineListFiltersPanel>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default AdminListFilters;
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
import TableSkeleton from "@/components/ui/TableSkeleton";
|
import TableSkeleton from "@/components/ui/TableSkeleton";
|
||||||
import { AdminStatus } from "@/constants/enums";
|
import { AdminStatus } from "@/constants/enums";
|
||||||
import { _TooltipDefaultParams } from "@/constants/tooltip";
|
import { _TooltipDefaultParams } from "@/constants/tooltip";
|
||||||
import { getPaginatedRowNumber } from "@/utils/shared";
|
|
||||||
import { Tooltip } from "react-tooltip";
|
import { Tooltip } from "react-tooltip";
|
||||||
import AdminListFilters from "./AdminListFilters";
|
import AdminListFilters from "./AdminListFilters";
|
||||||
import ChangeStatusModalContent from "./ChangeStatusModalContent";
|
import ChangeStatusModalContent from "./ChangeStatusModalContent";
|
||||||
@@ -28,8 +27,7 @@ import { useAdminsPresenter } from "./useAdminsPresenter";
|
|||||||
|
|
||||||
const AdminsTable = () => {
|
const AdminsTable = () => {
|
||||||
const {
|
const {
|
||||||
allUsers,
|
usersList,
|
||||||
router,
|
|
||||||
isPending,
|
isPending,
|
||||||
onConfirmDelete,
|
onConfirmDelete,
|
||||||
isModalOpen,
|
isModalOpen,
|
||||||
@@ -37,9 +35,6 @@ const AdminsTable = () => {
|
|||||||
isChangeStatusModalOpen,
|
isChangeStatusModalOpen,
|
||||||
setIsChangeStatusModalOpen,
|
setIsChangeStatusModalOpen,
|
||||||
columns,
|
columns,
|
||||||
setCurrentUser,
|
|
||||||
isFilterModalOpen,
|
|
||||||
setIsFilterModalOpen,
|
|
||||||
changeStatus,
|
changeStatus,
|
||||||
currentUser,
|
currentUser,
|
||||||
setStatus,
|
setStatus,
|
||||||
@@ -50,18 +45,36 @@ const AdminsTable = () => {
|
|||||||
filterRegister,
|
filterRegister,
|
||||||
isMutating,
|
isMutating,
|
||||||
watch,
|
watch,
|
||||||
metadata,
|
|
||||||
setParams,
|
|
||||||
shouldShowPagination,
|
shouldShowPagination,
|
||||||
|
clearAllFilters,
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
getRowNumber,
|
||||||
|
pagination,
|
||||||
|
handlePaginationChange,
|
||||||
|
navigateToEditAdmin,
|
||||||
|
openDeleteModalForUser,
|
||||||
|
openChangeStatusModalForUser,
|
||||||
} = useAdminsPresenter();
|
} = useAdminsPresenter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListWrapper data-cy="admins-page">
|
<ListWrapper data-cy="admins-page">
|
||||||
<ListHeader
|
<ListHeader
|
||||||
onFilter={() => setIsFilterModalOpen(true)}
|
hasFilter={false}
|
||||||
createButtonText="Create Admin"
|
createButtonText="Create Admin"
|
||||||
dataCyPrefix="admins"
|
dataCyPrefix="admins"
|
||||||
/>
|
/>
|
||||||
|
<AdminListFilters
|
||||||
|
setValue={setFilterValue}
|
||||||
|
filterRegister={filterRegister}
|
||||||
|
handleFilterSubmit={handleFilterSubmit}
|
||||||
|
isMutating={isMutating as number}
|
||||||
|
search={search}
|
||||||
|
watch={watch}
|
||||||
|
onClearAll={clearAllFilters}
|
||||||
|
/>
|
||||||
|
<div ref={tableSectionRef}>
|
||||||
<Table data-cy="admins-table">
|
<Table data-cy="admins-table">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-none uppercase [&>th]:text-start">
|
<TableRow className="border-none uppercase [&>th]:text-start">
|
||||||
@@ -76,26 +89,25 @@ const AdminsTable = () => {
|
|||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
{isPending && <TableSkeleton column={columns.length} />}
|
|
||||||
<TableBody>
|
{isPending ? (
|
||||||
|
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
|
||||||
|
) : (
|
||||||
|
<TableBody ref={dataTbodyRef}>
|
||||||
<EmptyListWrapper
|
<EmptyListWrapper
|
||||||
list={allUsers}
|
list={usersList}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
emptyMessage="No admins were found"
|
emptyMessage="No admins were found"
|
||||||
isLoading={isPending}
|
isLoading={false}
|
||||||
>
|
>
|
||||||
{allUsers.map((item, index) => (
|
{usersList.map((item, index) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={item?.id || index}
|
key={item?.id || index}
|
||||||
className="odd:bg-gray-2 dark:odd:bg-gray-7"
|
className="odd:bg-gray-2 dark:odd:bg-gray-7"
|
||||||
data-cy={`admins-table-row-${item?.id ?? index}`}
|
data-cy={`admins-table-row-${item?.id ?? index}`}
|
||||||
>
|
>
|
||||||
<TableCell className="text-start" colSpan={100} data-cy="admins-row-number">
|
<TableCell className="text-start" colSpan={100} data-cy="admins-row-number">
|
||||||
{getPaginatedRowNumber({
|
{getRowNumber(index)}
|
||||||
index,
|
|
||||||
page: metadata?.page,
|
|
||||||
limit: metadata?.limit,
|
|
||||||
})}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-start" colSpan={100} data-cy="admins-row-full-name">
|
<TableCell className="text-start" colSpan={100} data-cy="admins-row-full-name">
|
||||||
{item?.full_name}
|
{item?.full_name}
|
||||||
@@ -114,30 +126,26 @@ const AdminsTable = () => {
|
|||||||
<TableCell className="text-start xl:pr-7.5" data-cy="admins-row-actions">
|
<TableCell className="text-start xl:pr-7.5" data-cy="admins-row-actions">
|
||||||
<div className="flex items-center justify-start gap-x-3.5">
|
<div className="flex items-center justify-start gap-x-3.5">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
data-tooltip-id="delete"
|
data-tooltip-id="delete"
|
||||||
data-tooltip-content="Delete"
|
data-tooltip-content="Delete"
|
||||||
data-tooltip-place="top"
|
data-tooltip-place="top"
|
||||||
className="hover:text-red-500"
|
className="hover:text-red-500"
|
||||||
data-cy={`admins-row-delete-${item?.id ?? index}`}
|
data-cy={`admins-row-delete-${item?.id ?? index}`}
|
||||||
onClick={() => {
|
onClick={() => openDeleteModalForUser(item)}
|
||||||
setCurrentUser(item);
|
|
||||||
setIsModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
|
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
|
||||||
<span className="sr-only">Delete Admin </span>
|
<span className="sr-only">Delete Admin </span>
|
||||||
<TrashIcon />
|
<TrashIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
data-tooltip-id="change-status"
|
data-tooltip-id="change-status"
|
||||||
data-tooltip-content="Change status"
|
data-tooltip-content="Change status"
|
||||||
data-tooltip-place="top"
|
data-tooltip-place="top"
|
||||||
className="hover:text-yellow-dark"
|
className="hover:text-yellow-dark"
|
||||||
data-cy={`admins-row-manage-status-${item?.id ?? index}`}
|
data-cy={`admins-row-manage-status-${item?.id ?? index}`}
|
||||||
onClick={() => {
|
onClick={() => openChangeStatusModalForUser(item)}
|
||||||
setCurrentUser(item);
|
|
||||||
setIsChangeStatusModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
{..._TooltipDefaultParams({ id: "change-status" })}
|
{..._TooltipDefaultParams({ id: "change-status" })}
|
||||||
@@ -146,12 +154,13 @@ const AdminsTable = () => {
|
|||||||
<UserSettingIcon />
|
<UserSettingIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
data-tooltip-id="edit"
|
data-tooltip-id="edit"
|
||||||
data-tooltip-content="Edit"
|
data-tooltip-content="Edit"
|
||||||
data-tooltip-place="top"
|
data-tooltip-place="top"
|
||||||
className="hover:text-primary"
|
className="hover:text-primary"
|
||||||
data-cy={`admins-row-edit-${item?.id ?? index}`}
|
data-cy={`admins-row-edit-${item?.id ?? index}`}
|
||||||
onClick={() => router.push(`/admins/edit/${item.id}`)}
|
onClick={() => navigateToEditAdmin(item.id)}
|
||||||
>
|
>
|
||||||
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
|
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
|
||||||
<span className="sr-only">Edit Admin </span>
|
<span className="sr-only">Edit Admin </span>
|
||||||
@@ -163,7 +172,9 @@ const AdminsTable = () => {
|
|||||||
))}
|
))}
|
||||||
</EmptyListWrapper>
|
</EmptyListWrapper>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isChangeStatusModalOpen}
|
isOpen={isChangeStatusModalOpen}
|
||||||
onClose={() => setIsChangeStatusModalOpen(false)}
|
onClose={() => setIsChangeStatusModalOpen(false)}
|
||||||
@@ -179,22 +190,6 @@ const AdminsTable = () => {
|
|||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
|
||||||
isOpen={isFilterModalOpen}
|
|
||||||
onClose={() => setIsFilterModalOpen(false)}
|
|
||||||
classNames="w-full xl:w-1/2"
|
|
||||||
showButtons={false}
|
|
||||||
>
|
|
||||||
<AdminListFilters
|
|
||||||
setValue={setFilterValue}
|
|
||||||
filterRegister={filterRegister}
|
|
||||||
handleFilterSubmit={handleFilterSubmit}
|
|
||||||
isMutating={isMutating as number}
|
|
||||||
search={search}
|
|
||||||
setIsFilterModalOpen={setIsFilterModalOpen}
|
|
||||||
watch={watch}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
@@ -212,15 +207,9 @@ const AdminsTable = () => {
|
|||||||
)}
|
)}
|
||||||
<IsVisible condition={shouldShowPagination}>
|
<IsVisible condition={shouldShowPagination}>
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={metadata?.page ? metadata.page - 1 : 0}
|
currentPage={pagination.currentPage}
|
||||||
totalPages={metadata?.pages ?? 1}
|
totalPages={pagination.totalPages}
|
||||||
onPageChange={(e) => {
|
onPageChange={handlePaginationChange}
|
||||||
setParams((currentParams) => ({
|
|
||||||
...currentParams,
|
|
||||||
offset: e.selected * (metadata?.limit ?? 10),
|
|
||||||
limit: metadata?.limit ?? 10,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</IsVisible>
|
</IsVisible>
|
||||||
</ListWrapper>
|
</ListWrapper>
|
||||||
|
|||||||
@@ -8,15 +8,22 @@ import {
|
|||||||
useGetAdminListQuery,
|
useGetAdminListQuery,
|
||||||
} from "@/hooks/queries";
|
} from "@/hooks/queries";
|
||||||
import { IUser, TChangeAdminStatusCredentials } from "@/lib/api";
|
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 { useIsMutating } from "@tanstack/react-query";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
export const useAdminsPresenter = () => {
|
export const useAdminsPresenter = () => {
|
||||||
const [isChangeStatusModalOpen, setIsChangeStatusModalOpen] = useState(false);
|
const [isChangeStatusModalOpen, setIsChangeStatusModalOpen] = useState(false);
|
||||||
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
|
|
||||||
const [currentUser, setCurrentUser] = useState<IUser | null>(null);
|
const [currentUser, setCurrentUser] = useState<IUser | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [status, setStatus] = useState<string>(
|
const [status, setStatus] = useState<string>(
|
||||||
@@ -46,8 +53,12 @@ export const useAdminsPresenter = () => {
|
|||||||
const [params, setParams] = useState<Record<string, any>>({});
|
const [params, setParams] = useState<Record<string, any>>({});
|
||||||
const pathName = usePathname();
|
const pathName = usePathname();
|
||||||
|
|
||||||
|
const tableSectionRef = useRef<HTMLDivElement>(null);
|
||||||
|
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||||
|
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newParams: Record<string, any> = {
|
const newParams: Record<string, unknown> = {
|
||||||
...apiDefaultParams,
|
...apiDefaultParams,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
@@ -58,12 +69,112 @@ export const useAdminsPresenter = () => {
|
|||||||
setParams(newParams);
|
setParams(newParams);
|
||||||
filterFormReset(newParams);
|
filterFormReset(newParams);
|
||||||
}, [searchParams, filterFormReset]);
|
}, [searchParams, filterFormReset]);
|
||||||
|
|
||||||
const { data, isPending } = useGetAdminListQuery({
|
const { data, isPending } = useGetAdminListQuery({
|
||||||
...apiDefaultParams,
|
...apiDefaultParams,
|
||||||
...params,
|
...params,
|
||||||
});
|
});
|
||||||
|
|
||||||
const shouldShowPagination =
|
const shouldShowPagination =
|
||||||
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
||||||
|
|
||||||
|
const 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(() => {
|
const { mutate: deleteAdmin } = useDeleteAdmin(() => {
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
});
|
});
|
||||||
@@ -73,6 +184,7 @@ export const useAdminsPresenter = () => {
|
|||||||
deleteAdmin(currentUser.id);
|
deleteAdmin(currentUser.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutate: changeStatus } = useChangeAdminStatus({
|
const { mutate: changeStatus } = useChangeAdminStatus({
|
||||||
id: currentUser?.id ?? "",
|
id: currentUser?.id ?? "",
|
||||||
successCallback: () => {
|
successCallback: () => {
|
||||||
@@ -80,15 +192,17 @@ export const useAdminsPresenter = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const search = (data: any) => {
|
const search = (formData: any) => {
|
||||||
const newParams = { ...params, ...data, offset: 0 };
|
const newParams = { ...params, ...formData, offset: 0 };
|
||||||
const cleanedParams = deleteEmptyKeys(newParams);
|
const cleanedParams = deleteEmptyKeys(newParams);
|
||||||
const queryString = new URLSearchParams(cleanedParams).toString();
|
const queryString = new URLSearchParams(cleanedParams).toString();
|
||||||
router.push(`${pathName}?${queryString}`);
|
router.push(`${pathName}?${queryString}`);
|
||||||
setIsFilterModalOpen(false);
|
|
||||||
};
|
};
|
||||||
const allUsers = data?.data?.users ?? [];
|
|
||||||
const metadata = data?.meta;
|
const clearAllFilters = useCallback(() => {
|
||||||
|
router.push(pathName);
|
||||||
|
}, [pathName, router]);
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
"row",
|
"row",
|
||||||
"full name",
|
"full name",
|
||||||
@@ -97,8 +211,10 @@ export const useAdminsPresenter = () => {
|
|||||||
"status",
|
"status",
|
||||||
"actions",
|
"actions",
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
allUsers,
|
allUsers: usersList,
|
||||||
|
usersList,
|
||||||
setFilterValue,
|
setFilterValue,
|
||||||
metadata,
|
metadata,
|
||||||
columns,
|
columns,
|
||||||
@@ -107,7 +223,6 @@ export const useAdminsPresenter = () => {
|
|||||||
isModalOpen,
|
isModalOpen,
|
||||||
setIsModalOpen,
|
setIsModalOpen,
|
||||||
isMutating,
|
isMutating,
|
||||||
router,
|
|
||||||
setCurrentUser,
|
setCurrentUser,
|
||||||
pathName,
|
pathName,
|
||||||
status,
|
status,
|
||||||
@@ -119,13 +234,21 @@ export const useAdminsPresenter = () => {
|
|||||||
register,
|
register,
|
||||||
errors,
|
errors,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
isFilterModalOpen,
|
|
||||||
setIsFilterModalOpen,
|
|
||||||
setParams,
|
setParams,
|
||||||
filterRegister,
|
filterRegister,
|
||||||
handleFilterSubmit,
|
handleFilterSubmit,
|
||||||
search,
|
search,
|
||||||
watch,
|
watch,
|
||||||
shouldShowPagination,
|
shouldShowPagination,
|
||||||
|
clearAllFilters,
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
getRowNumber,
|
||||||
|
pagination,
|
||||||
|
handlePaginationChange,
|
||||||
|
navigateToEditAdmin,
|
||||||
|
openDeleteModalForUser,
|
||||||
|
openChangeStatusModalForUser,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import InputGroup from "@/components/FormElements/InputGroup";
|
import InputGroup from "@/components/FormElements/InputGroup";
|
||||||
|
import { Button } from "@/components/ui-elements/button";
|
||||||
|
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
||||||
import {
|
import {
|
||||||
FieldValues,
|
FieldValues,
|
||||||
UseFormHandleSubmit,
|
UseFormHandleSubmit,
|
||||||
@@ -6,64 +10,98 @@ import {
|
|||||||
UseFormSetValue,
|
UseFormSetValue,
|
||||||
UseFormWatch,
|
UseFormWatch,
|
||||||
} from "react-hook-form";
|
} from "react-hook-form";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
interface CmsListFiltersProps {
|
interface CmsListFiltersProps {
|
||||||
filterRegister: UseFormRegister<FieldValues>;
|
filterRegister: UseFormRegister<FieldValues>;
|
||||||
setIsFilterModalOpen: (isOpen: boolean) => void;
|
|
||||||
isMutating: number;
|
isMutating: number;
|
||||||
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
|
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
|
||||||
search: (data: any) => void;
|
search: (data: any) => void;
|
||||||
watch: UseFormWatch<FieldValues>;
|
watch: UseFormWatch<FieldValues>;
|
||||||
setValue: UseFormSetValue<any>;
|
setValue: UseFormSetValue<any>;
|
||||||
|
onClearAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countActive(values: Record<string, unknown>): 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 = ({
|
const CmsListFilters = ({
|
||||||
filterRegister,
|
filterRegister,
|
||||||
setIsFilterModalOpen,
|
|
||||||
isMutating,
|
isMutating,
|
||||||
handleFilterSubmit,
|
handleFilterSubmit,
|
||||||
search,
|
search,
|
||||||
|
watch,
|
||||||
|
onClearAll,
|
||||||
}: CmsListFiltersProps) => {
|
}: CmsListFiltersProps) => {
|
||||||
|
const watched = watch();
|
||||||
|
const activeCount = useMemo(
|
||||||
|
() => countActive(watched as Record<string, unknown>),
|
||||||
|
[watched],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<InlineListFiltersPanel
|
||||||
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
|
title="Filter CMS entries"
|
||||||
onSubmit={handleFilterSubmit(search)}
|
description="Search by keyword or exact key — inline, no popup."
|
||||||
|
activeFilterCount={activeCount}
|
||||||
|
expandAriaLabel="Expand CMS filters"
|
||||||
|
collapseAriaLabel="Collapse CMS filters"
|
||||||
|
panelToggleDataCy="cms-filter-panel-toggle"
|
||||||
>
|
>
|
||||||
|
<form
|
||||||
|
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
|
||||||
|
onSubmit={handleFilterSubmit(search)}
|
||||||
|
data-cy="cms-filter-form"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<InputGroup
|
<InputGroup
|
||||||
{...filterRegister("q")}
|
{...filterRegister("q")}
|
||||||
name="q"
|
name="q"
|
||||||
label="search"
|
label="Keyword search"
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
type="search"
|
type="search"
|
||||||
|
dataCy="cms-filter-q-input"
|
||||||
/>
|
/>
|
||||||
<InputGroup
|
<InputGroup
|
||||||
{...filterRegister("key")}
|
{...filterRegister("key")}
|
||||||
name="key"
|
name="key"
|
||||||
label="key"
|
label="Key"
|
||||||
placeholder="Key"
|
placeholder="Key"
|
||||||
type="text"
|
type="text"
|
||||||
|
dataCy="cms-filter-key-input"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
|
size="small"
|
||||||
>
|
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
|
||||||
{isMutating ? (
|
data-cy="cms-filter-submit-button"
|
||||||
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
|
label={
|
||||||
|
isMutating ? (
|
||||||
|
<span className="inline-flex h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
) : (
|
) : (
|
||||||
<span>Search</span>
|
<span>Apply filters</span>
|
||||||
)}
|
)
|
||||||
</button>
|
}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
|
data-cy="cms-filter-reset-button"
|
||||||
onClick={() => setIsFilterModalOpen(false)}
|
onClick={onClearAll}
|
||||||
|
className="min-h-[52px] rounded-2xl border border-stroke/70 bg-white/80 px-6 text-sm font-semibold text-dark transition hover:border-primary/40 hover:text-primary dark:border-dark-3 dark:bg-dark-3/60 dark:text-white dark:hover:bg-white/[0.06]"
|
||||||
>
|
>
|
||||||
Close
|
Reset all
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</InlineListFiltersPanel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { PencilSquareIcon, TrashIcon } from "@/assets/icons";
|
|||||||
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
||||||
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
||||||
import ListHeader from "@/components/ui/ListHeader";
|
import ListHeader from "@/components/ui/ListHeader";
|
||||||
import Modal from "@/components/ui/modal";
|
import ListWrapper from "@/components/ui/ListWrapper";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -18,7 +18,6 @@ import { getPaginatedRowNumber, unixToDate } from "@/utils/shared";
|
|||||||
import { Tooltip } from "react-tooltip";
|
import { Tooltip } from "react-tooltip";
|
||||||
import CmsListFilters from "./CmsListFilters";
|
import CmsListFilters from "./CmsListFilters";
|
||||||
import useCmsTablePresenter from "./useCmsTablePresenter";
|
import useCmsTablePresenter from "./useCmsTablePresenter";
|
||||||
import ListWrapper from "@/components/ui/ListWrapper";
|
|
||||||
|
|
||||||
const CmsTable = () => {
|
const CmsTable = () => {
|
||||||
const {
|
const {
|
||||||
@@ -32,18 +31,30 @@ const CmsTable = () => {
|
|||||||
onConfirmDelete,
|
onConfirmDelete,
|
||||||
router,
|
router,
|
||||||
pathname,
|
pathname,
|
||||||
isFilterModalOpen,
|
|
||||||
setIsFilterModalOpen,
|
|
||||||
filterRegister,
|
filterRegister,
|
||||||
handleFilterSubmit,
|
handleFilterSubmit,
|
||||||
search,
|
search,
|
||||||
watch,
|
watch,
|
||||||
setFilterValue,
|
setFilterValue,
|
||||||
isMutating,
|
isMutating,
|
||||||
|
clearAllFilters,
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
} = useCmsTablePresenter();
|
} = useCmsTablePresenter();
|
||||||
return (
|
return (
|
||||||
<ListWrapper>
|
<ListWrapper>
|
||||||
<ListHeader onFilter={() => setIsFilterModalOpen(true)} />
|
<ListHeader hasFilter={false} />
|
||||||
|
<CmsListFilters
|
||||||
|
setValue={setFilterValue}
|
||||||
|
filterRegister={filterRegister}
|
||||||
|
handleFilterSubmit={handleFilterSubmit}
|
||||||
|
isMutating={isMutating as number}
|
||||||
|
search={search}
|
||||||
|
watch={watch}
|
||||||
|
onClearAll={clearAllFilters}
|
||||||
|
/>
|
||||||
|
<div ref={tableSectionRef}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-none uppercase [&>th]:text-start">
|
<TableRow className="border-none uppercase [&>th]:text-start">
|
||||||
@@ -54,12 +65,15 @@ const CmsTable = () => {
|
|||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
{isPending && <TableSkeleton column={columns.length} />}
|
{isPending ? (
|
||||||
<TableBody>
|
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
|
||||||
|
) : (
|
||||||
|
<TableBody ref={dataTbodyRef}>
|
||||||
<EmptyListWrapper
|
<EmptyListWrapper
|
||||||
list={data?.data.cms ?? []}
|
list={data?.data.cms ?? []}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
emptyMessage="No Cms were found"
|
emptyMessage="No Cms were found"
|
||||||
|
isLoading={false}
|
||||||
>
|
>
|
||||||
{data?.data.cms.map((item, index) => (
|
{data?.data.cms.map((item, index) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
@@ -100,7 +114,9 @@ const CmsTable = () => {
|
|||||||
data-tooltip-content="Edit"
|
data-tooltip-content="Edit"
|
||||||
data-tooltip-place="top"
|
data-tooltip-place="top"
|
||||||
className="hover:text-primary"
|
className="hover:text-primary"
|
||||||
onClick={() => router.push(`${pathname}/edit/${item.id}`)}
|
onClick={() =>
|
||||||
|
router.push(`${pathname}/edit/${item.id}`)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
|
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
|
||||||
<span className="sr-only">Edit Marketing </span>
|
<span className="sr-only">Edit Marketing </span>
|
||||||
@@ -112,23 +128,9 @@ const CmsTable = () => {
|
|||||||
))}
|
))}
|
||||||
</EmptyListWrapper>
|
</EmptyListWrapper>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
<Modal
|
</div>
|
||||||
isOpen={isFilterModalOpen}
|
|
||||||
onClose={() => setIsFilterModalOpen(false)}
|
|
||||||
classNames="w-full xl:w-1/2"
|
|
||||||
showButtons={false}
|
|
||||||
>
|
|
||||||
<CmsListFilters
|
|
||||||
setValue={setFilterValue}
|
|
||||||
filterRegister={filterRegister}
|
|
||||||
handleFilterSubmit={handleFilterSubmit}
|
|
||||||
isMutating={isMutating as number}
|
|
||||||
search={search}
|
|
||||||
setIsFilterModalOpen={setIsFilterModalOpen}
|
|
||||||
watch={watch}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
||||||
import { useDeleteCms, useGetCmsList } from "@/hooks/queries/useCmsQueries";
|
import { useDeleteCms, useGetCmsList } from "@/hooks/queries/useCmsQueries";
|
||||||
import { TCms } from "@/lib/api/types/TCms";
|
import { TCms } from "@/lib/api/types/TCms";
|
||||||
import { deleteEmptyKeys } from "@/utils/shared";
|
import { deleteEmptyKeys } from "@/utils/shared";
|
||||||
import { useIsMutating } from "@tanstack/react-query";
|
import { useIsMutating } from "@tanstack/react-query";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
const useCmsTablePresenter = () => {
|
const useCmsTablePresenter = () => {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
|
|
||||||
const [currentCms, setCurrentCms] = useState<TCms | null>(null);
|
const [currentCms, setCurrentCms] = useState<TCms | null>(null);
|
||||||
const [params, setParams] = useState<Record<string, any>>({});
|
const [params, setParams] = useState<Record<string, any>>({});
|
||||||
const columns = ["row", "key", "created at", "actions"];
|
const columns = ["row", "key", "created at", "actions"];
|
||||||
@@ -26,6 +26,10 @@ const useCmsTablePresenter = () => {
|
|||||||
setValue: setFilterValue,
|
setValue: setFilterValue,
|
||||||
} = useForm();
|
} = useForm();
|
||||||
|
|
||||||
|
const tableSectionRef = useRef<HTMLDivElement>(null);
|
||||||
|
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||||
|
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newParams: Record<string, any> = {};
|
const newParams: Record<string, any> = {};
|
||||||
for (const [key, value] of searchParams.entries()) {
|
for (const [key, value] of searchParams.entries()) {
|
||||||
@@ -37,19 +41,28 @@ const useCmsTablePresenter = () => {
|
|||||||
|
|
||||||
const { isPending, data } = useGetCmsList(params);
|
const { isPending, data } = useGetCmsList(params);
|
||||||
|
|
||||||
|
useTableSectionRowAnimations(isPending, data, {
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
});
|
||||||
|
|
||||||
const { mutate: deleteCms } = useDeleteCms(() => {
|
const { mutate: deleteCms } = useDeleteCms(() => {
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
setCurrentCms(null);
|
setCurrentCms(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
const search = (data: any) => {
|
const search = (formData: any) => {
|
||||||
const newParams = { ...params, ...data };
|
const newParams = { ...params, ...formData };
|
||||||
const cleanedParams = deleteEmptyKeys(newParams);
|
const cleanedParams = deleteEmptyKeys(newParams);
|
||||||
const queryString = new URLSearchParams(cleanedParams).toString();
|
const queryString = new URLSearchParams(cleanedParams).toString();
|
||||||
router.push(`${pathname}?${queryString}`);
|
router.push(`${pathname}?${queryString}`);
|
||||||
setIsFilterModalOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearAllFilters = useCallback(() => {
|
||||||
|
router.push(pathname);
|
||||||
|
}, [pathname, router]);
|
||||||
|
|
||||||
const onConfirmDelete = () => {
|
const onConfirmDelete = () => {
|
||||||
if (!currentCms?.id) return;
|
if (!currentCms?.id) return;
|
||||||
deleteCms(currentCms.id);
|
deleteCms(currentCms.id);
|
||||||
@@ -65,14 +78,16 @@ const useCmsTablePresenter = () => {
|
|||||||
onConfirmDelete,
|
onConfirmDelete,
|
||||||
router,
|
router,
|
||||||
pathname,
|
pathname,
|
||||||
isFilterModalOpen,
|
|
||||||
setIsFilterModalOpen,
|
|
||||||
filterRegister,
|
filterRegister,
|
||||||
handleFilterSubmit,
|
handleFilterSubmit,
|
||||||
search,
|
search,
|
||||||
watch,
|
watch,
|
||||||
setFilterValue,
|
setFilterValue,
|
||||||
isMutating,
|
isMutating,
|
||||||
|
clearAllFilters,
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import InputGroup from "@/components/FormElements/InputGroup";
|
import InputGroup from "@/components/FormElements/InputGroup";
|
||||||
import { Select } from "@/components/FormElements/select";
|
import { Select } from "@/components/FormElements/select";
|
||||||
|
import { Button } from "@/components/ui-elements/button";
|
||||||
|
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
||||||
import { Currencies } from "@/constants/enums";
|
import { Currencies } from "@/constants/enums";
|
||||||
import {
|
import {
|
||||||
FieldValues,
|
FieldValues,
|
||||||
@@ -8,56 +12,91 @@ import {
|
|||||||
UseFormSetValue,
|
UseFormSetValue,
|
||||||
UseFormWatch,
|
UseFormWatch,
|
||||||
} from "react-hook-form";
|
} from "react-hook-form";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
interface CompanyListFiltersProps {
|
interface CompanyListFiltersProps {
|
||||||
filterRegister: UseFormRegister<FieldValues>;
|
filterRegister: UseFormRegister<FieldValues>;
|
||||||
setIsFilterModalOpen: (isOpen: boolean) => void;
|
|
||||||
isMutating: number;
|
isMutating: number;
|
||||||
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
|
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
|
||||||
search: (data: any) => void;
|
search: (data: any) => void;
|
||||||
watch: UseFormWatch<FieldValues>;
|
watch: UseFormWatch<FieldValues>;
|
||||||
setValue: UseFormSetValue<any>;
|
setValue: UseFormSetValue<any>;
|
||||||
|
onClearAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countActive(values: Record<string, unknown>): 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 = ({
|
const CompanyListFilters = ({
|
||||||
filterRegister,
|
filterRegister,
|
||||||
setIsFilterModalOpen,
|
|
||||||
isMutating,
|
isMutating,
|
||||||
handleFilterSubmit,
|
handleFilterSubmit,
|
||||||
search,
|
search,
|
||||||
watch,
|
watch,
|
||||||
setValue,
|
setValue,
|
||||||
|
onClearAll,
|
||||||
}: CompanyListFiltersProps) => {
|
}: CompanyListFiltersProps) => {
|
||||||
|
const watched = watch();
|
||||||
|
const activeCount = useMemo(
|
||||||
|
() => countActive(watched as Record<string, unknown>),
|
||||||
|
[watched],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<InlineListFiltersPanel
|
||||||
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
|
title="Filter companies"
|
||||||
onSubmit={handleFilterSubmit(search)}
|
description="Filter by identity, currency, or headcount — inline, no popup."
|
||||||
|
activeFilterCount={activeCount}
|
||||||
|
expandAriaLabel="Expand company filters"
|
||||||
|
collapseAriaLabel="Collapse company filters"
|
||||||
|
panelToggleDataCy="companies-filter-panel-toggle"
|
||||||
>
|
>
|
||||||
|
<form
|
||||||
|
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
|
||||||
|
onSubmit={handleFilterSubmit(search)}
|
||||||
|
data-cy="companies-filter-form"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
<InputGroup
|
<InputGroup
|
||||||
{...filterRegister("name")}
|
{...filterRegister("name")}
|
||||||
name="name"
|
name="name"
|
||||||
label="name"
|
label="Name"
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
<InputGroup
|
<InputGroup
|
||||||
{...filterRegister("email")}
|
{...filterRegister("email")}
|
||||||
name="email"
|
name="email"
|
||||||
label="email"
|
label="Email"
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
type="email"
|
type="email"
|
||||||
/>
|
/>
|
||||||
<InputGroup
|
<InputGroup
|
||||||
{...filterRegister("phone")}
|
{...filterRegister("phone")}
|
||||||
name="phone"
|
name="phone"
|
||||||
label="phone"
|
label="Phone"
|
||||||
placeholder="Phone"
|
placeholder="Phone"
|
||||||
type="tel"
|
type="tel"
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
{...filterRegister("currency")}
|
{...filterRegister("currency")}
|
||||||
items={Currencies}
|
items={Currencies}
|
||||||
label="currency"
|
label="Currency"
|
||||||
name="currency"
|
name="currency"
|
||||||
placeholder="Currency"
|
placeholder="Currency"
|
||||||
clearable
|
clearable
|
||||||
@@ -67,31 +106,36 @@ const CompanyListFilters = ({
|
|||||||
<InputGroup
|
<InputGroup
|
||||||
{...filterRegister("employee_count")}
|
{...filterRegister("employee_count")}
|
||||||
name="employee_count"
|
name="employee_count"
|
||||||
label="employee count"
|
label="Employee count"
|
||||||
placeholder="Employee Count"
|
placeholder="Employee count"
|
||||||
type="number"
|
type="number"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex gap-4 col-span-2">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
|
size="small"
|
||||||
>
|
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
|
||||||
{isMutating ? (
|
data-cy="companies-filter-submit-button"
|
||||||
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
|
label={
|
||||||
|
isMutating ? (
|
||||||
|
<span className="inline-flex h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
) : (
|
) : (
|
||||||
<span>Search</span>
|
<span>Apply filters</span>
|
||||||
)}
|
)
|
||||||
</button>
|
}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
|
data-cy="companies-filter-reset-button"
|
||||||
onClick={() => setIsFilterModalOpen(false)}
|
onClick={onClearAll}
|
||||||
|
className="min-h-[52px] rounded-2xl border border-stroke/70 bg-white/80 px-6 text-sm font-semibold text-dark transition hover:border-primary/40 hover:text-primary dark:border-dark-3 dark:bg-dark-3/60 dark:text-white dark:hover:bg-white/[0.06]"
|
||||||
>
|
>
|
||||||
Close
|
Reset all
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</InlineListFiltersPanel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import InputGroup from "@/components/FormElements/InputGroup";
|
import InputGroup from "@/components/FormElements/InputGroup";
|
||||||
import { Select } from "@/components/FormElements/select";
|
import { Select } from "@/components/FormElements/select";
|
||||||
|
import { Button } from "@/components/ui-elements/button";
|
||||||
|
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
||||||
import {
|
import {
|
||||||
FieldValues,
|
FieldValues,
|
||||||
UseFormHandleSubmit,
|
UseFormHandleSubmit,
|
||||||
@@ -7,36 +11,63 @@ import {
|
|||||||
UseFormSetValue,
|
UseFormSetValue,
|
||||||
UseFormWatch,
|
UseFormWatch,
|
||||||
} from "react-hook-form";
|
} from "react-hook-form";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
interface CompanyCategoryListFiltersProps {
|
interface CompanyCategoryListFiltersProps {
|
||||||
filterRegister: UseFormRegister<FieldValues>;
|
filterRegister: UseFormRegister<FieldValues>;
|
||||||
setIsFilterModalOpen: (isOpen: boolean) => void;
|
|
||||||
isMutating: number;
|
isMutating: number;
|
||||||
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
|
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
|
||||||
search: (data: any) => void;
|
search: (data: any) => void;
|
||||||
watch: UseFormWatch<FieldValues>;
|
watch: UseFormWatch<FieldValues>;
|
||||||
setValue: UseFormSetValue<any>;
|
setValue: UseFormSetValue<any>;
|
||||||
|
onClearAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countActive(values: Record<string, unknown>): 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 = ({
|
const CompanyCategoryListFilters = ({
|
||||||
filterRegister,
|
filterRegister,
|
||||||
setIsFilterModalOpen,
|
|
||||||
isMutating,
|
isMutating,
|
||||||
handleFilterSubmit,
|
handleFilterSubmit,
|
||||||
search,
|
search,
|
||||||
watch,
|
watch,
|
||||||
setValue,
|
setValue,
|
||||||
|
onClearAll,
|
||||||
}: CompanyCategoryListFiltersProps) => {
|
}: CompanyCategoryListFiltersProps) => {
|
||||||
|
const watched = watch();
|
||||||
|
const activeCount = useMemo(
|
||||||
|
() => countActive(watched as Record<string, unknown>),
|
||||||
|
[watched],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<InlineListFiltersPanel
|
||||||
|
title="Filter categories"
|
||||||
|
description="Keyword search and published state — inline, no popup."
|
||||||
|
activeFilterCount={activeCount}
|
||||||
|
expandAriaLabel="Expand category filters"
|
||||||
|
collapseAriaLabel="Collapse category filters"
|
||||||
|
panelToggleDataCy="company-categories-filter-panel-toggle"
|
||||||
|
>
|
||||||
<form
|
<form
|
||||||
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
|
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
|
||||||
data-cy="company-categories-filter-form"
|
data-cy="company-categories-filter-form"
|
||||||
onSubmit={handleFilterSubmit(search)}
|
onSubmit={handleFilterSubmit(search)}
|
||||||
>
|
>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<InputGroup
|
<InputGroup
|
||||||
{...filterRegister("q")}
|
{...filterRegister("q")}
|
||||||
name="q"
|
name="q"
|
||||||
label="search"
|
label="Keyword search"
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
type="search"
|
type="search"
|
||||||
dataCy="company-categories-filter-search-input"
|
dataCy="company-categories-filter-search-input"
|
||||||
@@ -47,37 +78,40 @@ const CompanyCategoryListFilters = ({
|
|||||||
{ label: "Published", value: "true" },
|
{ label: "Published", value: "true" },
|
||||||
{ label: "Not Published", value: "false" },
|
{ label: "Not Published", value: "false" },
|
||||||
]}
|
]}
|
||||||
label="published"
|
label="Published"
|
||||||
name="published"
|
name="published"
|
||||||
placeholder="Published Status"
|
placeholder="Published status"
|
||||||
clearable
|
clearable
|
||||||
value={watch("published")}
|
value={watch("published")}
|
||||||
onClear={() => setValue("published", undefined)}
|
onClear={() => setValue("published", undefined)}
|
||||||
dataCy="company-categories-filter-published-select"
|
dataCy="company-categories-filter-published-select"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
size="small"
|
||||||
|
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
|
||||||
data-cy="company-categories-filter-submit-button"
|
data-cy="company-categories-filter-submit-button"
|
||||||
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
|
label={
|
||||||
>
|
isMutating ? (
|
||||||
{isMutating ? (
|
<span className="inline-flex h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
|
|
||||||
) : (
|
) : (
|
||||||
<span>Search</span>
|
<span>Apply filters</span>
|
||||||
)}
|
)
|
||||||
</button>
|
}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-cy="company-categories-filter-close-button"
|
data-cy="company-categories-filter-reset-button"
|
||||||
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
|
onClick={onClearAll}
|
||||||
onClick={() => setIsFilterModalOpen(false)}
|
className="min-h-[52px] rounded-2xl border border-stroke/70 bg-white/80 px-6 text-sm font-semibold text-dark transition hover:border-primary/40 hover:text-primary dark:border-dark-3 dark:bg-dark-3/60 dark:text-white dark:hover:bg-white/[0.06]"
|
||||||
>
|
>
|
||||||
Close
|
Reset all
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</InlineListFiltersPanel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
|||||||
import IsVisible from "@/components/ui/IsVisible";
|
import IsVisible from "@/components/ui/IsVisible";
|
||||||
import ListHeader from "@/components/ui/ListHeader";
|
import ListHeader from "@/components/ui/ListHeader";
|
||||||
import ListWrapper from "@/components/ui/ListWrapper";
|
import ListWrapper from "@/components/ui/ListWrapper";
|
||||||
import Modal from "@/components/ui/modal";
|
import Pagination from "@/components/ui/pagination";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import TableSkeleton from "@/components/ui/TableSkeleton";
|
import TableSkeleton from "@/components/ui/TableSkeleton";
|
||||||
import Pagination from "@/components/ui/pagination";
|
|
||||||
import { _TooltipDefaultParams } from "@/constants/tooltip";
|
import { _TooltipDefaultParams } from "@/constants/tooltip";
|
||||||
import { getPaginatedRowNumber, unixToDate } from "@/utils/shared";
|
import { getPaginatedRowNumber, unixToDate } from "@/utils/shared";
|
||||||
import { startTransition } from "react";
|
import { startTransition } from "react";
|
||||||
@@ -37,8 +36,6 @@ const CompanyCategoriesTable = () => {
|
|||||||
setCurrentCategory,
|
setCurrentCategory,
|
||||||
isModalOpen,
|
isModalOpen,
|
||||||
setIsModalOpen,
|
setIsModalOpen,
|
||||||
isFilterModalOpen,
|
|
||||||
setIsFilterModalOpen,
|
|
||||||
filterRegister,
|
filterRegister,
|
||||||
handleFilterSubmit,
|
handleFilterSubmit,
|
||||||
search,
|
search,
|
||||||
@@ -47,17 +44,32 @@ const CompanyCategoriesTable = () => {
|
|||||||
columns,
|
columns,
|
||||||
isMutating,
|
isMutating,
|
||||||
metadata,
|
metadata,
|
||||||
setParams,
|
|
||||||
shouldShowPagination,
|
shouldShowPagination,
|
||||||
|
clearAllFilters,
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
pagination,
|
||||||
|
handlePaginationChange,
|
||||||
} = useCompanyCategoriesPresenter();
|
} = useCompanyCategoriesPresenter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListWrapper data-cy="company-categories-page">
|
<ListWrapper data-cy="company-categories-page">
|
||||||
<ListHeader
|
<ListHeader
|
||||||
onFilter={() => setIsFilterModalOpen(true)}
|
hasFilter={false}
|
||||||
createButtonText="Create Category"
|
createButtonText="Create Category"
|
||||||
dataCyPrefix="company-categories"
|
dataCyPrefix="company-categories"
|
||||||
/>
|
/>
|
||||||
|
<CompanyCategoryListFilters
|
||||||
|
setValue={setFilterValue}
|
||||||
|
filterRegister={filterRegister}
|
||||||
|
handleFilterSubmit={handleFilterSubmit}
|
||||||
|
isMutating={isMutating as number}
|
||||||
|
search={search}
|
||||||
|
watch={watch}
|
||||||
|
onClearAll={clearAllFilters}
|
||||||
|
/>
|
||||||
|
<div ref={tableSectionRef}>
|
||||||
<Table data-cy="company-categories-table">
|
<Table data-cy="company-categories-table">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-none uppercase [&>th]:text-start">
|
<TableRow className="border-none uppercase [&>th]:text-start">
|
||||||
@@ -69,13 +81,15 @@ const CompanyCategoriesTable = () => {
|
|||||||
<TableHead colSpan={100}>Actions</TableHead>
|
<TableHead colSpan={100}>Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
{isLoading && <TableSkeleton column={columns.length} />}
|
{isLoading ? (
|
||||||
<TableBody>
|
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
|
||||||
|
) : (
|
||||||
|
<TableBody ref={dataTbodyRef}>
|
||||||
<EmptyListWrapper
|
<EmptyListWrapper
|
||||||
list={optimisticCategories}
|
list={optimisticCategories}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
emptyMessage="No categories were found"
|
emptyMessage="No categories were found"
|
||||||
isLoading={isLoading}
|
isLoading={false}
|
||||||
>
|
>
|
||||||
{optimisticCategories.map((category, index) => (
|
{optimisticCategories.map((category, index) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
@@ -96,7 +110,6 @@ const CompanyCategoriesTable = () => {
|
|||||||
{unixToDate({ unix: category.created_at, hasTime: true })}
|
{unixToDate({ unix: category.created_at, hasTime: true })}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell colSpan={100}>
|
<TableCell colSpan={100}>
|
||||||
{/* Published: flip UI immediately via useOptimistic; if togglePublished rejects, React rolls the optimistic row back. */}
|
|
||||||
<Switch
|
<Switch
|
||||||
withIcon
|
withIcon
|
||||||
dataCy={`company-categories-published-toggle-${category.id}`}
|
dataCy={`company-categories-published-toggle-${category.id}`}
|
||||||
@@ -157,23 +170,9 @@ const CompanyCategoriesTable = () => {
|
|||||||
))}
|
))}
|
||||||
</EmptyListWrapper>
|
</EmptyListWrapper>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
<Modal
|
</div>
|
||||||
isOpen={isFilterModalOpen}
|
|
||||||
onClose={() => setIsFilterModalOpen(false)}
|
|
||||||
classNames="w-full xl:w-1/2"
|
|
||||||
showButtons={false}
|
|
||||||
>
|
|
||||||
<CompanyCategoryListFilters
|
|
||||||
setValue={setFilterValue}
|
|
||||||
filterRegister={filterRegister}
|
|
||||||
handleFilterSubmit={handleFilterSubmit}
|
|
||||||
isMutating={isMutating as number}
|
|
||||||
search={search}
|
|
||||||
setIsFilterModalOpen={setIsFilterModalOpen}
|
|
||||||
watch={watch}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
@@ -191,15 +190,9 @@ const CompanyCategoriesTable = () => {
|
|||||||
)}
|
)}
|
||||||
<IsVisible condition={shouldShowPagination}>
|
<IsVisible condition={shouldShowPagination}>
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={metadata?.page ? metadata.page - 1 : 0}
|
currentPage={pagination.currentPage}
|
||||||
totalPages={metadata?.pages ?? 1}
|
totalPages={pagination.totalPages}
|
||||||
onPageChange={(e) => {
|
onPageChange={handlePaginationChange}
|
||||||
setParams((currentParams) => ({
|
|
||||||
...currentParams,
|
|
||||||
offset: e.selected * (metadata?.limit ?? 10),
|
|
||||||
limit: metadata?.limit ?? 10,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</IsVisible>
|
</IsVisible>
|
||||||
</ListWrapper>
|
</ListWrapper>
|
||||||
|
|||||||
+46
-7
@@ -5,18 +5,18 @@ import {
|
|||||||
useDeleteCompanyCategoryQuery,
|
useDeleteCompanyCategoryQuery,
|
||||||
useToggleCompanyCategoryPublished,
|
useToggleCompanyCategoryPublished,
|
||||||
} from "@/hooks/queries";
|
} from "@/hooks/queries";
|
||||||
|
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
||||||
import { TCompanyCategory } from "@/lib/api";
|
import { TCompanyCategory } from "@/lib/api";
|
||||||
import { deleteEmptyKeys } from "@/utils/shared";
|
import { deleteEmptyKeys } from "@/utils/shared";
|
||||||
import { useIsMutating } from "@tanstack/react-query";
|
import { useIsMutating } from "@tanstack/react-query";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useOptimistic, useState } from "react";
|
import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
type TogglePublishedOptimistic = Pick<TCompanyCategory, "id" | "published">;
|
type TogglePublishedOptimistic = Pick<TCompanyCategory, "id" | "published">;
|
||||||
|
|
||||||
const useCompanyCategoriesPresenter = () => {
|
const useCompanyCategoriesPresenter = () => {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
|
|
||||||
const [currentCategory, setCurrentCategory] = useState<TCompanyCategory>();
|
const [currentCategory, setCurrentCategory] = useState<TCompanyCategory>();
|
||||||
const [params, setParams] = useState<Record<string, any>>({});
|
const [params, setParams] = useState<Record<string, any>>({});
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -32,6 +32,10 @@ const useCompanyCategoriesPresenter = () => {
|
|||||||
setValue: setFilterValue,
|
setValue: setFilterValue,
|
||||||
} = useForm();
|
} = useForm();
|
||||||
|
|
||||||
|
const tableSectionRef = useRef<HTMLDivElement>(null);
|
||||||
|
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||||
|
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newParams: Record<string, any> = {
|
const newParams: Record<string, any> = {
|
||||||
...apiDefaultParams,
|
...apiDefaultParams,
|
||||||
@@ -46,11 +50,39 @@ const useCompanyCategoriesPresenter = () => {
|
|||||||
}, [searchParams, filterFormReset]);
|
}, [searchParams, filterFormReset]);
|
||||||
|
|
||||||
const { data, isLoading, isError } = useCompanyCategoriesQuery(params);
|
const { data, isLoading, isError } = useCompanyCategoriesQuery(params);
|
||||||
|
|
||||||
|
useTableSectionRowAnimations(isLoading, data, {
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
});
|
||||||
|
|
||||||
const categories = data?.data.categories;
|
const categories = data?.data.categories;
|
||||||
const metadata = data?.meta;
|
const metadata = data?.meta;
|
||||||
const shouldShowPagination =
|
const shouldShowPagination =
|
||||||
!isLoading &&
|
!isLoading &&
|
||||||
(data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
(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<
|
const [optimisticCategories, addOptimisticPublished] = useOptimistic<
|
||||||
TCompanyCategory[],
|
TCompanyCategory[],
|
||||||
TogglePublishedOptimistic
|
TogglePublishedOptimistic
|
||||||
@@ -63,14 +95,17 @@ const useCompanyCategoriesPresenter = () => {
|
|||||||
const { mutateAsync: togglePublished } =
|
const { mutateAsync: togglePublished } =
|
||||||
useToggleCompanyCategoryPublished();
|
useToggleCompanyCategoryPublished();
|
||||||
|
|
||||||
const search = (data: any) => {
|
const search = (formData: any) => {
|
||||||
const newParams = { ...params, ...data, offset: 0 };
|
const newParams = { ...params, ...formData, offset: 0 };
|
||||||
const cleanedParams = deleteEmptyKeys(newParams);
|
const cleanedParams = deleteEmptyKeys(newParams);
|
||||||
const queryString = new URLSearchParams(cleanedParams).toString();
|
const queryString = new URLSearchParams(cleanedParams).toString();
|
||||||
router.push(`${pathName}?${queryString}`);
|
router.push(`${pathName}?${queryString}`);
|
||||||
setIsFilterModalOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearAllFilters = useCallback(() => {
|
||||||
|
router.push(pathName);
|
||||||
|
}, [pathName, router]);
|
||||||
|
|
||||||
const onConfirmDelete = () => {
|
const onConfirmDelete = () => {
|
||||||
if (!currentCategory) return;
|
if (!currentCategory) return;
|
||||||
deleteCategory(currentCategory?.id);
|
deleteCategory(currentCategory?.id);
|
||||||
@@ -100,8 +135,6 @@ const useCompanyCategoriesPresenter = () => {
|
|||||||
togglePublished,
|
togglePublished,
|
||||||
setCurrentCategory,
|
setCurrentCategory,
|
||||||
onConfirmDelete,
|
onConfirmDelete,
|
||||||
isFilterModalOpen,
|
|
||||||
setIsFilterModalOpen,
|
|
||||||
filterRegister,
|
filterRegister,
|
||||||
handleFilterSubmit,
|
handleFilterSubmit,
|
||||||
search,
|
search,
|
||||||
@@ -110,6 +143,12 @@ const useCompanyCategoriesPresenter = () => {
|
|||||||
isMutating,
|
isMutating,
|
||||||
metadata,
|
metadata,
|
||||||
shouldShowPagination,
|
shouldShowPagination,
|
||||||
|
clearAllFilters,
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
pagination,
|
||||||
|
handlePaginationChange,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
|||||||
import IsVisible from "@/components/ui/IsVisible";
|
import IsVisible from "@/components/ui/IsVisible";
|
||||||
import ListHeader from "@/components/ui/ListHeader";
|
import ListHeader from "@/components/ui/ListHeader";
|
||||||
import ListWrapper from "@/components/ui/ListWrapper";
|
import ListWrapper from "@/components/ui/ListWrapper";
|
||||||
import Modal from "@/components/ui/modal";
|
|
||||||
import Pagination from "@/components/ui/pagination";
|
import Pagination from "@/components/ui/pagination";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -39,8 +38,6 @@ const CompaniesTable = () => {
|
|||||||
setCurrentCompany,
|
setCurrentCompany,
|
||||||
onConfirmDelete,
|
onConfirmDelete,
|
||||||
modalConfig,
|
modalConfig,
|
||||||
isFilterModalOpen,
|
|
||||||
setIsFilterModalOpen,
|
|
||||||
filterRegister,
|
filterRegister,
|
||||||
handleFilterSubmit,
|
handleFilterSubmit,
|
||||||
search,
|
search,
|
||||||
@@ -48,39 +45,53 @@ const CompaniesTable = () => {
|
|||||||
setFilterValue,
|
setFilterValue,
|
||||||
isMutating,
|
isMutating,
|
||||||
metadata,
|
metadata,
|
||||||
setParams,
|
|
||||||
shouldShowPagination,
|
shouldShowPagination,
|
||||||
|
clearAllFilters,
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
pagination,
|
||||||
|
handlePaginationChange,
|
||||||
} = useCompanyListPresenter();
|
} = useCompanyListPresenter();
|
||||||
const companies = allCompanies.filter(
|
const companies = allCompanies.filter(
|
||||||
(c): c is NonNullable<typeof c> => c != null,
|
(c): c is NonNullable<typeof c> => c != null,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<ListWrapper>
|
<ListWrapper>
|
||||||
<ListHeader
|
<ListHeader hasFilter={false} createButtonText="Create Company" />
|
||||||
onFilter={() => setIsFilterModalOpen(true)}
|
<CompanyListFilters
|
||||||
createButtonText="Create Company"
|
setValue={setFilterValue}
|
||||||
|
filterRegister={filterRegister}
|
||||||
|
handleFilterSubmit={handleFilterSubmit}
|
||||||
|
isMutating={isMutating as number}
|
||||||
|
search={search}
|
||||||
|
watch={watch}
|
||||||
|
onClearAll={clearAllFilters}
|
||||||
/>
|
/>
|
||||||
|
<div ref={tableSectionRef}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{columns.map((columns) => (
|
{columns.map((col) => (
|
||||||
<TableHead
|
<TableHead
|
||||||
colSpan={100}
|
colSpan={100}
|
||||||
className="text-start uppercase"
|
className="text-start uppercase"
|
||||||
key={columns}
|
key={col}
|
||||||
>
|
>
|
||||||
{columns}
|
{col}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
{isPending && <TableSkeleton column={columns.length} />}
|
{isPending ? (
|
||||||
<TableBody>
|
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
|
||||||
|
) : (
|
||||||
|
<TableBody ref={dataTbodyRef}>
|
||||||
<EmptyListWrapper
|
<EmptyListWrapper
|
||||||
list={companies}
|
list={companies}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
emptyMessage="No companies were found"
|
emptyMessage="No companies were found"
|
||||||
isLoading={isPending}
|
isLoading={false}
|
||||||
>
|
>
|
||||||
{companies.map((company, index) => (
|
{companies.map((company, index) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
@@ -176,23 +187,9 @@ const CompaniesTable = () => {
|
|||||||
))}
|
))}
|
||||||
</EmptyListWrapper>
|
</EmptyListWrapper>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
<Modal
|
</div>
|
||||||
isOpen={isFilterModalOpen}
|
|
||||||
onClose={() => setIsFilterModalOpen(false)}
|
|
||||||
classNames="w-full xl:w-1/2"
|
|
||||||
showButtons={false}
|
|
||||||
>
|
|
||||||
<CompanyListFilters
|
|
||||||
setValue={setFilterValue}
|
|
||||||
filterRegister={filterRegister}
|
|
||||||
handleFilterSubmit={handleFilterSubmit}
|
|
||||||
isMutating={isMutating as number}
|
|
||||||
search={search}
|
|
||||||
setIsFilterModalOpen={setIsFilterModalOpen}
|
|
||||||
watch={watch}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
@@ -210,15 +207,9 @@ const CompaniesTable = () => {
|
|||||||
)}
|
)}
|
||||||
<IsVisible condition={shouldShowPagination}>
|
<IsVisible condition={shouldShowPagination}>
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={metadata?.page ? metadata.page - 1 : 0}
|
currentPage={pagination.currentPage}
|
||||||
totalPages={metadata?.pages ?? 1}
|
totalPages={pagination.totalPages}
|
||||||
onPageChange={(e) => {
|
onPageChange={handlePaginationChange}
|
||||||
setParams((currentParams) => ({
|
|
||||||
...currentParams,
|
|
||||||
offset: e.selected * (metadata?.limit ?? 10),
|
|
||||||
limit: metadata?.limit ?? 10,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</IsVisible>
|
</IsVisible>
|
||||||
</ListWrapper>
|
</ListWrapper>
|
||||||
|
|||||||
@@ -5,16 +5,16 @@ import {
|
|||||||
useCompaniesQuery,
|
useCompaniesQuery,
|
||||||
useDeleteCompanyQuery,
|
useDeleteCompanyQuery,
|
||||||
} from "@/hooks/queries";
|
} from "@/hooks/queries";
|
||||||
|
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
||||||
import { TCompany } from "@/lib/api";
|
import { TCompany } from "@/lib/api";
|
||||||
import { deleteEmptyKeys } from "@/utils/shared";
|
import { deleteEmptyKeys } from "@/utils/shared";
|
||||||
import { useIsMutating } from "@tanstack/react-query";
|
import { useIsMutating } from "@tanstack/react-query";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
export const useCompanyListPresenter = () => {
|
export const useCompanyListPresenter = () => {
|
||||||
const [currentCompany, setCurrentCompany] = useState<TCompany | null>(null);
|
const [currentCompany, setCurrentCompany] = useState<TCompany | null>(null);
|
||||||
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
|
|
||||||
const [modalConfig, setModalConfig] = useState<
|
const [modalConfig, setModalConfig] = useState<
|
||||||
Partial<ConfirmationModalProps>
|
Partial<ConfirmationModalProps>
|
||||||
>({});
|
>({});
|
||||||
@@ -33,6 +33,10 @@ export const useCompanyListPresenter = () => {
|
|||||||
setValue: setFilterValue,
|
setValue: setFilterValue,
|
||||||
} = useForm();
|
} = useForm();
|
||||||
|
|
||||||
|
const tableSectionRef = useRef<HTMLDivElement>(null);
|
||||||
|
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||||
|
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
"row",
|
"row",
|
||||||
"name",
|
"name",
|
||||||
@@ -61,6 +65,12 @@ export const useCompanyListPresenter = () => {
|
|||||||
|
|
||||||
const { data, error, isPending, isError } = useCompaniesQuery(params);
|
const { data, error, isPending, isError } = useCompaniesQuery(params);
|
||||||
|
|
||||||
|
useTableSectionRowAnimations(isPending, data, {
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
});
|
||||||
|
|
||||||
const shouldShowPagination =
|
const shouldShowPagination =
|
||||||
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
||||||
|
|
||||||
@@ -68,13 +78,37 @@ export const useCompanyListPresenter = () => {
|
|||||||
setIsModalOpen(false),
|
setIsModalOpen(false),
|
||||||
);
|
);
|
||||||
|
|
||||||
const search = (data: any) => {
|
const search = (formData: any) => {
|
||||||
const newParams = { ...params, ...data, offset: 0 };
|
const newParams = { ...params, ...formData, offset: 0 };
|
||||||
const cleanedParams = deleteEmptyKeys(newParams);
|
const cleanedParams = deleteEmptyKeys(newParams);
|
||||||
const queryString = new URLSearchParams(cleanedParams).toString();
|
const queryString = new URLSearchParams(cleanedParams).toString();
|
||||||
router.push(`${pathName}?${queryString}`);
|
router.push(`${pathName}?${queryString}`);
|
||||||
setIsFilterModalOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearAllFilters = useCallback(() => {
|
||||||
|
router.push(pathName);
|
||||||
|
}, [pathName, router]);
|
||||||
|
|
||||||
|
const 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 = () => {
|
const onConfirmDelete = () => {
|
||||||
if (currentCompany) {
|
if (currentCompany) {
|
||||||
deleteCompany(currentCompany.id);
|
deleteCompany(currentCompany.id);
|
||||||
@@ -98,8 +132,6 @@ export const useCompanyListPresenter = () => {
|
|||||||
router,
|
router,
|
||||||
isPending,
|
isPending,
|
||||||
isError,
|
isError,
|
||||||
isFilterModalOpen,
|
|
||||||
setIsFilterModalOpen,
|
|
||||||
filterRegister,
|
filterRegister,
|
||||||
handleFilterSubmit,
|
handleFilterSubmit,
|
||||||
search,
|
search,
|
||||||
@@ -108,5 +140,11 @@ export const useCompanyListPresenter = () => {
|
|||||||
isMutating,
|
isMutating,
|
||||||
setParams,
|
setParams,
|
||||||
shouldShowPagination,
|
shouldShowPagination,
|
||||||
|
clearAllFilters,
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
pagination,
|
||||||
|
handlePaginationChange,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<FieldValues>;
|
||||||
|
isMutating: number;
|
||||||
|
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
|
||||||
|
search: (data: any) => void;
|
||||||
|
watch: UseFormWatch<FieldValues>;
|
||||||
|
setValue: UseFormSetValue<any>;
|
||||||
|
onClearAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countActive(values: Record<string, unknown>): 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<string, unknown>),
|
||||||
|
[watched],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InlineListFiltersPanel
|
||||||
|
title="Filter contacts"
|
||||||
|
description="Search submissions or narrow by status — inline, no popup."
|
||||||
|
activeFilterCount={activeCount}
|
||||||
|
expandAriaLabel="Expand contact filters"
|
||||||
|
collapseAriaLabel="Collapse contact filters"
|
||||||
|
panelToggleDataCy="contact-us-filter-panel-toggle"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
|
||||||
|
onSubmit={handleFilterSubmit(search)}
|
||||||
|
data-cy="contact-us-filter-form"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<InputGroup
|
||||||
|
{...filterRegister("q")}
|
||||||
|
name="q"
|
||||||
|
label="Keyword search"
|
||||||
|
placeholder="Name, email…"
|
||||||
|
type="search"
|
||||||
|
dataCy="contact-us-filter-q-input"
|
||||||
|
/>
|
||||||
|
<InputGroup
|
||||||
|
{...filterRegister("status")}
|
||||||
|
name="status"
|
||||||
|
label="Status"
|
||||||
|
placeholder="Status"
|
||||||
|
type="text"
|
||||||
|
dataCy="contact-us-filter-status-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="small"
|
||||||
|
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
|
||||||
|
data-cy="contact-us-filter-submit-button"
|
||||||
|
label={
|
||||||
|
isMutating ? (
|
||||||
|
<span className="inline-flex h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
|
) : (
|
||||||
|
<span>Apply filters</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-cy="contact-us-filter-reset-button"
|
||||||
|
onClick={onClearAll}
|
||||||
|
className="min-h-[52px] rounded-2xl border border-stroke/70 bg-white/80 px-6 text-sm font-semibold text-dark transition hover:border-primary/40 hover:text-primary dark:border-dark-3 dark:bg-dark-3/60 dark:text-white dark:hover:bg-white/[0.06]"
|
||||||
|
>
|
||||||
|
Reset all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</InlineListFiltersPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { TrashIcon } from "@/assets/icons";
|
|||||||
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
||||||
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
||||||
import IsVisible from "@/components/ui/IsVisible";
|
import IsVisible from "@/components/ui/IsVisible";
|
||||||
|
import ListHeader from "@/components/ui/ListHeader";
|
||||||
import ListWrapper from "@/components/ui/ListWrapper";
|
import ListWrapper from "@/components/ui/ListWrapper";
|
||||||
import Pagination from "@/components/ui/pagination";
|
import Pagination from "@/components/ui/pagination";
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +18,7 @@ import TableSkeleton from "@/components/ui/TableSkeleton";
|
|||||||
import { _TooltipDefaultParams } from "@/constants/tooltip";
|
import { _TooltipDefaultParams } from "@/constants/tooltip";
|
||||||
import { getPaginatedRowNumber, unixToDate } from "@/utils/shared";
|
import { getPaginatedRowNumber, unixToDate } from "@/utils/shared";
|
||||||
import { Tooltip } from "react-tooltip";
|
import { Tooltip } from "react-tooltip";
|
||||||
|
import ContactUsListFilters from "./ContactUsListFilters";
|
||||||
import { useContactUsListPresenter } from "./useContactUsListPresenter";
|
import { useContactUsListPresenter } from "./useContactUsListPresenter";
|
||||||
|
|
||||||
const ContactUsTable = () => {
|
const ContactUsTable = () => {
|
||||||
@@ -30,32 +32,56 @@ const ContactUsTable = () => {
|
|||||||
isModalOpen,
|
isModalOpen,
|
||||||
currentContact,
|
currentContact,
|
||||||
metadata,
|
metadata,
|
||||||
setParams,
|
|
||||||
shouldShowPagination,
|
shouldShowPagination,
|
||||||
|
filterRegister,
|
||||||
|
handleFilterSubmit,
|
||||||
|
search,
|
||||||
|
watch,
|
||||||
|
setFilterValue,
|
||||||
|
isMutating,
|
||||||
|
clearAllFilters,
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
pagination,
|
||||||
|
handlePaginationChange,
|
||||||
} = useContactUsListPresenter();
|
} = useContactUsListPresenter();
|
||||||
return (
|
return (
|
||||||
<ListWrapper>
|
<ListWrapper>
|
||||||
|
<ListHeader hasFilter={false} hasCreate={false} />
|
||||||
|
<ContactUsListFilters
|
||||||
|
setValue={setFilterValue}
|
||||||
|
filterRegister={filterRegister}
|
||||||
|
handleFilterSubmit={handleFilterSubmit}
|
||||||
|
isMutating={isMutating as number}
|
||||||
|
search={search}
|
||||||
|
watch={watch}
|
||||||
|
onClearAll={clearAllFilters}
|
||||||
|
/>
|
||||||
|
<div ref={tableSectionRef}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{columns.map((columns) => (
|
{columns.map((col) => (
|
||||||
<TableHead
|
<TableHead
|
||||||
colSpan={100}
|
colSpan={100}
|
||||||
className="text-start uppercase"
|
className="text-start uppercase"
|
||||||
key={columns}
|
key={col}
|
||||||
>
|
>
|
||||||
{columns}
|
{col}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
{isPending && <TableSkeleton column={columns.length} />}
|
{isPending ? (
|
||||||
<TableBody>
|
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
|
||||||
|
) : (
|
||||||
|
<TableBody ref={dataTbodyRef}>
|
||||||
<EmptyListWrapper
|
<EmptyListWrapper
|
||||||
list={contacts ?? []}
|
list={contacts ?? []}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
emptyMessage="No contact us is submitted"
|
emptyMessage="No contact us is submitted"
|
||||||
isLoading={isPending}
|
isLoading={false}
|
||||||
>
|
>
|
||||||
{contacts?.map((item, index) => (
|
{contacts?.map((item, index) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
@@ -106,7 +132,9 @@ const ContactUsTable = () => {
|
|||||||
))}
|
))}
|
||||||
</EmptyListWrapper>
|
</EmptyListWrapper>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
@@ -124,15 +152,9 @@ const ContactUsTable = () => {
|
|||||||
)}
|
)}
|
||||||
<IsVisible condition={shouldShowPagination}>
|
<IsVisible condition={shouldShowPagination}>
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={metadata?.page ? metadata.page - 1 : 0}
|
currentPage={pagination.currentPage}
|
||||||
totalPages={metadata?.pages ?? 1}
|
totalPages={pagination.totalPages}
|
||||||
onPageChange={(e) => {
|
onPageChange={handlePaginationChange}
|
||||||
setParams((currentParams) => ({
|
|
||||||
...currentParams,
|
|
||||||
offset: e.selected * (metadata?.limit ?? 10),
|
|
||||||
limit: metadata?.limit ?? 10,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</IsVisible>
|
</IsVisible>
|
||||||
</ListWrapper>
|
</ListWrapper>
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ import {
|
|||||||
useDeleteContactUs,
|
useDeleteContactUs,
|
||||||
useReadAllContactUs,
|
useReadAllContactUs,
|
||||||
} from "@/hooks/queries/useContactUsQuery";
|
} from "@/hooks/queries/useContactUsQuery";
|
||||||
|
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
||||||
import { TContact } from "@/lib/api/types/TContacts";
|
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 { 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 = () => {
|
export const useContactUsListPresenter = () => {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
@@ -24,6 +28,19 @@ export const useContactUsListPresenter = () => {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const isMutating = useIsMutating();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register: filterRegister,
|
||||||
|
handleSubmit: handleFilterSubmit,
|
||||||
|
reset: filterFormReset,
|
||||||
|
watch,
|
||||||
|
setValue: setFilterValue,
|
||||||
|
} = useForm();
|
||||||
|
|
||||||
|
const tableSectionRef = useRef<HTMLDivElement>(null);
|
||||||
|
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||||
|
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newParams: Record<string, unknown> = {
|
const newParams: Record<string, unknown> = {
|
||||||
@@ -35,9 +52,17 @@ export const useContactUsListPresenter = () => {
|
|||||||
newParams[key] = value;
|
newParams[key] = value;
|
||||||
}
|
}
|
||||||
setParams(newParams);
|
setParams(newParams);
|
||||||
}, [searchParams]);
|
filterFormReset(newParams as any);
|
||||||
|
}, [searchParams, filterFormReset]);
|
||||||
|
|
||||||
const { data, isPending } = useReadAllContactUs(params);
|
const { data, isPending } = useReadAllContactUs(params);
|
||||||
|
|
||||||
|
useTableSectionRowAnimations(isPending, data, {
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
});
|
||||||
|
|
||||||
const { mutate: deleteContactUS } = useDeleteContactUs(() =>
|
const { mutate: deleteContactUS } = useDeleteContactUs(() =>
|
||||||
setIsModalOpen(false),
|
setIsModalOpen(false),
|
||||||
);
|
);
|
||||||
@@ -45,6 +70,39 @@ export const useContactUsListPresenter = () => {
|
|||||||
const shouldShowPagination =
|
const shouldShowPagination =
|
||||||
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
||||||
|
|
||||||
|
const 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<string, string>,
|
||||||
|
).toString();
|
||||||
|
router.push(`${pathname}?${queryString}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAllFilters = useCallback(() => {
|
||||||
|
router.push(pathname);
|
||||||
|
}, [pathname, router]);
|
||||||
|
|
||||||
const onConfirmDelete = () => {
|
const onConfirmDelete = () => {
|
||||||
if (!currentContact) return;
|
if (!currentContact) return;
|
||||||
deleteContactUS(currentContact?.id);
|
deleteContactUS(currentContact?.id);
|
||||||
@@ -63,5 +121,17 @@ export const useContactUsListPresenter = () => {
|
|||||||
setCurrentContact,
|
setCurrentContact,
|
||||||
isModalOpen,
|
isModalOpen,
|
||||||
currentContact,
|
currentContact,
|
||||||
|
filterRegister,
|
||||||
|
handleFilterSubmit,
|
||||||
|
search,
|
||||||
|
watch,
|
||||||
|
setFilterValue,
|
||||||
|
isMutating,
|
||||||
|
clearAllFilters,
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
pagination,
|
||||||
|
handlePaginationChange,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { Select } from "@/components/FormElements/select";
|
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 { AdminRoles, CustomerStatus, CustomerType } from "@/constants/enums";
|
||||||
import { getEnumAsArray } from "@/utils/shared";
|
import { getEnumAsArray } from "@/utils/shared";
|
||||||
import {
|
import {
|
||||||
@@ -8,35 +12,61 @@ import {
|
|||||||
UseFormSetValue,
|
UseFormSetValue,
|
||||||
UseFormWatch,
|
UseFormWatch,
|
||||||
} from "react-hook-form";
|
} from "react-hook-form";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
interface CustomerListFiltersProps {
|
interface CustomerListFiltersProps {
|
||||||
filterRegister: UseFormRegister<FieldValues>;
|
filterRegister: UseFormRegister<FieldValues>;
|
||||||
setIsFilterModalOpen: (isOpen: boolean) => void;
|
|
||||||
isMutating: number;
|
isMutating: number;
|
||||||
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
|
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
|
||||||
search: (data: any) => void;
|
search: (data: any) => void;
|
||||||
watch: UseFormWatch<FieldValues>;
|
watch: UseFormWatch<FieldValues>;
|
||||||
setValue: UseFormSetValue<any>;
|
setValue: UseFormSetValue<any>;
|
||||||
|
onClearAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countActive(values: Record<string, unknown>): 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 = ({
|
const CustomerListFilters = ({
|
||||||
filterRegister,
|
filterRegister,
|
||||||
setIsFilterModalOpen,
|
|
||||||
isMutating,
|
isMutating,
|
||||||
handleFilterSubmit,
|
handleFilterSubmit,
|
||||||
search,
|
search,
|
||||||
watch,
|
watch,
|
||||||
setValue,
|
setValue,
|
||||||
|
onClearAll,
|
||||||
}: CustomerListFiltersProps) => {
|
}: CustomerListFiltersProps) => {
|
||||||
|
const watched = watch();
|
||||||
|
const activeCount = useMemo(
|
||||||
|
() => countActive(watched as Record<string, unknown>),
|
||||||
|
[watched],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<InlineListFiltersPanel
|
||||||
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
|
title="Filter customers"
|
||||||
onSubmit={handleFilterSubmit(search)}
|
description="Slice by status, role, or account type — inline, no popup."
|
||||||
|
activeFilterCount={activeCount}
|
||||||
|
expandAriaLabel="Expand customer filters"
|
||||||
|
collapseAriaLabel="Collapse customer filters"
|
||||||
|
panelToggleDataCy="customers-filter-panel-toggle"
|
||||||
>
|
>
|
||||||
|
<form
|
||||||
|
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
|
||||||
|
onSubmit={handleFilterSubmit(search)}
|
||||||
|
data-cy="customers-filter-form"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
<Select
|
<Select
|
||||||
{...filterRegister("status")}
|
{...filterRegister("status")}
|
||||||
items={getEnumAsArray(CustomerStatus)}
|
items={getEnumAsArray(CustomerStatus)}
|
||||||
label="status"
|
label="Status"
|
||||||
name="status"
|
name="status"
|
||||||
placeholder="Status"
|
placeholder="Status"
|
||||||
clearable
|
clearable
|
||||||
@@ -46,7 +76,7 @@ const CustomerListFilters = ({
|
|||||||
<Select
|
<Select
|
||||||
{...filterRegister("role")}
|
{...filterRegister("role")}
|
||||||
items={getEnumAsArray(AdminRoles)}
|
items={getEnumAsArray(AdminRoles)}
|
||||||
label="role"
|
label="Role"
|
||||||
name="role"
|
name="role"
|
||||||
placeholder="Role"
|
placeholder="Role"
|
||||||
clearable
|
clearable
|
||||||
@@ -56,34 +86,39 @@ const CustomerListFilters = ({
|
|||||||
<Select
|
<Select
|
||||||
{...filterRegister("type")}
|
{...filterRegister("type")}
|
||||||
items={getEnumAsArray(CustomerType)}
|
items={getEnumAsArray(CustomerType)}
|
||||||
label="type"
|
label="Type"
|
||||||
name="type"
|
name="type"
|
||||||
placeholder="Type"
|
placeholder="Type"
|
||||||
clearable
|
clearable
|
||||||
value={watch("type")}
|
value={watch("type")}
|
||||||
onClear={() => setValue("type", undefined)}
|
onClear={() => setValue("type", undefined)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex gap-4 col-span-2">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
|
size="small"
|
||||||
>
|
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
|
||||||
{isMutating ? (
|
data-cy="customers-filter-submit-button"
|
||||||
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
|
label={
|
||||||
|
isMutating ? (
|
||||||
|
<span className="inline-flex h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
) : (
|
) : (
|
||||||
<span>Search</span>
|
<span>Apply filters</span>
|
||||||
)}
|
)
|
||||||
</button>
|
}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
|
data-cy="customers-filter-reset-button"
|
||||||
onClick={() => setIsFilterModalOpen(false)}
|
onClick={onClearAll}
|
||||||
|
className="min-h-[52px] rounded-2xl border border-stroke/70 bg-white/80 px-6 text-sm font-semibold text-dark transition hover:border-primary/40 hover:text-primary dark:border-dark-3 dark:bg-dark-3/60 dark:text-white dark:hover:bg-white/[0.06]"
|
||||||
>
|
>
|
||||||
Close
|
Reset all
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</InlineListFiltersPanel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { ExclamationIcon, PencilSquareIcon, TrashIcon } from "@/assets/icons";
|
import { ExclamationIcon, PencilSquareIcon, TrashIcon } from "@/assets/icons";
|
||||||
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
|
||||||
import { Building } from "@/components/Layouts/sidebar/icons";
|
import { Building } from "@/components/Layouts/sidebar/icons";
|
||||||
|
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
||||||
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
||||||
import IsVisible from "@/components/ui/IsVisible";
|
import IsVisible from "@/components/ui/IsVisible";
|
||||||
import ListHeader from "@/components/ui/ListHeader";
|
import ListHeader from "@/components/ui/ListHeader";
|
||||||
|
import ListWrapper from "@/components/ui/ListWrapper";
|
||||||
import Modal from "@/components/ui/modal";
|
import Modal from "@/components/ui/modal";
|
||||||
import Pagination from "@/components/ui/pagination";
|
import Pagination from "@/components/ui/pagination";
|
||||||
import Status from "@/components/ui/Status";
|
import Status from "@/components/ui/Status";
|
||||||
@@ -24,7 +25,6 @@ import { Tooltip } from "react-tooltip";
|
|||||||
import AssignToCompanyModalContent from "./AssignToCompanyModalContent";
|
import AssignToCompanyModalContent from "./AssignToCompanyModalContent";
|
||||||
import CustomerListFilters from "./CustomerListFilters";
|
import CustomerListFilters from "./CustomerListFilters";
|
||||||
import useCustomerListPresenter from "./useCustomerListPresenter";
|
import useCustomerListPresenter from "./useCustomerListPresenter";
|
||||||
import ListWrapper from "@/components/ui/ListWrapper";
|
|
||||||
|
|
||||||
const CustomersTable = () => {
|
const CustomersTable = () => {
|
||||||
const {
|
const {
|
||||||
@@ -44,46 +44,58 @@ const CustomersTable = () => {
|
|||||||
setCurrentCustomer,
|
setCurrentCustomer,
|
||||||
isAssignCompanyModalOpen,
|
isAssignCompanyModalOpen,
|
||||||
setIsAssignCompanyModalOpen,
|
setIsAssignCompanyModalOpen,
|
||||||
isFilterModalOpen,
|
|
||||||
setIsFilterModalOpen,
|
|
||||||
filterRegister,
|
filterRegister,
|
||||||
handleFilterSubmit,
|
handleFilterSubmit,
|
||||||
search,
|
search,
|
||||||
watch,
|
watch,
|
||||||
setFilterValue,
|
setFilterValue,
|
||||||
isMutating,
|
isMutating,
|
||||||
setParams,
|
|
||||||
assignCompanyCompaniesQuery,
|
assignCompanyCompaniesQuery,
|
||||||
shouldShowPagination,
|
shouldShowPagination,
|
||||||
|
clearAllFilters,
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
pagination,
|
||||||
|
handlePaginationChange,
|
||||||
} = useCustomerListPresenter();
|
} = useCustomerListPresenter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListWrapper>
|
<ListWrapper>
|
||||||
<ListHeader
|
<ListHeader hasFilter={false} createButtonText="Create Customer" />
|
||||||
onFilter={() => setIsFilterModalOpen(true)}
|
<CustomerListFilters
|
||||||
createButtonText="Create Customer"
|
setValue={setFilterValue}
|
||||||
|
filterRegister={filterRegister}
|
||||||
|
handleFilterSubmit={handleFilterSubmit}
|
||||||
|
isMutating={isMutating as number}
|
||||||
|
search={search}
|
||||||
|
watch={watch}
|
||||||
|
onClearAll={clearAllFilters}
|
||||||
/>
|
/>
|
||||||
|
<div ref={tableSectionRef}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{columns.map((columns) => (
|
{columns.map((col) => (
|
||||||
<TableHead
|
<TableHead
|
||||||
colSpan={100}
|
colSpan={100}
|
||||||
className="text-start font-extrabold uppercase"
|
className="text-start font-extrabold uppercase"
|
||||||
key={columns}
|
key={col}
|
||||||
>
|
>
|
||||||
{columns}
|
{col}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
{isPending && <TableSkeleton column={columns.length} />}
|
{isPending ? (
|
||||||
<TableBody>
|
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
|
||||||
|
) : (
|
||||||
|
<TableBody ref={dataTbodyRef}>
|
||||||
<EmptyListWrapper
|
<EmptyListWrapper
|
||||||
list={allCustomers ?? []}
|
list={allCustomers ?? []}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
emptyMessage="No Customers were found"
|
emptyMessage="No Customers were found"
|
||||||
isLoading={isPending}
|
isLoading={false}
|
||||||
>
|
>
|
||||||
{allCustomers?.map((customer, index) => (
|
{allCustomers?.map((customer, index) => (
|
||||||
<TableRow key={customer?.id ?? index}>
|
<TableRow key={customer?.id ?? index}>
|
||||||
@@ -184,36 +196,16 @@ const CustomersTable = () => {
|
|||||||
))}
|
))}
|
||||||
</EmptyListWrapper>
|
</EmptyListWrapper>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
<IsVisible condition={shouldShowPagination}>
|
<IsVisible condition={shouldShowPagination}>
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={metadata?.page ? metadata?.page - 1 : 0}
|
currentPage={pagination.currentPage}
|
||||||
totalPages={metadata?.pages ?? 1}
|
totalPages={pagination.totalPages}
|
||||||
onPageChange={(e) => {
|
onPageChange={handlePaginationChange}
|
||||||
setParams((currentParams) => ({
|
|
||||||
...currentParams,
|
|
||||||
offset: e.selected * (metadata?.limit ?? 10),
|
|
||||||
limit: metadata?.limit ?? 10,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</IsVisible>
|
</IsVisible>
|
||||||
<Modal
|
|
||||||
isOpen={isFilterModalOpen}
|
|
||||||
onClose={() => setIsFilterModalOpen(false)}
|
|
||||||
classNames="w-full xl:w-1/2"
|
|
||||||
showButtons={false}
|
|
||||||
>
|
|
||||||
<CustomerListFilters
|
|
||||||
setValue={setFilterValue}
|
|
||||||
filterRegister={filterRegister}
|
|
||||||
handleFilterSubmit={handleFilterSubmit}
|
|
||||||
isMutating={isMutating as number}
|
|
||||||
search={search}
|
|
||||||
setIsFilterModalOpen={setIsFilterModalOpen}
|
|
||||||
watch={watch}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
<div className="w-1/12">
|
<div className="w-1/12">
|
||||||
<Modal
|
<Modal
|
||||||
classNames="w-full xl:w-1/2"
|
classNames="w-full xl:w-1/2"
|
||||||
|
|||||||
@@ -6,18 +6,18 @@ import {
|
|||||||
useCustomersQuery,
|
useCustomersQuery,
|
||||||
useDeleteCustomerQuery,
|
useDeleteCustomerQuery,
|
||||||
} from "@/hooks/queries";
|
} from "@/hooks/queries";
|
||||||
|
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
||||||
import { TAssignCustomerToCompanyCredentials, TCustomer } from "@/lib/api";
|
import { TAssignCustomerToCompanyCredentials, TCustomer } from "@/lib/api";
|
||||||
import { apiDefaultParams } from "@/constants/Api_Params";
|
import { apiDefaultParams } from "@/constants/Api_Params";
|
||||||
import { deleteEmptyKeys } from "@/utils/shared";
|
import { deleteEmptyKeys } from "@/utils/shared";
|
||||||
import { useIsMutating } from "@tanstack/react-query";
|
import { useIsMutating } from "@tanstack/react-query";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
const useCustomerListPresenter = () => {
|
const useCustomerListPresenter = () => {
|
||||||
const [isAssignCompanyModalOpen, setIsAssignCompanyModalOpen] =
|
const [isAssignCompanyModalOpen, setIsAssignCompanyModalOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
|
|
||||||
const [selectedCompanies, setSelectedCompanies] = useState<
|
const [selectedCompanies, setSelectedCompanies] = useState<
|
||||||
TAssignCustomerToCompanyCredentials | undefined
|
TAssignCustomerToCompanyCredentials | undefined
|
||||||
>();
|
>();
|
||||||
@@ -27,6 +27,7 @@ const useCustomerListPresenter = () => {
|
|||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const pathName = usePathname();
|
const pathName = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [params, setParams] = useState<Record<string, any>>({
|
const [params, setParams] = useState<Record<string, any>>({
|
||||||
...apiDefaultParams,
|
...apiDefaultParams,
|
||||||
});
|
});
|
||||||
@@ -41,7 +42,31 @@ const useCustomerListPresenter = () => {
|
|||||||
setValue: setFilterValue,
|
setValue: setFilterValue,
|
||||||
} = useForm();
|
} = useForm();
|
||||||
|
|
||||||
|
const tableSectionRef = useRef<HTMLDivElement>(null);
|
||||||
|
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||||
|
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const newParams: Record<string, any> = {
|
||||||
|
...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);
|
const { data, isPending } = useCustomersQuery(params);
|
||||||
|
|
||||||
|
useTableSectionRowAnimations(isPending, data, {
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
});
|
||||||
|
|
||||||
const assignCompanyCompaniesQuery = useCompanyFullList({
|
const assignCompanyCompaniesQuery = useCompanyFullList({
|
||||||
enabled: isAssignCompanyModalOpen,
|
enabled: isAssignCompanyModalOpen,
|
||||||
});
|
});
|
||||||
@@ -55,15 +80,17 @@ const useCustomerListPresenter = () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const search = (data: any) => {
|
const search = (formData: any) => {
|
||||||
const newParams = { ...params, ...data, offset: 0 };
|
const newParams = { ...params, ...formData, offset: 0 };
|
||||||
const cleanedParams = deleteEmptyKeys(newParams);
|
const cleanedParams = deleteEmptyKeys(newParams);
|
||||||
setParams(cleanedParams);
|
|
||||||
const queryString = new URLSearchParams(cleanedParams).toString();
|
const queryString = new URLSearchParams(cleanedParams).toString();
|
||||||
router.push(`${pathName}?${queryString}`);
|
router.push(`${pathName}?${queryString}`);
|
||||||
setIsFilterModalOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearAllFilters = useCallback(() => {
|
||||||
|
router.push(pathName);
|
||||||
|
}, [pathName, router]);
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
"row",
|
"row",
|
||||||
"full name",
|
"full name",
|
||||||
@@ -85,6 +112,26 @@ const useCustomerListPresenter = () => {
|
|||||||
const shouldShowPagination =
|
const shouldShowPagination =
|
||||||
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
||||||
|
|
||||||
|
const 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 {
|
return {
|
||||||
allCustomers: customers,
|
allCustomers: customers,
|
||||||
metadata: data?.meta,
|
metadata: data?.meta,
|
||||||
@@ -102,8 +149,6 @@ const useCustomerListPresenter = () => {
|
|||||||
router,
|
router,
|
||||||
isModalOpen,
|
isModalOpen,
|
||||||
setIsModalOpen,
|
setIsModalOpen,
|
||||||
isFilterModalOpen,
|
|
||||||
setIsFilterModalOpen,
|
|
||||||
filterRegister,
|
filterRegister,
|
||||||
handleFilterSubmit,
|
handleFilterSubmit,
|
||||||
search,
|
search,
|
||||||
@@ -113,6 +158,12 @@ const useCustomerListPresenter = () => {
|
|||||||
setParams,
|
setParams,
|
||||||
assignCompanyCompaniesQuery,
|
assignCompanyCompaniesQuery,
|
||||||
shouldShowPagination,
|
shouldShowPagination,
|
||||||
|
clearAllFilters,
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
pagination,
|
||||||
|
handlePaginationChange,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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<FieldValues>;
|
||||||
|
isMutating: number;
|
||||||
|
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
|
||||||
|
search: (data: any) => void;
|
||||||
|
watch: UseFormWatch<FieldValues>;
|
||||||
|
onClearAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countActive(values: Record<string, unknown>): 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<string, unknown>),
|
||||||
|
[watched],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InlineListFiltersPanel
|
||||||
|
title="Filter feedback"
|
||||||
|
description="Keyword search across this list — inline, no popup."
|
||||||
|
activeFilterCount={activeCount}
|
||||||
|
expandAriaLabel="Expand feedback filters"
|
||||||
|
collapseAriaLabel="Collapse feedback filters"
|
||||||
|
panelToggleDataCy="feedback-filter-panel-toggle"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
|
||||||
|
onSubmit={handleFilterSubmit(search)}
|
||||||
|
data-cy="feedback-filter-form"
|
||||||
|
>
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<InputGroup
|
||||||
|
{...filterRegister("q")}
|
||||||
|
name="q"
|
||||||
|
label="Keyword search"
|
||||||
|
placeholder="Search feedback…"
|
||||||
|
type="search"
|
||||||
|
dataCy="feedback-filter-q-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="small"
|
||||||
|
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
|
||||||
|
data-cy="feedback-filter-submit-button"
|
||||||
|
label={
|
||||||
|
isMutating ? (
|
||||||
|
<span className="inline-flex h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
|
) : (
|
||||||
|
<span>Apply filters</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-cy="feedback-filter-reset-button"
|
||||||
|
onClick={onClearAll}
|
||||||
|
className="min-h-[52px] rounded-2xl border border-stroke/70 bg-white/80 px-6 text-sm font-semibold text-dark transition hover:border-primary/40 hover:text-primary dark:border-dark-3 dark:bg-dark-3/60 dark:text-white dark:hover:bg-white/[0.06]"
|
||||||
|
>
|
||||||
|
Reset all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</InlineListFiltersPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import TableSkeleton from "@/components/ui/TableSkeleton";
|
import TableSkeleton from "@/components/ui/TableSkeleton";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { capitalize, getPaginatedRowNumber, unixToDate } from "@/utils/shared";
|
import { capitalize, getPaginatedRowNumber, unixToDate } from "@/utils/shared";
|
||||||
|
import FeedbackListFilters from "./FeedbackListFilters";
|
||||||
import useFeedbackTablePresenter from "./useFeedbackTablePresenter";
|
import useFeedbackTablePresenter from "./useFeedbackTablePresenter";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
@@ -30,8 +31,18 @@ const FeedbackTable = ({ id, paramKey }: IProps) => {
|
|||||||
router,
|
router,
|
||||||
getShowcaseSectionTitle,
|
getShowcaseSectionTitle,
|
||||||
metadata,
|
metadata,
|
||||||
setParams,
|
|
||||||
shouldShowPagination,
|
shouldShowPagination,
|
||||||
|
filterRegister,
|
||||||
|
handleFilterSubmit,
|
||||||
|
search,
|
||||||
|
watch,
|
||||||
|
clearAllFilters,
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
pagination,
|
||||||
|
handlePaginationChange,
|
||||||
|
isMutating,
|
||||||
} = useFeedbackTablePresenter({ id, paramKey });
|
} = useFeedbackTablePresenter({ id, paramKey });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -42,6 +53,15 @@ 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"
|
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"
|
||||||
>
|
>
|
||||||
|
<FeedbackListFilters
|
||||||
|
filterRegister={filterRegister}
|
||||||
|
handleFilterSubmit={handleFilterSubmit}
|
||||||
|
isMutating={isMutating as number}
|
||||||
|
search={search}
|
||||||
|
watch={watch}
|
||||||
|
onClearAll={clearAllFilters}
|
||||||
|
/>
|
||||||
|
<div ref={tableSectionRef}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -52,14 +72,15 @@ const FeedbackTable = ({ id, paramKey }: IProps) => {
|
|||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
{isLoading && <TableSkeleton column={columns.length} />}
|
{isLoading ? (
|
||||||
|
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
|
||||||
<TableBody>
|
) : (
|
||||||
|
<TableBody ref={dataTbodyRef}>
|
||||||
<EmptyListWrapper
|
<EmptyListWrapper
|
||||||
list={feedback ?? []}
|
list={feedback ?? []}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
emptyMessage="No feedback were found"
|
emptyMessage="No feedback were found"
|
||||||
isLoading={isLoading}
|
isLoading={false}
|
||||||
>
|
>
|
||||||
{(feedback ?? []).map((item, index) => (
|
{(feedback ?? []).map((item, index) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
@@ -88,6 +109,7 @@ const FeedbackTable = ({ id, paramKey }: IProps) => {
|
|||||||
colSpan={100}
|
colSpan={100}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="text-gray-40 rounded-full bg-gray-dark bg-opacity-5 hover:text-green-dark dark:bg-gray dark:bg-opacity-5"
|
className="text-gray-40 rounded-full bg-gray-dark bg-opacity-5 hover:text-green-dark dark:bg-gray dark:bg-opacity-5"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push(`${pathName}/${item.id}`);
|
router.push(`${pathName}/${item.id}`);
|
||||||
@@ -100,18 +122,14 @@ const FeedbackTable = ({ id, paramKey }: IProps) => {
|
|||||||
))}
|
))}
|
||||||
</EmptyListWrapper>
|
</EmptyListWrapper>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
<IsVisible condition={shouldShowPagination}>
|
<IsVisible condition={shouldShowPagination}>
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={metadata?.page ? metadata.page - 1 : 0}
|
currentPage={pagination.currentPage}
|
||||||
totalPages={metadata?.pages ?? 1}
|
totalPages={pagination.totalPages}
|
||||||
onPageChange={(e) => {
|
onPageChange={handlePaginationChange}
|
||||||
setParams((currentParams) => ({
|
|
||||||
...currentParams,
|
|
||||||
offset: e.selected * (metadata?.limit ?? 10),
|
|
||||||
limit: metadata?.limit ?? 10,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</IsVisible>
|
</IsVisible>
|
||||||
</ShowcaseSection>
|
</ShowcaseSection>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { apiDefaultParams } from "@/constants/Api_Params";
|
import { apiDefaultParams } from "@/constants/Api_Params";
|
||||||
import { useGetFeedback } from "@/hooks/queries";
|
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 { 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 {
|
interface IProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -14,6 +18,19 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathName = usePathname();
|
const pathName = usePathname();
|
||||||
|
const isMutating = useIsMutating();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register: filterRegister,
|
||||||
|
handleSubmit: handleFilterSubmit,
|
||||||
|
reset: filterFormReset,
|
||||||
|
watch,
|
||||||
|
setValue: setFilterValue,
|
||||||
|
} = useForm();
|
||||||
|
|
||||||
|
const tableSectionRef = useRef<HTMLDivElement>(null);
|
||||||
|
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||||
|
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newParams: Record<string, unknown> = {
|
const newParams: Record<string, unknown> = {
|
||||||
@@ -25,7 +42,8 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
|
|||||||
newParams[key] = value;
|
newParams[key] = value;
|
||||||
}
|
}
|
||||||
setParams(newParams);
|
setParams(newParams);
|
||||||
}, [searchParams]);
|
filterFormReset(newParams as any);
|
||||||
|
}, [searchParams, filterFormReset]);
|
||||||
|
|
||||||
const queryParams = useMemo(
|
const queryParams = useMemo(
|
||||||
() => ({ ...params, [paramKey]: id }),
|
() => ({ ...params, [paramKey]: id }),
|
||||||
@@ -33,6 +51,13 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { data, isLoading } = useGetFeedback(queryParams);
|
const { data, isLoading } = useGetFeedback(queryParams);
|
||||||
|
|
||||||
|
useTableSectionRowAnimations(isLoading, data, {
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
});
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
"row",
|
"row",
|
||||||
"feedback type",
|
"feedback type",
|
||||||
@@ -45,6 +70,39 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
|
|||||||
!isLoading &&
|
!isLoading &&
|
||||||
(data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
(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<string, string>,
|
||||||
|
).toString();
|
||||||
|
router.push(`${pathName}?${queryString}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAllFilters = useCallback(() => {
|
||||||
|
router.push(pathName);
|
||||||
|
}, [pathName, router]);
|
||||||
|
|
||||||
const getShowcaseSectionTitle = () => {
|
const getShowcaseSectionTitle = () => {
|
||||||
if (paramKey === "tender_id") {
|
if (paramKey === "tender_id") {
|
||||||
const title = data?.data?.[0]?.tender?.title;
|
const title = data?.data?.[0]?.tender?.title;
|
||||||
@@ -62,6 +120,18 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
|
|||||||
metadata: data?.meta,
|
metadata: data?.meta,
|
||||||
setParams,
|
setParams,
|
||||||
shouldShowPagination,
|
shouldShowPagination,
|
||||||
|
filterRegister,
|
||||||
|
handleFilterSubmit,
|
||||||
|
search,
|
||||||
|
watch,
|
||||||
|
setFilterValue,
|
||||||
|
clearAllFilters,
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
pagination,
|
||||||
|
handlePaginationChange,
|
||||||
|
isMutating,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import InputGroup from "@/components/FormElements/InputGroup";
|
import InputGroup from "@/components/FormElements/InputGroup";
|
||||||
import { Select } from "@/components/FormElements/select";
|
import { Select } from "@/components/FormElements/select";
|
||||||
|
import { Button } from "@/components/ui-elements/button";
|
||||||
|
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
||||||
import { INQUIRY_STATUS } from "@/constants/enums";
|
import { INQUIRY_STATUS } from "@/constants/enums";
|
||||||
import {
|
import {
|
||||||
FieldValues,
|
FieldValues,
|
||||||
@@ -8,15 +12,16 @@ import {
|
|||||||
UseFormSetValue,
|
UseFormSetValue,
|
||||||
UseFormWatch,
|
UseFormWatch,
|
||||||
} from "react-hook-form";
|
} from "react-hook-form";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
interface InquiriesListFiltersProps {
|
interface InquiriesListFiltersProps {
|
||||||
filterRegister: UseFormRegister<FieldValues>;
|
filterRegister: UseFormRegister<FieldValues>;
|
||||||
setIsFilterModalOpen: (isOpen: boolean) => void;
|
|
||||||
isMutating: number;
|
isMutating: number;
|
||||||
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
|
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
|
||||||
search: (data: any) => void;
|
search: (data: any) => void;
|
||||||
watch: UseFormWatch<FieldValues>;
|
watch: UseFormWatch<FieldValues>;
|
||||||
setValue: UseFormSetValue<any>;
|
setValue: UseFormSetValue<any>;
|
||||||
|
onClearAll: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusOptions = Object.values(INQUIRY_STATUS).map((status) => ({
|
const statusOptions = Object.values(INQUIRY_STATUS).map((status) => ({
|
||||||
@@ -24,58 +29,97 @@ const statusOptions = Object.values(INQUIRY_STATUS).map((status) => ({
|
|||||||
value: status,
|
value: status,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function countActive(values: Record<string, unknown>): 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 = ({
|
const InquiriesListFilters = ({
|
||||||
filterRegister,
|
filterRegister,
|
||||||
setIsFilterModalOpen,
|
|
||||||
isMutating,
|
isMutating,
|
||||||
handleFilterSubmit,
|
handleFilterSubmit,
|
||||||
search,
|
search,
|
||||||
setValue,
|
setValue,
|
||||||
watch,
|
watch,
|
||||||
|
onClearAll,
|
||||||
}: InquiriesListFiltersProps) => {
|
}: InquiriesListFiltersProps) => {
|
||||||
|
const watched = watch();
|
||||||
|
const activeCount = useMemo(
|
||||||
|
() => countActive(watched as Record<string, unknown>),
|
||||||
|
[watched],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<InlineListFiltersPanel
|
||||||
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
|
title="Filter inquiries"
|
||||||
onSubmit={handleFilterSubmit(search)}
|
description="Search or filter by workflow status — inline, no popup."
|
||||||
|
activeFilterCount={activeCount}
|
||||||
|
expandAriaLabel="Expand inquiry filters"
|
||||||
|
collapseAriaLabel="Collapse inquiry filters"
|
||||||
|
panelToggleDataCy="inquiries-filter-panel-toggle"
|
||||||
>
|
>
|
||||||
|
<form
|
||||||
|
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
|
||||||
|
onSubmit={handleFilterSubmit(search)}
|
||||||
|
data-cy="inquiries-filter-form"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<InputGroup
|
<InputGroup
|
||||||
{...filterRegister("q")}
|
{...filterRegister("q")}
|
||||||
name="q"
|
name="q"
|
||||||
label="search"
|
label="Keyword search"
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
type="search"
|
type="search"
|
||||||
|
dataCy="inquiries-filter-q-input"
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
{...filterRegister("status")}
|
{...filterRegister("status")}
|
||||||
name="status"
|
name="status"
|
||||||
label="status"
|
label="Status"
|
||||||
placeholder="Select Status"
|
placeholder="Select status"
|
||||||
items={statusOptions}
|
items={statusOptions}
|
||||||
clearable
|
clearable
|
||||||
value={watch("status")}
|
value={watch("status")}
|
||||||
onClear={() => setValue("status", "")}
|
onClear={() => setValue("status", "")}
|
||||||
|
dataCy="inquiries-filter-status-select"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
|
size="small"
|
||||||
>
|
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
|
||||||
{isMutating ? (
|
data-cy="inquiries-filter-submit-button"
|
||||||
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
|
label={
|
||||||
|
isMutating ? (
|
||||||
|
<span className="inline-flex h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
) : (
|
) : (
|
||||||
<span>Search</span>
|
<span>Apply filters</span>
|
||||||
)}
|
)
|
||||||
</button>
|
}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
|
data-cy="inquiries-filter-reset-button"
|
||||||
onClick={() => setIsFilterModalOpen(false)}
|
onClick={onClearAll}
|
||||||
|
className="min-h-[52px] rounded-2xl border border-stroke/70 bg-white/80 px-6 text-sm font-semibold text-dark transition hover:border-primary/40 hover:text-primary dark:border-dark-3 dark:bg-dark-3/60 dark:text-white dark:hover:bg-white/[0.06]"
|
||||||
>
|
>
|
||||||
Close
|
Reset all
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</InlineListFiltersPanel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ const InquiriesTable = () => {
|
|||||||
columns,
|
columns,
|
||||||
inquiries,
|
inquiries,
|
||||||
allInquiries,
|
allInquiries,
|
||||||
isFilterModalOpen,
|
|
||||||
setIsFilterModalOpen,
|
|
||||||
isPending,
|
isPending,
|
||||||
currentInquiry,
|
currentInquiry,
|
||||||
isDeleteModalOpen,
|
isDeleteModalOpen,
|
||||||
@@ -51,16 +49,28 @@ const InquiriesTable = () => {
|
|||||||
watch,
|
watch,
|
||||||
setFilterValue,
|
setFilterValue,
|
||||||
isMutating,
|
isMutating,
|
||||||
setParams,
|
|
||||||
shouldShowPagination,
|
shouldShowPagination,
|
||||||
|
clearAllFilters,
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
pagination,
|
||||||
|
handlePaginationChange,
|
||||||
} = useInquiriesListPresenter();
|
} = useInquiriesListPresenter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListWrapper>
|
<ListWrapper>
|
||||||
<ListHeader
|
<ListHeader hasFilter={false} hasCreate={false} />
|
||||||
onFilter={() => setIsFilterModalOpen(true)}
|
<InquiriesListFilters
|
||||||
hasCreate={false}
|
setValue={setFilterValue}
|
||||||
|
filterRegister={filterRegister}
|
||||||
|
handleFilterSubmit={handleFilterSubmit}
|
||||||
|
isMutating={isMutating as number}
|
||||||
|
search={search}
|
||||||
|
watch={watch}
|
||||||
|
onClearAll={clearAllFilters}
|
||||||
/>
|
/>
|
||||||
|
<div ref={tableSectionRef}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-none uppercase [&>th]:text-start">
|
<TableRow className="border-none uppercase [&>th]:text-start">
|
||||||
@@ -71,13 +81,15 @@ const InquiriesTable = () => {
|
|||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
{isPending && <TableSkeleton column={columns.length} />}
|
{isPending ? (
|
||||||
<TableBody>
|
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
|
||||||
|
) : (
|
||||||
|
<TableBody ref={dataTbodyRef}>
|
||||||
<EmptyListWrapper
|
<EmptyListWrapper
|
||||||
columns={columns}
|
columns={columns}
|
||||||
list={inquiries ?? []}
|
list={inquiries ?? []}
|
||||||
emptyMessage="No Inquiries were found"
|
emptyMessage="No Inquiries were found"
|
||||||
isLoading={isPending}
|
isLoading={false}
|
||||||
>
|
>
|
||||||
{inquiries?.map((inquiry: TInquiries, index: number) => (
|
{inquiries?.map((inquiry: TInquiries, index: number) => (
|
||||||
<TableRow key={inquiry?.id ?? index}>
|
<TableRow key={inquiry?.id ?? index}>
|
||||||
@@ -108,6 +120,7 @@ const InquiriesTable = () => {
|
|||||||
<TableCell className="text-start xl:pr-7.5">
|
<TableCell className="text-start xl:pr-7.5">
|
||||||
<div className="flex items-center justify-start gap-x-3.5">
|
<div className="flex items-center justify-start gap-x-3.5">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
data-tooltip-id="change-status"
|
data-tooltip-id="change-status"
|
||||||
data-tooltip-content="Change Status"
|
data-tooltip-content="Change Status"
|
||||||
data-tooltip-place="top"
|
data-tooltip-place="top"
|
||||||
@@ -124,6 +137,7 @@ const InquiriesTable = () => {
|
|||||||
<PencilSquareIcon />
|
<PencilSquareIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
data-tooltip-id="delete"
|
data-tooltip-id="delete"
|
||||||
data-tooltip-content="Delete"
|
data-tooltip-content="Delete"
|
||||||
data-tooltip-place="top"
|
data-tooltip-place="top"
|
||||||
@@ -143,38 +157,9 @@ const InquiriesTable = () => {
|
|||||||
))}
|
))}
|
||||||
</EmptyListWrapper>
|
</EmptyListWrapper>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
<Modal
|
</div>
|
||||||
isOpen={isFilterModalOpen}
|
|
||||||
onClose={() => setIsFilterModalOpen(false)}
|
|
||||||
classNames="w-full xl:w-1/2"
|
|
||||||
showButtons={false}
|
|
||||||
>
|
|
||||||
<InquiriesListFilters
|
|
||||||
setValue={setFilterValue}
|
|
||||||
filterRegister={filterRegister}
|
|
||||||
handleFilterSubmit={handleFilterSubmit}
|
|
||||||
isMutating={isMutating as number}
|
|
||||||
search={search}
|
|
||||||
setIsFilterModalOpen={setIsFilterModalOpen}
|
|
||||||
watch={watch}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
<IsVisible condition={shouldShowPagination}>
|
|
||||||
<Pagination
|
|
||||||
currentPage={
|
|
||||||
allInquiries?.meta?.page ? allInquiries?.meta?.page - 1 : 0
|
|
||||||
}
|
|
||||||
totalPages={allInquiries?.meta?.pages ?? 1}
|
|
||||||
onPageChange={(e) => {
|
|
||||||
setParams((currentParams) => ({
|
|
||||||
...currentParams,
|
|
||||||
offset: e.selected * (allInquiries?.meta?.limit ?? 10),
|
|
||||||
limit: allInquiries?.meta?.limit ?? 10,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</IsVisible>
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isChangeStatusModalOpen}
|
isOpen={isChangeStatusModalOpen}
|
||||||
onClose={() => setIsChangeStatusModalOpen(false)}
|
onClose={() => setIsChangeStatusModalOpen(false)}
|
||||||
@@ -192,6 +177,13 @@ const InquiriesTable = () => {
|
|||||||
currentStatus={currentInquiry?.status ?? "pending"}
|
currentStatus={currentInquiry?.status ?? "pending"}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<IsVisible condition={shouldShowPagination}>
|
||||||
|
<Pagination
|
||||||
|
currentPage={pagination.currentPage}
|
||||||
|
totalPages={pagination.totalPages}
|
||||||
|
onPageChange={handlePaginationChange}
|
||||||
|
/>
|
||||||
|
</IsVisible>
|
||||||
{isDeleteModalOpen && (
|
{isDeleteModalOpen && (
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
isOpen={isDeleteModalOpen}
|
isOpen={isDeleteModalOpen}
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ import {
|
|||||||
useDeleteInquiry,
|
useDeleteInquiry,
|
||||||
useGetInquiries,
|
useGetInquiries,
|
||||||
} from "@/hooks/queries/useInquiryQueries";
|
} from "@/hooks/queries/useInquiryQueries";
|
||||||
|
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
||||||
import { TInquiries } from "@/lib/api";
|
import { TInquiries } from "@/lib/api";
|
||||||
import { deleteEmptyKeys } from "@/utils/shared";
|
import { deleteEmptyKeys } from "@/utils/shared";
|
||||||
import { useIsMutating } from "@tanstack/react-query";
|
import { useIsMutating } from "@tanstack/react-query";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
const useInquiriesListPresenter = () => {
|
const useInquiriesListPresenter = () => {
|
||||||
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
|
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [isChangeStatusModalOpen, setIsChangeStatusModalOpen] = useState(false);
|
const [isChangeStatusModalOpen, setIsChangeStatusModalOpen] = useState(false);
|
||||||
const [currentInquiry, setCurrentInquiry] = useState<TInquiries | null>(null);
|
const [currentInquiry, setCurrentInquiry] = useState<TInquiries | null>(null);
|
||||||
@@ -39,6 +39,10 @@ const useInquiriesListPresenter = () => {
|
|||||||
setValue: setFilterValue,
|
setValue: setFilterValue,
|
||||||
} = useForm();
|
} = useForm();
|
||||||
|
|
||||||
|
const tableSectionRef = useRef<HTMLDivElement>(null);
|
||||||
|
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||||
|
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newParams: Record<string, any> = {
|
const newParams: Record<string, any> = {
|
||||||
offset: 0,
|
offset: 0,
|
||||||
@@ -52,30 +56,61 @@ const useInquiriesListPresenter = () => {
|
|||||||
}, [searchParams, filterFormReset]);
|
}, [searchParams, filterFormReset]);
|
||||||
|
|
||||||
const { data, isPending } = useGetInquiries(params);
|
const { data, isPending } = useGetInquiries(params);
|
||||||
|
|
||||||
|
useTableSectionRowAnimations(isPending, data, {
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
});
|
||||||
|
|
||||||
const shouldShowPagination =
|
const shouldShowPagination =
|
||||||
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
||||||
|
|
||||||
|
const 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 } = useDeleteInquiry(() => setIsDeleteModalOpen(false));
|
||||||
const { mutate: changeStatus } =
|
const { mutate: changeStatus } =
|
||||||
useChangeInquiryStatus(() => setIsChangeStatusModalOpen(false));
|
useChangeInquiryStatus(() => setIsChangeStatusModalOpen(false));
|
||||||
|
|
||||||
const search = (data: any) => {
|
const search = (formData: any) => {
|
||||||
const newParams = { ...params, ...data, offset: 0 };
|
const newParams = { ...params, ...formData, offset: 0 };
|
||||||
const cleanedParams = deleteEmptyKeys(newParams);
|
const cleanedParams = deleteEmptyKeys(newParams);
|
||||||
const queryString = new URLSearchParams(cleanedParams).toString();
|
const queryString = new URLSearchParams(cleanedParams).toString();
|
||||||
router.push(`${pathname}?${queryString}`);
|
router.push(`${pathname}?${queryString}`);
|
||||||
setIsFilterModalOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearAllFilters = useCallback(() => {
|
||||||
|
router.push(pathname);
|
||||||
|
}, [pathname, router]);
|
||||||
|
|
||||||
const deleteInquiry = () => {
|
const deleteInquiry = () => {
|
||||||
if (!currentInquiry?.id) return;
|
if (!currentInquiry?.id) return;
|
||||||
mutate(currentInquiry.id);
|
mutate(currentInquiry.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChangeStatus = (data: any) => {
|
const handleChangeStatus = (payload: any) => {
|
||||||
if (!currentInquiry?.id) return;
|
if (!currentInquiry?.id) return;
|
||||||
changeStatus({
|
changeStatus({
|
||||||
id: currentInquiry.id,
|
id: currentInquiry.id,
|
||||||
credentials: data,
|
credentials: payload,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,8 +118,6 @@ const useInquiriesListPresenter = () => {
|
|||||||
columns,
|
columns,
|
||||||
inquiries: data?.data.inquiries,
|
inquiries: data?.data.inquiries,
|
||||||
allInquiries: data,
|
allInquiries: data,
|
||||||
isFilterModalOpen,
|
|
||||||
setIsFilterModalOpen,
|
|
||||||
isPending,
|
isPending,
|
||||||
isDeleteModalOpen,
|
isDeleteModalOpen,
|
||||||
setIsDeleteModalOpen,
|
setIsDeleteModalOpen,
|
||||||
@@ -103,6 +136,12 @@ const useInquiriesListPresenter = () => {
|
|||||||
isMutating,
|
isMutating,
|
||||||
setParams,
|
setParams,
|
||||||
shouldShowPagination,
|
shouldShowPagination,
|
||||||
|
clearAllFilters,
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
pagination,
|
||||||
|
handlePaginationChange,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import InputGroup from "@/components/FormElements/InputGroup";
|
import InputGroup from "@/components/FormElements/InputGroup";
|
||||||
import { Select } from "@/components/FormElements/select";
|
import { Select } from "@/components/FormElements/select";
|
||||||
|
import { Button } from "@/components/ui-elements/button";
|
||||||
|
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
||||||
import {
|
import {
|
||||||
FieldValues,
|
FieldValues,
|
||||||
UseFormHandleSubmit,
|
UseFormHandleSubmit,
|
||||||
@@ -7,29 +11,64 @@ import {
|
|||||||
UseFormSetValue,
|
UseFormSetValue,
|
||||||
UseFormWatch,
|
UseFormWatch,
|
||||||
} from "react-hook-form";
|
} from "react-hook-form";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
interface NotificationHistoryListFiltersProps {
|
interface NotificationHistoryListFiltersProps {
|
||||||
filterRegister: UseFormRegister<FieldValues>;
|
filterRegister: UseFormRegister<FieldValues>;
|
||||||
setIsFilterModalOpen: (isOpen: boolean) => void;
|
|
||||||
isMutating: number;
|
isMutating: number;
|
||||||
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
|
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
|
||||||
search: (data: any) => void;
|
search: (data: any) => void;
|
||||||
watch: UseFormWatch<FieldValues>;
|
watch: UseFormWatch<FieldValues>;
|
||||||
setValue: UseFormSetValue<any>;
|
setValue: UseFormSetValue<any>;
|
||||||
|
onClearAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countActive(values: Record<string, unknown>): 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 = ({
|
const NotificationHistoryListFilters = ({
|
||||||
filterRegister,
|
filterRegister,
|
||||||
setIsFilterModalOpen,
|
|
||||||
isMutating,
|
isMutating,
|
||||||
handleFilterSubmit,
|
handleFilterSubmit,
|
||||||
search,
|
search,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
onClearAll,
|
||||||
}: NotificationHistoryListFiltersProps) => {
|
}: NotificationHistoryListFiltersProps) => {
|
||||||
|
const watched = watch();
|
||||||
|
const activeCount = useMemo(
|
||||||
|
() => countActive(watched as Record<string, unknown>),
|
||||||
|
[watched],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<InlineListFiltersPanel
|
||||||
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
|
title="Filter notifications"
|
||||||
onSubmit={handleFilterSubmit(search)}
|
description="Seen state and message search — inline, no popup."
|
||||||
|
activeFilterCount={activeCount}
|
||||||
|
expandAriaLabel="Expand notification filters"
|
||||||
|
collapseAriaLabel="Collapse notification filters"
|
||||||
|
panelToggleDataCy="notification-history-filter-panel-toggle"
|
||||||
>
|
>
|
||||||
|
<form
|
||||||
|
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
|
||||||
|
onSubmit={handleFilterSubmit(search)}
|
||||||
|
data-cy="notification-history-filter-form"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<Select
|
<Select
|
||||||
{...filterRegister("seen")}
|
{...filterRegister("seen")}
|
||||||
items={[
|
items={[
|
||||||
@@ -38,35 +77,45 @@ const NotificationHistoryListFilters = ({
|
|||||||
]}
|
]}
|
||||||
label="Seen"
|
label="Seen"
|
||||||
name="seen"
|
name="seen"
|
||||||
|
placeholder="Any"
|
||||||
|
clearable
|
||||||
|
value={watch("seen")}
|
||||||
|
onClear={() => setValue("seen", undefined)}
|
||||||
/>
|
/>
|
||||||
<InputGroup
|
<InputGroup
|
||||||
{...filterRegister("search")}
|
{...filterRegister("search")}
|
||||||
name="search"
|
name="search"
|
||||||
label="search"
|
label="Search"
|
||||||
placeholder="search"
|
placeholder="Search"
|
||||||
type="search"
|
type="search"
|
||||||
|
dataCy="notification-history-filter-search-input"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
|
size="small"
|
||||||
>
|
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
|
||||||
{isMutating ? (
|
data-cy="notification-history-filter-submit-button"
|
||||||
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
|
label={
|
||||||
|
isMutating ? (
|
||||||
|
<span className="inline-flex h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
) : (
|
) : (
|
||||||
<span>Search</span>
|
<span>Apply filters</span>
|
||||||
)}
|
)
|
||||||
</button>
|
}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
|
data-cy="notification-history-filter-reset-button"
|
||||||
onClick={() => setIsFilterModalOpen(false)}
|
onClick={onClearAll}
|
||||||
|
className="min-h-[52px] rounded-2xl border border-stroke/70 bg-white/80 px-6 text-sm font-semibold text-dark transition hover:border-primary/40 hover:text-primary dark:border-dark-3 dark:bg-dark-3/60 dark:text-white dark:hover:bg-white/[0.06]"
|
||||||
>
|
>
|
||||||
Close
|
Reset all
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</InlineListFiltersPanel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { Tooltip } from "react-tooltip";
|
|||||||
import Boolean from "@/components/ui/Boolean";
|
import Boolean from "@/components/ui/Boolean";
|
||||||
import ListHeader from "@/components/ui/ListHeader";
|
import ListHeader from "@/components/ui/ListHeader";
|
||||||
import ListWrapper from "@/components/ui/ListWrapper";
|
import ListWrapper from "@/components/ui/ListWrapper";
|
||||||
import Modal from "@/components/ui/modal";
|
|
||||||
import NotificationHistoryListFilters from "./NotificationHistoryListFilters";
|
import NotificationHistoryListFilters from "./NotificationHistoryListFilters";
|
||||||
import useNotificationHistoryTablePresenter from "./useNotificationHistoryTablePresenter";
|
import useNotificationHistoryTablePresenter from "./useNotificationHistoryTablePresenter";
|
||||||
|
|
||||||
@@ -36,43 +35,56 @@ const NotificationHistoryTable = () => {
|
|||||||
notificationHistory,
|
notificationHistory,
|
||||||
pathName,
|
pathName,
|
||||||
router,
|
router,
|
||||||
isFilterModalOpen,
|
handleFilterSubmit,
|
||||||
setIsFilterModalOpen,
|
filterRegister,
|
||||||
setParams,
|
|
||||||
handleSubmit,
|
|
||||||
register,
|
|
||||||
setValue,
|
setValue,
|
||||||
watch,
|
watch,
|
||||||
search,
|
search,
|
||||||
isMutating,
|
isMutating,
|
||||||
shouldShowPagination,
|
shouldShowPagination,
|
||||||
|
clearAllFilters,
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
pagination,
|
||||||
|
handlePaginationChange,
|
||||||
} = useNotificationHistoryTablePresenter();
|
} = useNotificationHistoryTablePresenter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ListWrapper>
|
<ListWrapper>
|
||||||
<ListHeader
|
<ListHeader hasFilter={false} createButtonText="Create notification" />
|
||||||
createButtonText="Create notification"
|
<NotificationHistoryListFilters
|
||||||
onFilter={() => setIsFilterModalOpen(true)}
|
filterRegister={filterRegister}
|
||||||
|
handleFilterSubmit={handleFilterSubmit}
|
||||||
|
isMutating={isMutating as number}
|
||||||
|
watch={watch}
|
||||||
|
setValue={setValue}
|
||||||
|
search={search}
|
||||||
|
onClearAll={clearAllFilters}
|
||||||
/>
|
/>
|
||||||
|
<div ref={tableSectionRef}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
<TableHead key={column}>
|
<TableHead key={column}>{column}</TableHead>
|
||||||
{column}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
{isLoading && (
|
{isLoading ? (
|
||||||
<TableSkeleton column={columns.length} cellColSpan={1} />
|
<TableSkeleton
|
||||||
)}
|
ref={skeletonTbodyRef}
|
||||||
<TableBody>
|
column={columns.length}
|
||||||
|
cellColSpan={1}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TableBody ref={dataTbodyRef}>
|
||||||
<EmptyListWrapper
|
<EmptyListWrapper
|
||||||
list={notificationHistory ?? []}
|
list={notificationHistory ?? []}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
emptyMessage="No notifications were found"
|
emptyMessage="No notifications were found"
|
||||||
|
isLoading={false}
|
||||||
>
|
>
|
||||||
{(notificationHistory ?? []).map((item, index) => (
|
{(notificationHistory ?? []).map((item, index) => (
|
||||||
<TableRow key={item.id}>
|
<TableRow key={item.id}>
|
||||||
@@ -91,18 +103,12 @@ const NotificationHistoryTable = () => {
|
|||||||
unix: item.created_at,
|
unix: item.created_at,
|
||||||
})}
|
})}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>{item.recipient?.full_name}</TableCell>
|
||||||
{item.recipient?.full_name}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="truncate">
|
<TableCell className="truncate">
|
||||||
{truncateString(item.message, 20, 20)}
|
{truncateString(item.message, 20, 20)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="capitalize">
|
<TableCell className="capitalize">{item.priority}</TableCell>
|
||||||
{item.priority}
|
<TableCell className="capitalize">{item.type}</TableCell>
|
||||||
</TableCell>
|
|
||||||
<TableCell className="capitalize">
|
|
||||||
{item.type}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="capitalize">
|
<TableCell className="capitalize">
|
||||||
<Boolean value={item.seen} />
|
<Boolean value={item.seen} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -111,6 +117,7 @@ const NotificationHistoryTable = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
data-tooltip-id="details"
|
data-tooltip-id="details"
|
||||||
data-tooltip-content="Details"
|
data-tooltip-content="Details"
|
||||||
data-tooltip-place="top"
|
data-tooltip-place="top"
|
||||||
@@ -130,36 +137,17 @@ const NotificationHistoryTable = () => {
|
|||||||
))}
|
))}
|
||||||
</EmptyListWrapper>
|
</EmptyListWrapper>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
<IsVisible condition={shouldShowPagination}>
|
<IsVisible condition={shouldShowPagination}>
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={metadata?.page ? metadata?.page - 1 : 0}
|
currentPage={pagination.currentPage}
|
||||||
totalPages={metadata?.pages ?? 1}
|
totalPages={pagination.totalPages}
|
||||||
onPageChange={(e) => {
|
onPageChange={handlePaginationChange}
|
||||||
setParams((currentParams: Record<string, any>) => ({
|
|
||||||
...currentParams,
|
|
||||||
offset: e.selected * (metadata?.limit ?? 10),
|
|
||||||
limit: metadata?.limit ?? 10,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</IsVisible>
|
</IsVisible>
|
||||||
</ListWrapper>
|
</ListWrapper>
|
||||||
<Modal
|
|
||||||
isOpen={isFilterModalOpen}
|
|
||||||
onClose={() => setIsFilterModalOpen(false)}
|
|
||||||
showButtons={false}
|
|
||||||
>
|
|
||||||
<NotificationHistoryListFilters
|
|
||||||
filterRegister={register}
|
|
||||||
handleFilterSubmit={handleSubmit}
|
|
||||||
isMutating={isMutating}
|
|
||||||
watch={watch}
|
|
||||||
setValue={setValue}
|
|
||||||
setIsFilterModalOpen={setIsFilterModalOpen}
|
|
||||||
search={search}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+71
-11
@@ -1,19 +1,37 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { apiDefaultParams } from "@/constants/Api_Params";
|
import { apiDefaultParams } from "@/constants/Api_Params";
|
||||||
import { useGetNotificationHistoryQuery } from "@/hooks/queries/useNotificationQueries";
|
import { useGetNotificationHistoryQuery } from "@/hooks/queries/useNotificationQueries";
|
||||||
|
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
||||||
import { deleteEmptyKeys } from "@/utils/shared";
|
import { deleteEmptyKeys } from "@/utils/shared";
|
||||||
import { useIsMutating } from "@tanstack/react-query";
|
import { useIsMutating } from "@tanstack/react-query";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
const useNotificationHistoryTablePresenter = () => {
|
const useNotificationHistoryTablePresenter = () => {
|
||||||
const { register, handleSubmit, watch, setValue } = useForm();
|
const {
|
||||||
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
|
register: filterRegister,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
reset: filterFormReset,
|
||||||
|
} = useForm();
|
||||||
const [params, setParams] = useState<Record<string, any>>({
|
const [params, setParams] = useState<Record<string, any>>({
|
||||||
...apiDefaultParams,
|
...apiDefaultParams,
|
||||||
});
|
});
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const tableSectionRef = useRef<HTMLDivElement>(null);
|
||||||
|
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||||
|
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||||
|
|
||||||
const { data, isLoading } = useGetNotificationHistoryQuery(params);
|
const { data, isLoading } = useGetNotificationHistoryQuery(params);
|
||||||
|
|
||||||
|
useTableSectionRowAnimations(isLoading, data, {
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
});
|
||||||
|
|
||||||
const shouldShowPagination =
|
const shouldShowPagination =
|
||||||
!isLoading && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
!isLoading && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
||||||
const isMutating = useIsMutating();
|
const isMutating = useIsMutating();
|
||||||
@@ -32,14 +50,51 @@ const useNotificationHistoryTablePresenter = () => {
|
|||||||
];
|
];
|
||||||
const pathName = usePathname();
|
const pathName = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const search = (data: any) => {
|
|
||||||
const newParams = { ...params, ...data, offset: 0 };
|
useEffect(() => {
|
||||||
|
const newParams: Record<string, any> = {
|
||||||
|
...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<string, any>) => ({
|
||||||
|
...currentParams,
|
||||||
|
offset: e.selected * limit,
|
||||||
|
limit,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[data?.meta?.limit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const search = (formData: any) => {
|
||||||
|
const newParams = { ...params, ...formData, offset: 0 };
|
||||||
const cleanedParams = deleteEmptyKeys(newParams);
|
const cleanedParams = deleteEmptyKeys(newParams);
|
||||||
setParams(cleanedParams);
|
|
||||||
const queryString = new URLSearchParams(cleanedParams).toString();
|
const queryString = new URLSearchParams(cleanedParams).toString();
|
||||||
router.push(`${pathName}?${queryString}`);
|
router.push(`${pathName}?${queryString}`);
|
||||||
setIsFilterModalOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearAllFilters = useCallback(() => {
|
||||||
|
router.push(pathName);
|
||||||
|
}, [pathName, router]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
notificationHistory: data?.data,
|
notificationHistory: data?.data,
|
||||||
metadata: data?.meta,
|
metadata: data?.meta,
|
||||||
@@ -48,15 +103,20 @@ const useNotificationHistoryTablePresenter = () => {
|
|||||||
router,
|
router,
|
||||||
pathName,
|
pathName,
|
||||||
setParams,
|
setParams,
|
||||||
register,
|
filterRegister,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
handleFilterSubmit: handleSubmit,
|
||||||
watch,
|
watch,
|
||||||
setValue,
|
setValue,
|
||||||
isFilterModalOpen,
|
|
||||||
setIsFilterModalOpen,
|
|
||||||
isMutating,
|
isMutating,
|
||||||
search,
|
search,
|
||||||
shouldShowPagination,
|
shouldShowPagination,
|
||||||
|
clearAllFilters,
|
||||||
|
tableSectionRef,
|
||||||
|
skeletonTbodyRef,
|
||||||
|
dataTbodyRef,
|
||||||
|
pagination,
|
||||||
|
handlePaginationChange,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronUpIcon, GlobeIcon, SearchIcon } from "@/assets/icons";
|
import { GlobeIcon, SearchIcon } from "@/assets/icons";
|
||||||
import DatePicker from "@/components/FormElements/DatePicker/DatePicker";
|
import DatePicker from "@/components/FormElements/DatePicker/DatePicker";
|
||||||
import InputGroup from "@/components/FormElements/InputGroup";
|
import InputGroup from "@/components/FormElements/InputGroup";
|
||||||
import { Select } from "@/components/FormElements/select";
|
import { Select } from "@/components/FormElements/select";
|
||||||
import { Button } from "@/components/ui-elements/button";
|
import { Button } from "@/components/ui-elements/button";
|
||||||
|
import CollapsibleFilterSection from "@/components/ui/CollapsibleFilterSection";
|
||||||
|
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
||||||
import { Countries } from "@/constants/countries";
|
import { Countries } from "@/constants/countries";
|
||||||
import { cn } from "@/lib/utils";
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Control,
|
Control,
|
||||||
FieldValues,
|
FieldValues,
|
||||||
@@ -15,8 +17,6 @@ import {
|
|||||||
UseFormSetValue,
|
UseFormSetValue,
|
||||||
UseFormWatch,
|
UseFormWatch,
|
||||||
} from "react-hook-form";
|
} from "react-hook-form";
|
||||||
import gsap from "gsap";
|
|
||||||
import { type ReactNode, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
||||||
|
|
||||||
interface TenderListFiltersProps {
|
interface TenderListFiltersProps {
|
||||||
filterRegister: UseFormRegister<FieldValues>;
|
filterRegister: UseFormRegister<FieldValues>;
|
||||||
@@ -71,69 +71,6 @@ function countActiveFilters(values: Record<string, unknown>): number {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CollapsibleFilterCard({
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
badge,
|
|
||||||
defaultOpen,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
badge?: string;
|
|
||||||
defaultOpen?: boolean;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(Boolean(defaultOpen));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"overflow-hidden rounded-2xl border border-stroke/50 bg-white/50 backdrop-blur-md dark:border-white/[0.08] dark:bg-dark-2/45",
|
|
||||||
open && "ring-1 ring-primary/15 dark:ring-primary/25",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen((o) => !o)}
|
|
||||||
className="flex w-full items-center justify-between gap-3 px-4 py-3.5 text-start transition hover:bg-white/60 dark:hover:bg-white/[0.04]"
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="text-sm font-semibold text-dark dark:text-white">{title}</span>
|
|
||||||
{badge ? (
|
|
||||||
<span className="rounded-full bg-gradient-to-r from-primary/90 to-violet-500/85 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white">
|
|
||||||
{badge}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{subtitle ? (
|
|
||||||
<p className="mt-0.5 text-xs text-dark-5 dark:text-dark-6">{subtitle}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<ChevronUpIcon
|
|
||||||
className={cn(
|
|
||||||
"h-5 w-5 shrink-0 text-primary transition-transform duration-300 dark:text-primary/90",
|
|
||||||
!open && "rotate-180",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"grid transition-[grid-template-rows] duration-300 ease-out",
|
|
||||||
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="min-h-0 overflow-hidden">
|
|
||||||
<div className="border-t border-stroke/40 px-4 pb-5 pt-1 dark:border-white/[0.06]">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const TenderListFilters = ({
|
const TenderListFilters = ({
|
||||||
filterRegister,
|
filterRegister,
|
||||||
control,
|
control,
|
||||||
@@ -145,186 +82,19 @@ const TenderListFilters = ({
|
|||||||
onClearAll,
|
onClearAll,
|
||||||
}: TenderListFiltersProps) => {
|
}: TenderListFiltersProps) => {
|
||||||
const watched = watch();
|
const watched = watch();
|
||||||
const activeCount = useMemo(() => countActiveFilters(watched as Record<string, unknown>), [watched]);
|
const activeCount = useMemo(
|
||||||
const [panelOpen, setPanelOpen] = useState(false);
|
() => countActiveFilters(watched as Record<string, unknown>),
|
||||||
/** When true, header stays flush (square bottom) with the panel — keep true until collapse GSAP finishes so radius does not pop early. */
|
[watched],
|
||||||
const [headerFlushBottom, setHeaderFlushBottom] = useState(false);
|
|
||||||
|
|
||||||
const panelOuterRef = useRef<HTMLDivElement>(null);
|
|
||||||
const panelInnerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const chevronRef = useRef<HTMLSpanElement>(null);
|
|
||||||
const prevPanelOpen = useRef(panelOpen);
|
|
||||||
|
|
||||||
/** Initial dimensions without animating (avoids flash + Strict Mode double-invoke issues). */
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const outer = panelOuterRef.current;
|
|
||||||
const inner = panelInnerRef.current;
|
|
||||||
const chevron = chevronRef.current;
|
|
||||||
if (!outer || !inner) return;
|
|
||||||
|
|
||||||
gsap.set(outer, { height: panelOpen ? "auto" : 0 });
|
|
||||||
gsap.set(inner, {
|
|
||||||
opacity: panelOpen ? 1 : 0,
|
|
||||||
y: panelOpen ? 0 : -14,
|
|
||||||
scale: panelOpen ? 1 : 0.995,
|
|
||||||
});
|
|
||||||
if (chevron) gsap.set(chevron, { rotation: panelOpen ? 0 : 180, transformOrigin: "50% 50%" });
|
|
||||||
if (!panelOpen) {
|
|
||||||
setHeaderFlushBottom(false);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- run once on mount for initial `panelOpen`
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const outer = panelOuterRef.current;
|
|
||||||
const inner = panelInnerRef.current;
|
|
||||||
const chevron = chevronRef.current;
|
|
||||||
if (!outer || !inner) return;
|
|
||||||
|
|
||||||
if (prevPanelOpen.current === panelOpen) return;
|
|
||||||
prevPanelOpen.current = panelOpen;
|
|
||||||
|
|
||||||
const reduced =
|
|
||||||
typeof window !== "undefined" &&
|
|
||||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
||||||
|
|
||||||
gsap.killTweensOf([outer, inner, chevron].filter(Boolean));
|
|
||||||
|
|
||||||
if (reduced) {
|
|
||||||
gsap.set(outer, { height: panelOpen ? "auto" : 0 });
|
|
||||||
gsap.set(inner, { opacity: panelOpen ? 1 : 0, y: panelOpen ? 0 : -10, scale: 1 });
|
|
||||||
if (chevron) gsap.set(chevron, { rotation: panelOpen ? 0 : 180 });
|
|
||||||
setHeaderFlushBottom(panelOpen);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = gsap.context(() => {
|
|
||||||
if (panelOpen) {
|
|
||||||
setHeaderFlushBottom(true);
|
|
||||||
const targetH = inner.scrollHeight;
|
|
||||||
gsap.set(outer, { height: 0 });
|
|
||||||
gsap.set(inner, { opacity: 0, y: -36, scale: 0.985 });
|
|
||||||
|
|
||||||
const tl = gsap.timeline({ defaults: { ease: "none" } });
|
|
||||||
tl.to(outer, {
|
|
||||||
height: targetH,
|
|
||||||
duration: 0.62,
|
|
||||||
ease: "power4.out",
|
|
||||||
onComplete: () => {
|
|
||||||
gsap.set(outer, { height: "auto" });
|
|
||||||
},
|
|
||||||
}).to(
|
|
||||||
inner,
|
|
||||||
{
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
scale: 1,
|
|
||||||
duration: 0.52,
|
|
||||||
ease: "power3.out",
|
|
||||||
},
|
|
||||||
"-=0.5",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (chevron) {
|
|
||||||
tl.to(
|
|
||||||
chevron,
|
|
||||||
{ rotation: 0, duration: 0.48, ease: "back.out(1.35)" },
|
|
||||||
"-=0.52",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const currentH = Math.max(outer.offsetHeight, inner.scrollHeight);
|
|
||||||
gsap.set(outer, { height: currentH });
|
|
||||||
|
|
||||||
const tl = gsap.timeline({
|
|
||||||
onComplete: () => {
|
|
||||||
setHeaderFlushBottom(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
tl.to(inner, {
|
|
||||||
opacity: 0,
|
|
||||||
y: -22,
|
|
||||||
scale: 0.992,
|
|
||||||
duration: 0.3,
|
|
||||||
ease: "power2.in",
|
|
||||||
})
|
|
||||||
.to(
|
|
||||||
outer,
|
|
||||||
{
|
|
||||||
height: 0,
|
|
||||||
duration: 0.48,
|
|
||||||
ease: "power3.inOut",
|
|
||||||
},
|
|
||||||
"-=0.12",
|
|
||||||
);
|
|
||||||
if (chevron) {
|
|
||||||
tl.to(chevron, { rotation: 180, duration: 0.42, ease: "power2.inOut" }, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, outer);
|
|
||||||
|
|
||||||
return () => ctx.revert();
|
|
||||||
}, [panelOpen]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative mb-8">
|
<InlineListFiltersPanel
|
||||||
<div className="relative overflow-hidden rounded-[1.75rem] border border-stroke/60 bg-white dark:border-dark-3 dark:bg-dark-2">
|
title="Refine tenders"
|
||||||
<button
|
activeFilterCount={activeCount}
|
||||||
type="button"
|
expandAriaLabel="Expand tender filters"
|
||||||
onClick={() => setPanelOpen((o) => !o)}
|
collapseAriaLabel="Collapse tender filters"
|
||||||
aria-expanded={panelOpen}
|
panelToggleDataCy="tenders-filter-panel-toggle"
|
||||||
aria-label={panelOpen ? "Collapse tender filters" : "Expand tender filters"}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full flex-wrap items-start gap-x-3 gap-y-3 px-4 py-4 text-start transition-colors sm:items-center sm:justify-between sm:gap-4 sm:px-6 sm:py-5",
|
|
||||||
"rounded-[1.65rem] border border-transparent",
|
|
||||||
"hover:border-stroke/50 hover:bg-white/70 dark:hover:border-white/[0.08] dark:hover:bg-dark-3/50",
|
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-dark-2",
|
|
||||||
headerFlushBottom &&
|
|
||||||
"rounded-b-none border-b border-stroke/45 dark:border-white/[0.07]",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-1 items-start gap-3 sm:items-center">
|
|
||||||
<span className="mt-0.5 flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-violet-600 text-white sm:mt-0">
|
|
||||||
<SearchIcon className="h-5 w-5 opacity-95" />
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<span className="block text-lg font-bold tracking-tight text-dark dark:text-white">
|
|
||||||
Refine tenders
|
|
||||||
</span>
|
|
||||||
<p className="mt-0.5 max-w-xl text-sm text-dark-5 dark:text-dark-6">
|
|
||||||
Search and slice the catalogue inline — no popup. Expand sections when you need
|
|
||||||
precision.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
|
||||||
{activeCount > 0 ? (
|
|
||||||
<div className="flex items-center gap-2 rounded-full border border-primary/25 bg-primary/10 px-3 py-1.5 dark:border-primary/35 dark:bg-primary/15">
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-primary">
|
|
||||||
{activeCount} active
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center rounded-full border border-stroke/50 bg-white/60 px-3 py-1.5 text-xs font-medium text-dark-5 dark:border-white/10 dark:bg-dark-3/80 dark:text-dark-6">
|
|
||||||
No filters applied
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
ref={chevronRef}
|
|
||||||
className="inline-flex h-5 w-5 shrink-0 text-primary dark:text-primary/90"
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
<ChevronUpIcon className="h-5 w-5" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={panelOuterRef}
|
|
||||||
className="overflow-hidden"
|
|
||||||
aria-hidden={!panelOpen}
|
|
||||||
>
|
|
||||||
<div ref={panelInnerRef} className="will-change-transform">
|
|
||||||
<form
|
<form
|
||||||
className="space-y-4 px-4 pb-6 pt-1 sm:px-6"
|
className="space-y-4 px-4 pb-6 pt-1 sm:px-6"
|
||||||
onSubmit={handleFilterSubmit(search)}
|
onSubmit={handleFilterSubmit(search)}
|
||||||
@@ -338,7 +108,9 @@ const TenderListFilters = ({
|
|||||||
label="Keyword search"
|
label="Keyword search"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Title, buyer, CPV — cast a wide net…"
|
placeholder="Title, buyer, CPV — cast a wide net…"
|
||||||
icon={<SearchIcon className="text-primary dark:text-primary/90" />}
|
icon={
|
||||||
|
<SearchIcon className="text-primary dark:text-primary/90" />
|
||||||
|
}
|
||||||
iconPosition="left"
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
@@ -347,7 +119,6 @@ const TenderListFilters = ({
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="small"
|
size="small"
|
||||||
shape="rounded"
|
|
||||||
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
|
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
|
||||||
label={
|
label={
|
||||||
isMutating ? (
|
isMutating ? (
|
||||||
@@ -367,7 +138,7 @@ const TenderListFilters = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CollapsibleFilterCard
|
<CollapsibleFilterSection
|
||||||
title="Scope & geography"
|
title="Scope & geography"
|
||||||
subtitle="Country pick or comma-separated codes for multi-region searches."
|
subtitle="Country pick or comma-separated codes for multi-region searches."
|
||||||
>
|
>
|
||||||
@@ -391,9 +162,9 @@ const TenderListFilters = ({
|
|||||||
placeholder="e.g. SWE, DEU"
|
placeholder="e.g. SWE, DEU"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleFilterCard>
|
</CollapsibleFilterSection>
|
||||||
|
|
||||||
<CollapsibleFilterCard
|
<CollapsibleFilterSection
|
||||||
title="Notice & classification"
|
title="Notice & classification"
|
||||||
subtitle="TED notice dimensions and CPV-style filters."
|
subtitle="TED notice dimensions and CPV-style filters."
|
||||||
>
|
>
|
||||||
@@ -447,9 +218,9 @@ const TenderListFilters = ({
|
|||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleFilterCard>
|
</CollapsibleFilterSection>
|
||||||
|
|
||||||
<CollapsibleFilterCard
|
<CollapsibleFilterSection
|
||||||
title="Date ranges"
|
title="Date ranges"
|
||||||
subtitle="Unix-backed ranges — pick start/end; cleared ranges are omitted from the query."
|
subtitle="Unix-backed ranges — pick start/end; cleared ranges are omitted from the query."
|
||||||
badge="Time travel"
|
badge="Time travel"
|
||||||
@@ -463,6 +234,7 @@ const TenderListFilters = ({
|
|||||||
range
|
range
|
||||||
timePicker
|
timePicker
|
||||||
clearable
|
clearable
|
||||||
|
portal
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-stroke/40 bg-white/60 p-3 dark:border-white/[0.06] dark:bg-dark-3/40">
|
<div className="rounded-xl border border-stroke/40 bg-white/60 p-3 dark:border-white/[0.06] dark:bg-dark-3/40">
|
||||||
@@ -473,6 +245,7 @@ const TenderListFilters = ({
|
|||||||
range
|
range
|
||||||
timePicker
|
timePicker
|
||||||
clearable
|
clearable
|
||||||
|
portal
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-stroke/40 bg-white/60 p-3 dark:border-white/[0.06] dark:bg-dark-3/40">
|
<div className="rounded-xl border border-stroke/40 bg-white/60 p-3 dark:border-white/[0.06] dark:bg-dark-3/40">
|
||||||
@@ -483,6 +256,7 @@ const TenderListFilters = ({
|
|||||||
range
|
range
|
||||||
timePicker
|
timePicker
|
||||||
clearable
|
clearable
|
||||||
|
portal
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-stroke/40 bg-white/60 p-3 dark:border-white/[0.06] dark:bg-dark-3/40">
|
<div className="rounded-xl border border-stroke/40 bg-white/60 p-3 dark:border-white/[0.06] dark:bg-dark-3/40">
|
||||||
@@ -493,15 +267,13 @@ const TenderListFilters = ({
|
|||||||
range
|
range
|
||||||
timePicker
|
timePicker
|
||||||
clearable
|
clearable
|
||||||
|
portal
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleFilterCard>
|
</CollapsibleFilterSection>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</InlineListFiltersPanel>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,37 @@ const buildTenderFilterFormValues = (params: Record<string, any>) => ({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 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<string, any> = {
|
||||||
|
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<string, any>) => {
|
||||||
|
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<string, any>) => {
|
const mapDateRangeFilters = (values: Record<string, any>) => {
|
||||||
const mappedValues = { ...values };
|
const mappedValues = { ...values };
|
||||||
|
|
||||||
@@ -250,7 +281,7 @@ const useTenderListPresenter = () => {
|
|||||||
newParams[key] = value;
|
newParams[key] = value;
|
||||||
}
|
}
|
||||||
setParams(newParams);
|
setParams(newParams);
|
||||||
filterFormReset(buildTenderFilterFormValues(newParams));
|
filterFormReset(mergeTenderFilterFormState(newParams));
|
||||||
}, [searchParams, filterFormReset]);
|
}, [searchParams, filterFormReset]);
|
||||||
|
|
||||||
const { data, error, isPending } = useTendersQuery(params);
|
const { data, error, isPending } = useTendersQuery(params);
|
||||||
@@ -377,8 +408,14 @@ const useTenderListPresenter = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearAllFilters = useCallback(() => {
|
const clearAllFilters = useCallback(() => {
|
||||||
|
const cleared: Record<string, any> = {
|
||||||
|
...apiDefaultParams,
|
||||||
|
offset: 0,
|
||||||
|
limit: 10,
|
||||||
|
};
|
||||||
|
filterFormReset(mergeTenderFilterFormState(cleared));
|
||||||
router.push(pathName);
|
router.push(pathName);
|
||||||
}, [pathName, router]);
|
}, [pathName, router, filterFormReset]);
|
||||||
const columns = [
|
const columns = [
|
||||||
"row",
|
"row",
|
||||||
"title",
|
"title",
|
||||||
|
|||||||
+48
-75
@@ -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 { cn } from "@/lib/utils";
|
||||||
|
import Lottie from "lottie-react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
className?: string | null;
|
className?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-screen route-style loader (orbs, rings, shimmer). Pass `className` to adapt
|
* Extract of `animations/main.json` from `public/lottie/loading.lottie` (regenerate when
|
||||||
* the shell—for example `min-h-48 flex-none` inside compact panels.
|
* 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 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 (
|
return (
|
||||||
<>
|
|
||||||
<style>{`
|
|
||||||
@keyframes app-route-loading-shimmer {
|
|
||||||
0% { transform: translateX(-100%); }
|
|
||||||
100% { transform: translateX(400%); }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
<div
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-busy="true"
|
||||||
|
aria-live="polite"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex min-h-svh w-full min-w-0 flex-1 items-center justify-center overflow-hidden bg-gray dark:bg-dark",
|
"relative flex min-h-svh w-full min-w-0 flex-1 flex-col items-center justify-center overflow-hidden",
|
||||||
|
"bg-gray-1 dark:bg-dark",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Soft gradient wash */}
|
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(87,80,241,0.22),transparent)] dark:bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(87,80,241,0.35),transparent)]"
|
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
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Floating orbs */}
|
<div className="pointer-events-none absolute left-6 top-6 h-14 w-14 border-l-2 border-t-2 border-primary/45 dark:border-primary/40 sm:left-10 sm:top-10" />
|
||||||
<div
|
<div className="pointer-events-none absolute bottom-6 right-6 h-14 w-14 border-b-2 border-r-2 border-primary/45 dark:border-primary/40 sm:bottom-10 sm:right-10" />
|
||||||
className="pointer-events-none absolute -left-32 top-1/4 h-72 w-72 animate-pulse rounded-full bg-primary/20 blur-3xl dark:bg-primary/25"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute -right-24 bottom-1/4 h-64 w-64 animate-pulse rounded-full bg-blue/25 blur-3xl dark:bg-blue/20"
|
|
||||||
style={{ animationDelay: "1s" }}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute left-1/2 top-1/2 h-96 w-96 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/10 blur-[100px] dark:bg-primary/15"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Subtle grid */}
|
<div className="relative z-10 flex flex-col items-center gap-6 px-8">
|
||||||
<div
|
<div className="relative aspect-square w-[min(200px,55vw)] shrink-0">
|
||||||
className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_right,rgba(148,163,184,0.06)_1px,transparent_1px),linear-gradient(to_bottom,rgba(148,163,184,0.06)_1px,transparent_1px)] bg-[size:48px_48px] dark:bg-[linear-gradient(to_right,rgba(55,65,81,0.15)_1px,transparent_1px),linear-gradient(to_bottom,rgba(55,65,81,0.15)_1px,transparent_1px)]"
|
<div className="absolute inset-0 flex items-center justify-center [&_svg]:h-full [&_svg]:w-full [&_svg]:max-h-full [&_svg]:max-w-full">
|
||||||
|
<Lottie
|
||||||
|
key={mode}
|
||||||
|
animationData={animationData}
|
||||||
|
loop
|
||||||
|
className="h-full w-full"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative z-10 flex flex-col items-center gap-8 px-6">
|
|
||||||
<div className="relative flex h-28 w-28 items-center justify-center">
|
|
||||||
{/* Outer glow */}
|
|
||||||
<div className="absolute inset-0 rounded-full bg-primary/20 blur-xl dark:bg-primary/30" />
|
|
||||||
|
|
||||||
{/* Concentric rings */}
|
|
||||||
<div className="absolute inset-0 animate-spin rounded-full border-2 border-transparent border-r-primary/40 border-t-primary/90 shadow-[0_0_20px_rgba(87,80,241,0.35)]" />
|
|
||||||
<div
|
|
||||||
className="absolute inset-2 animate-[spin_2.5s_linear_infinite_reverse] rounded-full border-2 border-transparent border-b-blue/70 border-l-blue/30"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-5 rounded-full border border-stroke/80 dark:border-stroke-dark/60" />
|
|
||||||
|
|
||||||
{/* Core */}
|
|
||||||
<div className="relative flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-blue shadow-lg ring-4 ring-white/60 dark:ring-dark-2/80">
|
|
||||||
<span className="text-lg font-bold tracking-tight text-white drop-shadow-sm">
|
|
||||||
OL
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.45em] text-gray-6 dark:text-dark-6">
|
||||||
<div className="flex flex-col items-center gap-3 text-center">
|
Loading
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="h-2 w-2 animate-pulse rounded-full bg-primary" />
|
|
||||||
<span
|
|
||||||
className="h-2 w-2 animate-pulse rounded-full bg-primary/70"
|
|
||||||
style={{ animationDelay: "0.2s" }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="h-2 w-2 animate-pulse rounded-full bg-primary/40"
|
|
||||||
style={{ animationDelay: "0.4s" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="bg-gradient-to-r from-dark via-primary to-blue bg-clip-text text-body-sm font-medium text-transparent dark:from-gray-7 dark:via-primary dark:to-blue-light">
|
|
||||||
Preparing your workspace…
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Shimmer bar */}
|
<span className="sr-only">Loading, please wait.</span>
|
||||||
<div className="h-1 w-48 overflow-hidden rounded-full bg-stroke dark:bg-stroke-dark">
|
|
||||||
<div
|
|
||||||
className="h-full w-1/3 rounded-full bg-gradient-to-r from-transparent via-primary to-transparent"
|
|
||||||
style={{
|
|
||||||
animation:
|
|
||||||
"app-route-loading-shimmer 1.5s ease-in-out infinite",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative isolate flex min-h-svh w-full flex-col overflow-hidden",
|
||||||
|
"bg-gray-1 dark:bg-dark",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Ambient mesh */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute -left-1/4 top-0 h-[min(85vh,720px)] w-[min(85vw,720px)] rounded-full bg-primary/[0.22] blur-[100px] dark:bg-primary/[0.12]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute -right-1/4 bottom-0 h-[min(70vh,560px)] w-[min(70vw,560px)] rounded-full bg-blue/[0.18] blur-[90px] dark:bg-blue/[0.1]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute left-1/2 top-1/3 h-64 w-64 -translate-x-1/2 rounded-full bg-primary/[0.08] blur-3xl dark:bg-primary/[0.06]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Vignette + fine grid */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_55%_at_50%_38%,transparent_0%,rgba(17,25,40,0.04)_55%,rgba(17,25,40,0.09)_100%)] dark:bg-[radial-gradient(ellipse_80%_55%_at_50%_38%,transparent_0%,rgba(0,0,0,0.35)_55%,rgba(0,0,0,0.65)_100%)]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute inset-0 opacity-[0.45] dark:opacity-[0.22]",
|
||||||
|
"[background-image:linear-gradient(to_right,rgba(17,25,40,0.06)_1px,transparent_1px),linear-gradient(to_bottom,rgba(17,25,40,0.06)_1px,transparent_1px)]",
|
||||||
|
"dark:[background-image:linear-gradient(to_right,rgba(148,163,184,0.08)_1px,transparent_1px),linear-gradient(to_bottom,rgba(148,163,184,0.08)_1px,transparent_1px)]",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundSize: "28px 28px",
|
||||||
|
backgroundPosition: "0 0",
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Frame accents */}
|
||||||
|
<div className="pointer-events-none absolute left-5 top-5 h-16 w-16 border-l-2 border-t-2 border-primary/40 dark:border-primary/35 sm:left-8 sm:top-8" />
|
||||||
|
<div className="pointer-events-none absolute bottom-5 right-5 h-16 w-16 border-b-2 border-r-2 border-primary/40 dark:border-primary/35 sm:bottom-8 sm:right-8" />
|
||||||
|
|
||||||
|
<div className="relative z-10 flex flex-1 flex-col items-center justify-center px-4 py-14 sm:px-6 sm:py-16">
|
||||||
|
<div className="w-full max-w-xl">
|
||||||
|
{/* Gradient hairline frame */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-[1.75rem] p-[1px] shadow-5",
|
||||||
|
"bg-gradient-to-br from-primary/55 via-primary/[0.12] to-blue/35",
|
||||||
|
"dark:from-primary/45 dark:via-primary/[0.08] dark:to-blue/25",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative overflow-hidden rounded-[1.7rem]",
|
||||||
|
"bg-white/[0.92] backdrop-blur-2xl dark:bg-dark-2/[0.88]",
|
||||||
|
"border border-white/60 dark:border-dark-3/80",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-primary/35 to-transparent"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative px-6 pb-8 pt-7 sm:px-10 sm:pb-10 sm:pt-9">
|
||||||
|
<div className="mb-6 flex flex-col items-center gap-3 text-center sm:mb-8">
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full border border-stroke/90 bg-gray-1/90 px-3 py-1 font-mono text-[10px] font-semibold uppercase tracking-[0.28em] text-gray-6 shadow-theme-xs dark:border-dark-3 dark:bg-dark/60 dark:text-dark-6">
|
||||||
|
<span
|
||||||
|
className="h-1.5 w-1.5 shrink-0 rounded-full bg-primary shadow-[0_0_10px_rgba(87,80,241,0.65)]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
Route not found
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<h1 className="text-[clamp(3.25rem,12vw,5.5rem)] font-bold leading-none tracking-tight">
|
||||||
|
<span className="bg-gradient-to-br from-dark via-dark to-gray-7 bg-clip-text text-transparent dark:from-white dark:via-white dark:to-dark-6">
|
||||||
|
4
|
||||||
|
</span>
|
||||||
|
<span className="bg-gradient-to-br from-primary via-primary to-blue bg-clip-text text-transparent">
|
||||||
|
0
|
||||||
|
</span>
|
||||||
|
<span className="bg-gradient-to-br from-dark via-dark to-gray-7 bg-clip-text text-transparent dark:from-white dark:via-white dark:to-dark-6">
|
||||||
|
4
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="max-w-sm text-body-sm text-gray-6 dark:text-dark-6">
|
||||||
|
This URL is not mapped in the panel. The page may have moved
|
||||||
|
or the link could be mistyped.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mx-auto w-full max-w-[min(100%,400px)]">
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute -inset-3 rounded-2xl bg-gradient-to-b from-primary/[0.07] to-transparent dark:from-primary/[0.05]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="relative [&_canvas]:mx-auto [&_canvas]:block [&_canvas]:h-auto [&_canvas]:w-full">
|
||||||
|
{loadError ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[200px] w-full items-center justify-center rounded-2xl border border-dashed border-error/40 bg-gray-1/50 dark:border-error/35 dark:bg-dark/40",
|
||||||
|
"animate-pulse",
|
||||||
|
)}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<span className="text-body-xs font-medium text-gray-6 dark:text-dark-6">
|
||||||
|
Could not load illustration
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DotLottieReact
|
||||||
|
key={mode}
|
||||||
|
src={LOTTIE_URL}
|
||||||
|
loop
|
||||||
|
autoplay
|
||||||
|
className="h-auto w-full max-w-full drop-shadow-[0_12px_40px_rgba(87,80,241,0.12)] dark:drop-shadow-[0_12px_48px_rgba(0,0,0,0.35)]"
|
||||||
|
aria-hidden
|
||||||
|
onError={() => setLoadError(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-col items-stretch gap-3 sm:mt-10 sm:flex-row sm:items-center sm:justify-center sm:gap-4">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className={cn(
|
||||||
|
"shadow-theme-xs relative isolate inline-flex w-full items-center justify-center overflow-hidden rounded-xl bg-gradient-to-r from-primary to-primary/85 py-[11px] px-6 text-center text-body-sm font-semibold text-white transition hover:opacity-95",
|
||||||
|
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary",
|
||||||
|
"sm:flex-1 sm:max-w-[200px]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Back to dashboard
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
typeof window !== "undefined" && window.history.back()
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border border-stroke bg-white/90 px-6 py-3 text-body-sm font-semibold text-dark shadow-theme-xs",
|
||||||
|
"transition hover:border-primary/40 hover:text-primary",
|
||||||
|
"dark:border-dark-3 dark:bg-dark/80 dark:text-white dark:hover:border-primary/35 dark:hover:bg-dark-3",
|
||||||
|
"sm:flex-1 sm:max-w-[200px]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Go back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center font-mono text-[10px] uppercase tracking-[0.35em] text-gray-5 dark:text-dark-5">
|
||||||
|
Opp lens · admin
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-2xl border border-stroke/50 bg-white/50 backdrop-blur-md dark:border-white/[0.08] dark:bg-dark-2/45",
|
||||||
|
open ? "overflow-visible" : "overflow-hidden",
|
||||||
|
open && "ring-1 ring-primary/15 dark:ring-primary/25",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="flex w-full items-center justify-between gap-3 px-4 py-3.5 text-start transition hover:bg-white/60 dark:hover:bg-white/[0.04]"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-dark dark:text-white">{title}</span>
|
||||||
|
{badge ? (
|
||||||
|
<span className="rounded-full bg-gradient-to-r from-primary/90 to-violet-500/85 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{subtitle ? (
|
||||||
|
<p className="mt-0.5 text-xs text-dark-5 dark:text-dark-6">{subtitle}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<ChevronUpIcon
|
||||||
|
className={cn(
|
||||||
|
"h-5 w-5 shrink-0 text-primary transition-transform duration-300 dark:text-primary/90",
|
||||||
|
!open && "rotate-180",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid transition-[grid-template-rows] duration-300 ease-out",
|
||||||
|
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn("min-h-0", open ? "overflow-visible" : "overflow-hidden")}>
|
||||||
|
<div className="border-t border-stroke/40 px-4 pb-5 pt-1 dark:border-white/[0.06]">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 `<form>...</form>` (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<HTMLDivElement>(null);
|
||||||
|
const panelInnerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const chevronRef = useRef<HTMLSpanElement>(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 (
|
||||||
|
<div className={cn("relative mb-8", className)}>
|
||||||
|
<div className="relative overflow-hidden rounded-[1.75rem] border border-stroke/60 bg-white dark:border-dark-3 dark:bg-dark-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPanelOpen((o) => !o)}
|
||||||
|
aria-expanded={panelOpen}
|
||||||
|
aria-label={panelOpen ? collapseAriaLabel : expandAriaLabel}
|
||||||
|
data-cy={panelToggleDataCy}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-wrap items-start gap-x-3 gap-y-3 px-4 py-4 text-start transition-colors sm:items-center sm:justify-between sm:gap-4 sm:px-6 sm:py-5",
|
||||||
|
"rounded-[1.65rem] border border-transparent",
|
||||||
|
"hover:border-stroke/50 hover:bg-white/70 dark:hover:border-white/[0.08] dark:hover:bg-dark-3/50",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-dark-2",
|
||||||
|
headerFlushBottom &&
|
||||||
|
"rounded-b-none border-b border-stroke/45 dark:border-white/[0.07]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 items-start gap-3 sm:items-center">
|
||||||
|
<span className="mt-0.5 flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-violet-600 text-white sm:mt-0">
|
||||||
|
<SearchIcon className="h-5 w-5 opacity-95" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="block text-lg font-bold tracking-tight text-dark dark:text-white">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
<IsVisible condition={!!description}>
|
||||||
|
<p className="mt-0.5 max-w-xl text-sm text-dark-5 dark:text-dark-6">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</IsVisible>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||||
|
{activeFilterCount > 0 ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-full border border-primary/25 bg-primary/10 px-3 py-1.5 dark:border-primary/35 dark:bg-primary/15">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-primary">
|
||||||
|
{activeFilterCount} active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center rounded-full border border-stroke/50 bg-white/60 px-3 py-1.5 text-xs font-medium text-dark-5 dark:border-white/10 dark:bg-dark-3/80 dark:text-dark-6">
|
||||||
|
No filters applied
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
ref={chevronRef}
|
||||||
|
className="inline-flex h-5 w-5 shrink-0 text-primary dark:text-primary/90"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={panelOuterRef}
|
||||||
|
className="overflow-hidden"
|
||||||
|
aria-hidden={!panelOpen}
|
||||||
|
>
|
||||||
|
<div ref={panelInnerRef} className="will-change-transform">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,8 +23,18 @@ const ListHeader = ({
|
|||||||
}: IProps) => {
|
}: IProps) => {
|
||||||
const pathName = usePathname();
|
const pathName = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const rowJustify =
|
||||||
|
hasFilter && hasCreate
|
||||||
|
? "justify-between"
|
||||||
|
: hasCreate && !hasFilter
|
||||||
|
? "justify-center md:justify-end"
|
||||||
|
: "justify-start";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-1 flex flex-col-reverse items-center justify-between gap-3 md:flex-row">
|
<div
|
||||||
|
className={`mb-1 flex flex-col-reverse items-center gap-3 md:flex-row ${rowJustify}`}
|
||||||
|
>
|
||||||
<IsVisible condition={hasFilter}>
|
<IsVisible condition={hasFilter}>
|
||||||
<Button
|
<Button
|
||||||
label={filterButtonText}
|
label={filterButtonText}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import gsap from "gsap";
|
||||||
|
import { type RefObject, useLayoutEffect } from "react";
|
||||||
|
|
||||||
|
type TableAnimRefs = {
|
||||||
|
tableSectionRef: RefObject<HTMLDivElement | null>;
|
||||||
|
skeletonTbodyRef: RefObject<HTMLTableSectionElement | null>;
|
||||||
|
dataTbodyRef: RefObject<HTMLTableSectionElement | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stagger row entrance for table skeleton vs data body (matches admins/tenders pattern).
|
||||||
|
*/
|
||||||
|
export function useTableSectionRowAnimations(
|
||||||
|
isPending: boolean,
|
||||||
|
/** Changes when loaded list/payload updates — retriggers data row animation */
|
||||||
|
dataEpoch: unknown,
|
||||||
|
refs: TableAnimRefs,
|
||||||
|
) {
|
||||||
|
const { tableSectionRef, skeletonTbodyRef, dataTbodyRef } = refs;
|
||||||
|
|
||||||
|
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, dataEpoch]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Brand-aligned Lottie tinting (Tailwind theme tokens → normalized RGBA).
|
||||||
|
* Primary: #5750F1 — slightly lifted in dark mode for contrast on `dark` bg.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type LottieThemeMode = "light" | "dark";
|
||||||
|
|
||||||
|
/** #5750F1 */
|
||||||
|
const PRIMARY_LIGHT = [87 / 255, 80 / 255, 241 / 255, 1] as const;
|
||||||
|
/** Brighter violet for fills on #111928 */
|
||||||
|
const PRIMARY_ON_DARK = [125 / 255, 118 / 255, 252 / 255, 1] as const;
|
||||||
|
|
||||||
|
function deepClone<T>(value: T): T {
|
||||||
|
return JSON.parse(JSON.stringify(value)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorsMatch(a: unknown, b: readonly number[]): boolean {
|
||||||
|
if (!Array.isArray(a) || a.length !== 4) return false;
|
||||||
|
return a.every(
|
||||||
|
(v, i) => typeof v === "number" && Math.abs(v - b[i]!) < 0.0001,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace exact RGBA quadruples anywhere in the JSON tree (Lottie uses nested `c.k`).
|
||||||
|
*/
|
||||||
|
function replaceRgbaEverywhere(
|
||||||
|
node: unknown,
|
||||||
|
from: readonly number[],
|
||||||
|
to: readonly number[],
|
||||||
|
): unknown {
|
||||||
|
if (node === null || typeof node !== "object") {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
if (colorsMatch(node, from)) {
|
||||||
|
return [...to];
|
||||||
|
}
|
||||||
|
return node.map((item) => replaceRgbaEverywhere(item, from, to));
|
||||||
|
}
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(node)) {
|
||||||
|
out[k] = replaceRgbaEverywhere(v, from, to);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure black fills/strokes → brand primary (loading spinner dots, etc.). */
|
||||||
|
export function applyLoadingLottieTheme<T extends object>(
|
||||||
|
data: T,
|
||||||
|
mode: LottieThemeMode,
|
||||||
|
): T {
|
||||||
|
const clone = deepClone(data);
|
||||||
|
const to = mode === "dark" ? PRIMARY_ON_DARK : PRIMARY_LIGHT;
|
||||||
|
return replaceRgbaEverywhere(clone, [0, 0, 0, 1], to) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full illustrations (e.g. 404): clone only until we add palette maps per asset.
|
||||||
|
*/
|
||||||
|
export function applyIllustrationLottieTheme<T extends object>(
|
||||||
|
data: T,
|
||||||
|
_mode: LottieThemeMode,
|
||||||
|
): T {
|
||||||
|
return deepClone(data);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user