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", () => {
|
||||
cy.intercept(
|
||||
"POST",
|
||||
`**/api/proxy/tenders/${tenderId}/ai-translate`,
|
||||
(req) => {
|
||||
req.reply({
|
||||
statusCode: 200,
|
||||
body: {
|
||||
success: true,
|
||||
message: "ok",
|
||||
data: {
|
||||
notice_id: "NP-123",
|
||||
language: String(req.body?.language ?? ""),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
).as("translateOnLangChange");
|
||||
|
||||
cy.intercept("GET", `**/api/proxy/tenders/${tenderId}**`, (req) => {
|
||||
const lang = String(req.query.lang ?? "en");
|
||||
req.alias = lang === "fr" ? "getTenderDetailsFr" : "getTenderDetailsEn";
|
||||
@@ -91,18 +109,19 @@ describe("tender details - translation and summary", () => {
|
||||
|
||||
cy.get('select[name="lang"]').select("fr");
|
||||
cy.location("search").should("include", "lang=fr");
|
||||
cy.wait("@translateOnLangChange");
|
||||
cy.wait("@getTenderDetailsFr");
|
||||
tenderTitleHeading().should("contain.text", "Titre FR");
|
||||
cy.contains("Resume FR").should("be.visible");
|
||||
});
|
||||
|
||||
it("triggers translate endpoint with selected language", () => {
|
||||
it("triggers translate endpoint when language changes", () => {
|
||||
cy.intercept("GET", `**/api/proxy/tenders/${tenderId}**`, {
|
||||
statusCode: 200,
|
||||
body: makeTenderDetailsResponse({
|
||||
title: "Before translate",
|
||||
description: "Before translate description",
|
||||
language: "de",
|
||||
language: "en",
|
||||
}),
|
||||
}).as("getTenderDetails");
|
||||
|
||||
@@ -127,9 +146,9 @@ describe("tender details - translation and summary", () => {
|
||||
},
|
||||
).as("translateTender");
|
||||
|
||||
cy.visit(`/tenders/${tenderId}?lang=de`);
|
||||
cy.visit(`/tenders/${tenderId}?lang=en`);
|
||||
cy.wait("@getTenderDetails");
|
||||
cy.contains("button", "Translate metadata").click();
|
||||
cy.get('select[name="lang"]').select("de");
|
||||
cy.wait("@translateTender");
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"test:e2e": "start-server-and-test dev:e2e http://localhost:3000 \"CYPRESS_BASE_URL=http://localhost:3000 cypress run\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@lottiefiles/dotlottie-react": "^0.19.2",
|
||||
"@tanstack/react-query": "^5.85.6",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/react-paginate": "^7.1.4",
|
||||
@@ -28,6 +29,7 @@
|
||||
"gsap": "^3.15.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsvectormap": "^1.6.0",
|
||||
"lottie-react": "^2.4.1",
|
||||
"moment": "^2.30.1",
|
||||
"next": "15.1.9",
|
||||
"next-themes": "^0.4.4",
|
||||
|
||||
Generated
+38
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@lottiefiles/dotlottie-react':
|
||||
specifier: ^0.19.2
|
||||
version: 0.19.2(react@19.0.0)
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.85.6
|
||||
version: 5.100.2(react@19.0.0)
|
||||
@@ -50,6 +53,9 @@ importers:
|
||||
jsvectormap:
|
||||
specifier: ^1.6.0
|
||||
version: 1.7.0
|
||||
lottie-react:
|
||||
specifier: ^2.4.1
|
||||
version: 2.4.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
moment:
|
||||
specifier: ^2.30.1
|
||||
version: 2.30.1
|
||||
@@ -597,6 +603,14 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@lottiefiles/dotlottie-react@0.19.2':
|
||||
resolution: {integrity: sha512-qXvCU5Wm/BNYfoS/+MzeVqn03yu2v3XTJ+ik8kKgyHlbf6Vcgz3byOQk9H/mOciedjhGr+YmZXZ+ItqFUmA3Iw==}
|
||||
peerDependencies:
|
||||
react: ^17 || ^18 || ^19
|
||||
|
||||
'@lottiefiles/dotlottie-web@0.72.1':
|
||||
resolution: {integrity: sha512-TPHCTQHw4oZBiIi/JPd6CtjPzsazpyRVUloO7kA5khF6IX/sCIfBa8l/Tav333pskO6gFMtlnfRPwEOatMZS/A==}
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||
|
||||
@@ -2103,6 +2117,15 @@ packages:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
lottie-react@2.4.1:
|
||||
resolution: {integrity: sha512-LQrH7jlkigIIv++wIyrOYFLHSKQpEY4zehPicL9bQsrt1rnoKRYCYgpCUe5maqylNtacy58/sQDZTkwMcTRxZw==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
lottie-web@5.13.0:
|
||||
resolution: {integrity: sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==}
|
||||
|
||||
map-stream@0.1.0:
|
||||
resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==}
|
||||
|
||||
@@ -3588,6 +3611,13 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@lottiefiles/dotlottie-react@0.19.2(react@19.0.0)':
|
||||
dependencies:
|
||||
'@lottiefiles/dotlottie-web': 0.72.1
|
||||
react: 19.0.0
|
||||
|
||||
'@lottiefiles/dotlottie-web@0.72.1': {}
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
@@ -5280,6 +5310,14 @@ snapshots:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
lottie-react@2.4.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
|
||||
dependencies:
|
||||
lottie-web: 5.13.0
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
|
||||
lottie-web@5.13.0: {}
|
||||
|
||||
map-stream@0.1.0: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,37 +1,5 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import NotFoundContent from "@/components/not-found-content";
|
||||
|
||||
const NotFoundPage = () => {
|
||||
return (
|
||||
<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;
|
||||
export default function NotFoundPage() {
|
||||
return <NotFoundContent />;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { GlobeIcon } from "@/assets/icons";
|
||||
import { Select } from "@/components/FormElements/select";
|
||||
import { Button } from "@/components/ui-elements/button";
|
||||
import Status from "@/components/ui/Status";
|
||||
import { Languages } from "@/constants/languages";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useRef } from "react";
|
||||
import type { FieldValues } from "react-hook-form";
|
||||
import { TENDER_HEADER_TITLE_CLASS } from "../constants";
|
||||
import { useStackTenderHeaderActions } from "../hooks/use-stack-tender-header-actions";
|
||||
|
||||
type LanguageSelectOnChange = NonNullable<
|
||||
ComponentProps<typeof Select<FieldValues>>["onChange"]
|
||||
@@ -19,7 +17,6 @@ type TenderDetailsHeaderProps = {
|
||||
title: string;
|
||||
selectedLanguage: string;
|
||||
onLanguageChange: (event: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
onTranslate: () => void;
|
||||
isTranslating: boolean;
|
||||
status: string | undefined;
|
||||
};
|
||||
@@ -28,94 +25,69 @@ export function TenderDetailsHeader({
|
||||
title,
|
||||
selectedLanguage,
|
||||
onLanguageChange,
|
||||
onTranslate,
|
||||
isTranslating,
|
||||
status,
|
||||
}: TenderDetailsHeaderProps) {
|
||||
const headerLayoutContainerRef = useRef<HTMLDivElement>(null);
|
||||
const headerTitleProbeSizerRef = useRef<HTMLDivElement>(null);
|
||||
const stackActionsBelow = useStackTenderHeaderActions(
|
||||
title,
|
||||
headerLayoutContainerRef,
|
||||
headerTitleProbeSizerRef,
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={headerLayoutContainerRef} className="relative">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0 -z-10 max-h-0 overflow-hidden opacity-0"
|
||||
aria-hidden
|
||||
>
|
||||
<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",
|
||||
)}
|
||||
>
|
||||
<div className="relative w-full">
|
||||
<div className="flex w-full min-w-0 flex-col gap-5">
|
||||
<div className="w-full min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">
|
||||
Tender
|
||||
</p>
|
||||
<Status status={status ?? "Unknown"}>{status}</Status>
|
||||
</div>
|
||||
<h1 className={TENDER_HEADER_TITLE_CLASS}>{title}</h1>
|
||||
</div>
|
||||
<div className="flex w-full min-w-0 shrink-0 flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full min-w-0 shrink-0 flex-col gap-3",
|
||||
!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",
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
<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
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center",
|
||||
stackActionsBelow
|
||||
? "sm:justify-start lg:justify-start"
|
||||
: "sm:justify-end lg:justify-end",
|
||||
)}
|
||||
>
|
||||
<p className="mt-1 text-xs leading-relaxed text-dark-5/90 dark:text-dark-6/95">
|
||||
Changing language updates the URL, translates metadata, and
|
||||
reloads this view.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select<FieldValues>
|
||||
name="lang"
|
||||
label="Display language"
|
||||
items={Languages}
|
||||
disabled={isTranslating}
|
||||
prefixIcon={
|
||||
<GlobeIcon className="h-[1.05rem] w-[1.05rem] text-primary dark:text-primary/90" />
|
||||
}
|
||||
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",
|
||||
"[&_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}
|
||||
onChange={onLanguageChange as LanguageSelectOnChange}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
shape="full"
|
||||
className="w-full shrink-0 whitespace-nowrap sm:min-h-[3.375rem] sm:w-auto"
|
||||
label={
|
||||
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>
|
||||
{isTranslating ? (
|
||||
<p className="text-xs font-medium text-primary dark:text-primary/90">
|
||||
Translating…
|
||||
</p>
|
||||
) : null}
|
||||
</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 =
|
||||
"text-balance text-xl font-bold leading-tight text-dark dark:text-white sm:text-2xl lg:text-[1.65rem]";
|
||||
|
||||
/** Reserved width for the actions column when deciding side‑by‑side vs stacked (lg+) */
|
||||
export const TENDER_HEADER_ACTIONS_RESERVE_PX = 360;
|
||||
|
||||
export const TENDER_SECTION_IDS = {
|
||||
procurement: "tender-section-procurement",
|
||||
lots: "tender-section-lots",
|
||||
|
||||
@@ -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 { mutate: translateTender, isPending: isTranslating } =
|
||||
useTranslateTenderMutation();
|
||||
const { data, isLoading, isError, refetch } = useTenderDetailQuery(details, {
|
||||
const { data, isLoading, isError } = useTenderDetailQuery(details, {
|
||||
lang: selectedLanguage,
|
||||
});
|
||||
|
||||
@@ -65,29 +65,21 @@ const TenderDetails = ({ params }: IProps) => {
|
||||
const activeSectionId = useTenderSectionScrollSpy(sectionIds);
|
||||
const handleLanguageChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const nextParams = new URLSearchParams(searchParams.toString());
|
||||
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);
|
||||
} else {
|
||||
nextParams.delete("lang");
|
||||
}
|
||||
const queryString = nextParams.toString();
|
||||
router.replace(queryString ? `${pathname}?${queryString}` : pathname);
|
||||
},
|
||||
[pathname, router, searchParams],
|
||||
);
|
||||
|
||||
const handleTranslate = useCallback(() => {
|
||||
translateTender(
|
||||
{ id: details, language: selectedLanguage },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await refetch();
|
||||
},
|
||||
translateTender({ id: details, language: nextLanguage });
|
||||
},
|
||||
[details, pathname, router, searchParams, translateTender],
|
||||
);
|
||||
}, [details, refetch, selectedLanguage, translateTender]);
|
||||
|
||||
if (isLoading) return <Loading />;
|
||||
if (isError) {
|
||||
@@ -106,7 +98,6 @@ const TenderDetails = ({ params }: IProps) => {
|
||||
title={tender.title}
|
||||
selectedLanguage={selectedLanguage}
|
||||
onLanguageChange={handleLanguageChange}
|
||||
onTranslate={handleTranslate}
|
||||
isTranslating={isTranslating}
|
||||
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}
|
||||
multiple={multiple}
|
||||
{...(portal ? { portal: true } : {})}
|
||||
{...(portal ? { portal: true, zIndex: 99999 } : {})}
|
||||
value={pickerValue}
|
||||
onChange={(date: DateObject | DateObject[] | null) => {
|
||||
if (!date) {
|
||||
|
||||
@@ -26,6 +26,7 @@ type PropsType<T extends FieldValues> = {
|
||||
onClear?: () => void;
|
||||
value?: string;
|
||||
dataCy?: string;
|
||||
disabled?: boolean;
|
||||
} & (
|
||||
| { placeholder?: string; defaultValue?: string }
|
||||
| { placeholder: string; defaultValue?: string }
|
||||
@@ -50,6 +51,7 @@ export function Select<T extends FieldValues>({
|
||||
onClear,
|
||||
value: controlledValue,
|
||||
dataCy,
|
||||
disabled = false,
|
||||
...props
|
||||
}: PropsType<T>) {
|
||||
const id = useId();
|
||||
@@ -100,6 +102,7 @@ export function Select<T extends FieldValues>({
|
||||
id={id}
|
||||
name={name}
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
if (controlledValue === undefined) {
|
||||
@@ -113,6 +116,8 @@ export function Select<T extends FieldValues>({
|
||||
className={cn(
|
||||
"w-full appearance-none rounded-xl border border-stroke/70 bg-white/90 px-5.5 py-3 shadow-[0_1px_2px_rgba(16,24,40,0.04),0_8px_20px_rgba(16,24,40,0.04)] outline-none backdrop-blur-[1px] transition-all duration-200 focus:border-primary/70 focus:bg-white focus:shadow-[0_0_0_2px_rgba(59,130,246,0.14),0_10px_24px_rgba(59,130,246,0.08)] active:border-primary/70 dark:border-dark-3/80 dark:bg-dark-2/85 dark:focus:border-primary/80 dark:focus:shadow-[0_0_0_2px_rgba(59,130,246,0.2),0_10px_24px_rgba(15,23,42,0.45)] [&>option]:text-dark-5 dark:[&>option]:text-dark-6",
|
||||
isOptionSelected && "text-dark dark:text-white",
|
||||
disabled &&
|
||||
"cursor-not-allowed opacity-60 hover:border-stroke/70 dark:hover:border-dark-3/80",
|
||||
error &&
|
||||
"border-red/80 focus:border-red focus:shadow-[0_0_0_2px_rgba(239,68,68,0.16),0_10px_24px_rgba(239,68,68,0.1)] dark:border-red/80 dark:focus:shadow-[0_0_0_2px_rgba(239,68,68,0.24),0_10px_24px_rgba(127,29,29,0.45)]",
|
||||
prefixIcon && "pl-11.5",
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { SearchIcon } from "@/assets/icons";
|
||||
import InputGroup from "@/components/FormElements/InputGroup";
|
||||
import { Select } from "@/components/FormElements/select";
|
||||
import { Button } from "@/components/ui-elements/button";
|
||||
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
||||
import { AdminStatus } from "@/constants/enums";
|
||||
import { getEnumAsArray } from "@/utils/shared";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
FieldValues,
|
||||
UseFormHandleSubmit,
|
||||
@@ -12,103 +18,113 @@ import {
|
||||
|
||||
interface AdminListFiltersProps {
|
||||
filterRegister: UseFormRegister<FieldValues>;
|
||||
setIsFilterModalOpen: (isOpen: boolean) => void;
|
||||
isMutating: number;
|
||||
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
|
||||
search: (data: any) => void;
|
||||
watch: UseFormWatch<FieldValues>;
|
||||
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,
|
||||
setIsFilterModalOpen,
|
||||
isMutating,
|
||||
handleFilterSubmit,
|
||||
search,
|
||||
watch,
|
||||
setValue,
|
||||
}: AdminListFiltersProps) => {
|
||||
onClearAll,
|
||||
}: AdminListFiltersProps) {
|
||||
const watched = watch();
|
||||
const activeCount = useMemo(
|
||||
() => countActiveAdminFilters(watched as Record<string, unknown>),
|
||||
[watched],
|
||||
);
|
||||
|
||||
return (
|
||||
<InlineListFiltersPanel
|
||||
title="Filter admins"
|
||||
activeFilterCount={activeCount}
|
||||
expandAriaLabel="Expand admin filters"
|
||||
collapseAriaLabel="Collapse admin filters"
|
||||
panelToggleDataCy="admins-filter-panel-toggle"
|
||||
>
|
||||
<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)}
|
||||
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")}
|
||||
items={getEnumAsArray(AdminStatus)}
|
||||
label="status"
|
||||
label="Status"
|
||||
name="status"
|
||||
placeholder="Status"
|
||||
placeholder="Any status"
|
||||
clearable
|
||||
value={watch("status")}
|
||||
onClear={() => setValue("status", undefined)}
|
||||
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>
|
||||
</form>
|
||||
</InlineListFiltersPanel>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminListFilters;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import TableSkeleton from "@/components/ui/TableSkeleton";
|
||||
import { AdminStatus } from "@/constants/enums";
|
||||
import { _TooltipDefaultParams } from "@/constants/tooltip";
|
||||
import { getPaginatedRowNumber } from "@/utils/shared";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import AdminListFilters from "./AdminListFilters";
|
||||
import ChangeStatusModalContent from "./ChangeStatusModalContent";
|
||||
@@ -28,8 +27,7 @@ import { useAdminsPresenter } from "./useAdminsPresenter";
|
||||
|
||||
const AdminsTable = () => {
|
||||
const {
|
||||
allUsers,
|
||||
router,
|
||||
usersList,
|
||||
isPending,
|
||||
onConfirmDelete,
|
||||
isModalOpen,
|
||||
@@ -37,9 +35,6 @@ const AdminsTable = () => {
|
||||
isChangeStatusModalOpen,
|
||||
setIsChangeStatusModalOpen,
|
||||
columns,
|
||||
setCurrentUser,
|
||||
isFilterModalOpen,
|
||||
setIsFilterModalOpen,
|
||||
changeStatus,
|
||||
currentUser,
|
||||
setStatus,
|
||||
@@ -50,18 +45,36 @@ const AdminsTable = () => {
|
||||
filterRegister,
|
||||
isMutating,
|
||||
watch,
|
||||
metadata,
|
||||
setParams,
|
||||
shouldShowPagination,
|
||||
clearAllFilters,
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
getRowNumber,
|
||||
pagination,
|
||||
handlePaginationChange,
|
||||
navigateToEditAdmin,
|
||||
openDeleteModalForUser,
|
||||
openChangeStatusModalForUser,
|
||||
} = useAdminsPresenter();
|
||||
|
||||
return (
|
||||
<ListWrapper data-cy="admins-page">
|
||||
<ListHeader
|
||||
onFilter={() => setIsFilterModalOpen(true)}
|
||||
hasFilter={false}
|
||||
createButtonText="Create Admin"
|
||||
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">
|
||||
<TableHeader>
|
||||
<TableRow className="border-none uppercase [&>th]:text-start">
|
||||
@@ -76,26 +89,25 @@ const AdminsTable = () => {
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
{isPending && <TableSkeleton column={columns.length} />}
|
||||
<TableBody>
|
||||
|
||||
{isPending ? (
|
||||
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
|
||||
) : (
|
||||
<TableBody ref={dataTbodyRef}>
|
||||
<EmptyListWrapper
|
||||
list={allUsers}
|
||||
list={usersList}
|
||||
columns={columns}
|
||||
emptyMessage="No admins were found"
|
||||
isLoading={isPending}
|
||||
isLoading={false}
|
||||
>
|
||||
{allUsers.map((item, index) => (
|
||||
{usersList.map((item, index) => (
|
||||
<TableRow
|
||||
key={item?.id || index}
|
||||
className="odd:bg-gray-2 dark:odd:bg-gray-7"
|
||||
data-cy={`admins-table-row-${item?.id ?? index}`}
|
||||
>
|
||||
<TableCell className="text-start" colSpan={100} data-cy="admins-row-number">
|
||||
{getPaginatedRowNumber({
|
||||
index,
|
||||
page: metadata?.page,
|
||||
limit: metadata?.limit,
|
||||
})}
|
||||
{getRowNumber(index)}
|
||||
</TableCell>
|
||||
<TableCell className="text-start" colSpan={100} data-cy="admins-row-full-name">
|
||||
{item?.full_name}
|
||||
@@ -114,30 +126,26 @@ const AdminsTable = () => {
|
||||
<TableCell className="text-start xl:pr-7.5" data-cy="admins-row-actions">
|
||||
<div className="flex items-center justify-start gap-x-3.5">
|
||||
<button
|
||||
type="button"
|
||||
data-tooltip-id="delete"
|
||||
data-tooltip-content="Delete"
|
||||
data-tooltip-place="top"
|
||||
className="hover:text-red-500"
|
||||
data-cy={`admins-row-delete-${item?.id ?? index}`}
|
||||
onClick={() => {
|
||||
setCurrentUser(item);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
onClick={() => openDeleteModalForUser(item)}
|
||||
>
|
||||
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
|
||||
<span className="sr-only">Delete Admin </span>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-tooltip-id="change-status"
|
||||
data-tooltip-content="Change status"
|
||||
data-tooltip-place="top"
|
||||
className="hover:text-yellow-dark"
|
||||
data-cy={`admins-row-manage-status-${item?.id ?? index}`}
|
||||
onClick={() => {
|
||||
setCurrentUser(item);
|
||||
setIsChangeStatusModalOpen(true);
|
||||
}}
|
||||
onClick={() => openChangeStatusModalForUser(item)}
|
||||
>
|
||||
<Tooltip
|
||||
{..._TooltipDefaultParams({ id: "change-status" })}
|
||||
@@ -146,12 +154,13 @@ const AdminsTable = () => {
|
||||
<UserSettingIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-tooltip-id="edit"
|
||||
data-tooltip-content="Edit"
|
||||
data-tooltip-place="top"
|
||||
className="hover:text-primary"
|
||||
data-cy={`admins-row-edit-${item?.id ?? index}`}
|
||||
onClick={() => router.push(`/admins/edit/${item.id}`)}
|
||||
onClick={() => navigateToEditAdmin(item.id)}
|
||||
>
|
||||
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
|
||||
<span className="sr-only">Edit Admin </span>
|
||||
@@ -163,7 +172,9 @@ const AdminsTable = () => {
|
||||
))}
|
||||
</EmptyListWrapper>
|
||||
</TableBody>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
<Modal
|
||||
isOpen={isChangeStatusModalOpen}
|
||||
onClose={() => setIsChangeStatusModalOpen(false)}
|
||||
@@ -179,22 +190,6 @@ const AdminsTable = () => {
|
||||
/>
|
||||
</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 && (
|
||||
<ConfirmationModal
|
||||
isOpen={isModalOpen}
|
||||
@@ -212,15 +207,9 @@ const AdminsTable = () => {
|
||||
)}
|
||||
<IsVisible condition={shouldShowPagination}>
|
||||
<Pagination
|
||||
currentPage={metadata?.page ? metadata.page - 1 : 0}
|
||||
totalPages={metadata?.pages ?? 1}
|
||||
onPageChange={(e) => {
|
||||
setParams((currentParams) => ({
|
||||
...currentParams,
|
||||
offset: e.selected * (metadata?.limit ?? 10),
|
||||
limit: metadata?.limit ?? 10,
|
||||
}));
|
||||
}}
|
||||
currentPage={pagination.currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={handlePaginationChange}
|
||||
/>
|
||||
</IsVisible>
|
||||
</ListWrapper>
|
||||
|
||||
@@ -8,15 +8,22 @@ import {
|
||||
useGetAdminListQuery,
|
||||
} from "@/hooks/queries";
|
||||
import { IUser, TChangeAdminStatusCredentials } from "@/lib/api";
|
||||
import { deleteEmptyKeys } from "@/utils/shared";
|
||||
import { deleteEmptyKeys, getPaginatedRowNumber } from "@/utils/shared";
|
||||
import gsap from "gsap";
|
||||
import { useIsMutating } from "@tanstack/react-query";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
export const useAdminsPresenter = () => {
|
||||
const [isChangeStatusModalOpen, setIsChangeStatusModalOpen] = useState(false);
|
||||
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
|
||||
const [currentUser, setCurrentUser] = useState<IUser | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [status, setStatus] = useState<string>(
|
||||
@@ -46,8 +53,12 @@ export const useAdminsPresenter = () => {
|
||||
const [params, setParams] = useState<Record<string, any>>({});
|
||||
const pathName = usePathname();
|
||||
|
||||
const tableSectionRef = useRef<HTMLDivElement>(null);
|
||||
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const newParams: Record<string, any> = {
|
||||
const newParams: Record<string, unknown> = {
|
||||
...apiDefaultParams,
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
@@ -58,12 +69,112 @@ export const useAdminsPresenter = () => {
|
||||
setParams(newParams);
|
||||
filterFormReset(newParams);
|
||||
}, [searchParams, filterFormReset]);
|
||||
|
||||
const { data, isPending } = useGetAdminListQuery({
|
||||
...apiDefaultParams,
|
||||
...params,
|
||||
});
|
||||
|
||||
const shouldShowPagination =
|
||||
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
||||
|
||||
const usersList = data?.data?.users ?? [];
|
||||
const metadata = data?.meta;
|
||||
|
||||
const pagination = useMemo(
|
||||
() => ({
|
||||
currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
|
||||
totalPages: data?.meta?.pages ?? 1,
|
||||
}),
|
||||
[data?.meta?.page, data?.meta?.pages],
|
||||
);
|
||||
|
||||
const getRowNumber = useCallback(
|
||||
(index: number) =>
|
||||
getPaginatedRowNumber({
|
||||
index,
|
||||
page: data?.meta?.page,
|
||||
limit: data?.meta?.limit,
|
||||
}),
|
||||
[data?.meta?.limit, data?.meta?.page],
|
||||
);
|
||||
|
||||
const handlePaginationChange = useCallback(
|
||||
(e: { selected: number }) => {
|
||||
const limit = data?.meta?.limit ?? 10;
|
||||
setParams((currentParams) => ({
|
||||
...currentParams,
|
||||
offset: e.selected * limit,
|
||||
limit,
|
||||
}));
|
||||
},
|
||||
[data?.meta?.limit, setParams],
|
||||
);
|
||||
|
||||
const navigateToEditAdmin = useCallback(
|
||||
(userId: string) => {
|
||||
router.push(`/admins/edit/${userId}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const openDeleteModalForUser = useCallback((user: IUser) => {
|
||||
setCurrentUser(user);
|
||||
setIsModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const openChangeStatusModalForUser = useCallback((user: IUser) => {
|
||||
setCurrentUser(user);
|
||||
setIsChangeStatusModalOpen(true);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const reduced =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
if (reduced) return;
|
||||
|
||||
if (isPending) {
|
||||
const tbody = skeletonTbodyRef.current;
|
||||
if (!tbody) return;
|
||||
const rows = tbody.querySelectorAll("tr");
|
||||
gsap.fromTo(
|
||||
rows,
|
||||
{ opacity: 0.35, y: 14 },
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 0.42,
|
||||
stagger: 0.032,
|
||||
ease: "power2.out",
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const tbody = dataTbodyRef.current;
|
||||
if (!tbody) return;
|
||||
const rows = tbody.querySelectorAll("tr");
|
||||
if (!rows.length) return;
|
||||
gsap.fromTo(
|
||||
rows,
|
||||
{ opacity: 0, y: 18 },
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 0.46,
|
||||
stagger: 0.05,
|
||||
ease: "power3.out",
|
||||
clearProps: "opacity,transform",
|
||||
},
|
||||
);
|
||||
}, tableSectionRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, [isPending, data]);
|
||||
|
||||
const { mutate: deleteAdmin } = useDeleteAdmin(() => {
|
||||
setIsModalOpen(false);
|
||||
});
|
||||
@@ -73,6 +184,7 @@ export const useAdminsPresenter = () => {
|
||||
deleteAdmin(currentUser.id);
|
||||
}
|
||||
};
|
||||
|
||||
const { mutate: changeStatus } = useChangeAdminStatus({
|
||||
id: currentUser?.id ?? "",
|
||||
successCallback: () => {
|
||||
@@ -80,15 +192,17 @@ export const useAdminsPresenter = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const search = (data: any) => {
|
||||
const newParams = { ...params, ...data, offset: 0 };
|
||||
const search = (formData: any) => {
|
||||
const newParams = { ...params, ...formData, offset: 0 };
|
||||
const cleanedParams = deleteEmptyKeys(newParams);
|
||||
const queryString = new URLSearchParams(cleanedParams).toString();
|
||||
router.push(`${pathName}?${queryString}`);
|
||||
setIsFilterModalOpen(false);
|
||||
};
|
||||
const allUsers = data?.data?.users ?? [];
|
||||
const metadata = data?.meta;
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
router.push(pathName);
|
||||
}, [pathName, router]);
|
||||
|
||||
const columns = [
|
||||
"row",
|
||||
"full name",
|
||||
@@ -97,8 +211,10 @@ export const useAdminsPresenter = () => {
|
||||
"status",
|
||||
"actions",
|
||||
];
|
||||
|
||||
return {
|
||||
allUsers,
|
||||
allUsers: usersList,
|
||||
usersList,
|
||||
setFilterValue,
|
||||
metadata,
|
||||
columns,
|
||||
@@ -107,7 +223,6 @@ export const useAdminsPresenter = () => {
|
||||
isModalOpen,
|
||||
setIsModalOpen,
|
||||
isMutating,
|
||||
router,
|
||||
setCurrentUser,
|
||||
pathName,
|
||||
status,
|
||||
@@ -119,13 +234,21 @@ export const useAdminsPresenter = () => {
|
||||
register,
|
||||
errors,
|
||||
handleSubmit,
|
||||
isFilterModalOpen,
|
||||
setIsFilterModalOpen,
|
||||
setParams,
|
||||
filterRegister,
|
||||
handleFilterSubmit,
|
||||
search,
|
||||
watch,
|
||||
shouldShowPagination,
|
||||
clearAllFilters,
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
getRowNumber,
|
||||
pagination,
|
||||
handlePaginationChange,
|
||||
navigateToEditAdmin,
|
||||
openDeleteModalForUser,
|
||||
openChangeStatusModalForUser,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import InputGroup from "@/components/FormElements/InputGroup";
|
||||
import { Button } from "@/components/ui-elements/button";
|
||||
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
||||
import {
|
||||
FieldValues,
|
||||
UseFormHandleSubmit,
|
||||
@@ -6,64 +10,98 @@ import {
|
||||
UseFormSetValue,
|
||||
UseFormWatch,
|
||||
} from "react-hook-form";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface CmsListFiltersProps {
|
||||
filterRegister: UseFormRegister<FieldValues>;
|
||||
setIsFilterModalOpen: (isOpen: boolean) => void;
|
||||
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 key = values.key;
|
||||
if (typeof key === "string" && key.trim()) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
const CmsListFilters = ({
|
||||
filterRegister,
|
||||
setIsFilterModalOpen,
|
||||
isMutating,
|
||||
handleFilterSubmit,
|
||||
search,
|
||||
watch,
|
||||
onClearAll,
|
||||
}: CmsListFiltersProps) => {
|
||||
const watched = watch();
|
||||
const activeCount = useMemo(
|
||||
() => countActive(watched as Record<string, unknown>),
|
||||
[watched],
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
|
||||
onSubmit={handleFilterSubmit(search)}
|
||||
<InlineListFiltersPanel
|
||||
title="Filter CMS entries"
|
||||
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
|
||||
{...filterRegister("q")}
|
||||
name="q"
|
||||
label="search"
|
||||
label="Keyword search"
|
||||
placeholder="Search"
|
||||
type="search"
|
||||
dataCy="cms-filter-q-input"
|
||||
/>
|
||||
<InputGroup
|
||||
{...filterRegister("key")}
|
||||
name="key"
|
||||
label="key"
|
||||
label="Key"
|
||||
placeholder="Key"
|
||||
type="text"
|
||||
dataCy="cms-filter-key-input"
|
||||
/>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Button
|
||||
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"
|
||||
>
|
||||
{isMutating ? (
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
|
||||
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="cms-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>Search</span>
|
||||
)}
|
||||
</button>
|
||||
<span>Apply filters</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<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"
|
||||
onClick={() => setIsFilterModalOpen(false)}
|
||||
data-cy="cms-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]"
|
||||
>
|
||||
Close
|
||||
Reset all
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</InlineListFiltersPanel>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PencilSquareIcon, TrashIcon } from "@/assets/icons";
|
||||
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
||||
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
||||
import ListHeader from "@/components/ui/ListHeader";
|
||||
import Modal from "@/components/ui/modal";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -18,7 +18,6 @@ import { getPaginatedRowNumber, unixToDate } from "@/utils/shared";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import CmsListFilters from "./CmsListFilters";
|
||||
import useCmsTablePresenter from "./useCmsTablePresenter";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
|
||||
const CmsTable = () => {
|
||||
const {
|
||||
@@ -32,18 +31,30 @@ const CmsTable = () => {
|
||||
onConfirmDelete,
|
||||
router,
|
||||
pathname,
|
||||
isFilterModalOpen,
|
||||
setIsFilterModalOpen,
|
||||
filterRegister,
|
||||
handleFilterSubmit,
|
||||
search,
|
||||
watch,
|
||||
setFilterValue,
|
||||
isMutating,
|
||||
clearAllFilters,
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
} = useCmsTablePresenter();
|
||||
return (
|
||||
<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>
|
||||
<TableHeader>
|
||||
<TableRow className="border-none uppercase [&>th]:text-start">
|
||||
@@ -54,12 +65,15 @@ const CmsTable = () => {
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
{isPending && <TableSkeleton column={columns.length} />}
|
||||
<TableBody>
|
||||
{isPending ? (
|
||||
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
|
||||
) : (
|
||||
<TableBody ref={dataTbodyRef}>
|
||||
<EmptyListWrapper
|
||||
list={data?.data.cms ?? []}
|
||||
columns={columns}
|
||||
emptyMessage="No Cms were found"
|
||||
isLoading={false}
|
||||
>
|
||||
{data?.data.cms.map((item, index) => (
|
||||
<TableRow
|
||||
@@ -100,7 +114,9 @@ const CmsTable = () => {
|
||||
data-tooltip-content="Edit"
|
||||
data-tooltip-place="top"
|
||||
className="hover:text-primary"
|
||||
onClick={() => router.push(`${pathname}/edit/${item.id}`)}
|
||||
onClick={() =>
|
||||
router.push(`${pathname}/edit/${item.id}`)
|
||||
}
|
||||
>
|
||||
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
|
||||
<span className="sr-only">Edit Marketing </span>
|
||||
@@ -112,23 +128,9 @@ const CmsTable = () => {
|
||||
))}
|
||||
</EmptyListWrapper>
|
||||
</TableBody>
|
||||
)}
|
||||
</Table>
|
||||
<Modal
|
||||
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>
|
||||
</div>
|
||||
{isModalOpen && (
|
||||
<ConfirmationModal
|
||||
isOpen={isModalOpen}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
||||
import { useDeleteCms, useGetCmsList } from "@/hooks/queries/useCmsQueries";
|
||||
import { TCms } from "@/lib/api/types/TCms";
|
||||
import { deleteEmptyKeys } from "@/utils/shared";
|
||||
import { useIsMutating } from "@tanstack/react-query";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
const useCmsTablePresenter = () => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
|
||||
const [currentCms, setCurrentCms] = useState<TCms | null>(null);
|
||||
const [params, setParams] = useState<Record<string, any>>({});
|
||||
const columns = ["row", "key", "created at", "actions"];
|
||||
@@ -26,6 +26,10 @@ const useCmsTablePresenter = () => {
|
||||
setValue: setFilterValue,
|
||||
} = useForm();
|
||||
|
||||
const tableSectionRef = useRef<HTMLDivElement>(null);
|
||||
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const newParams: Record<string, any> = {};
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
@@ -37,19 +41,28 @@ const useCmsTablePresenter = () => {
|
||||
|
||||
const { isPending, data } = useGetCmsList(params);
|
||||
|
||||
useTableSectionRowAnimations(isPending, data, {
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
});
|
||||
|
||||
const { mutate: deleteCms } = useDeleteCms(() => {
|
||||
setIsModalOpen(false);
|
||||
setCurrentCms(null);
|
||||
});
|
||||
|
||||
const search = (data: any) => {
|
||||
const newParams = { ...params, ...data };
|
||||
const search = (formData: any) => {
|
||||
const newParams = { ...params, ...formData };
|
||||
const cleanedParams = deleteEmptyKeys(newParams);
|
||||
const queryString = new URLSearchParams(cleanedParams).toString();
|
||||
router.push(`${pathname}?${queryString}`);
|
||||
setIsFilterModalOpen(false);
|
||||
};
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
router.push(pathname);
|
||||
}, [pathname, router]);
|
||||
|
||||
const onConfirmDelete = () => {
|
||||
if (!currentCms?.id) return;
|
||||
deleteCms(currentCms.id);
|
||||
@@ -65,14 +78,16 @@ const useCmsTablePresenter = () => {
|
||||
onConfirmDelete,
|
||||
router,
|
||||
pathname,
|
||||
isFilterModalOpen,
|
||||
setIsFilterModalOpen,
|
||||
filterRegister,
|
||||
handleFilterSubmit,
|
||||
search,
|
||||
watch,
|
||||
setFilterValue,
|
||||
isMutating,
|
||||
clearAllFilters,
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import InputGroup from "@/components/FormElements/InputGroup";
|
||||
import { Select } from "@/components/FormElements/select";
|
||||
import { Button } from "@/components/ui-elements/button";
|
||||
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
||||
import { Currencies } from "@/constants/enums";
|
||||
import {
|
||||
FieldValues,
|
||||
@@ -8,56 +12,91 @@ import {
|
||||
UseFormSetValue,
|
||||
UseFormWatch,
|
||||
} from "react-hook-form";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface CompanyListFiltersProps {
|
||||
filterRegister: UseFormRegister<FieldValues>;
|
||||
setIsFilterModalOpen: (isOpen: boolean) => void;
|
||||
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 keys = ["name", "email", "phone"] as const;
|
||||
for (const k of keys) {
|
||||
const v = values[k];
|
||||
if (typeof v === "string" && v.trim()) n++;
|
||||
}
|
||||
const cur = values.currency;
|
||||
if (cur !== undefined && cur !== null && cur !== "" && String(cur).trim()) {
|
||||
n++;
|
||||
}
|
||||
const ec = values.employee_count;
|
||||
if (ec !== undefined && ec !== null && ec !== "" && String(ec).trim()) {
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
const CompanyListFilters = ({
|
||||
filterRegister,
|
||||
setIsFilterModalOpen,
|
||||
isMutating,
|
||||
handleFilterSubmit,
|
||||
search,
|
||||
watch,
|
||||
setValue,
|
||||
onClearAll,
|
||||
}: CompanyListFiltersProps) => {
|
||||
const watched = watch();
|
||||
const activeCount = useMemo(
|
||||
() => countActive(watched as Record<string, unknown>),
|
||||
[watched],
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
|
||||
onSubmit={handleFilterSubmit(search)}
|
||||
<InlineListFiltersPanel
|
||||
title="Filter companies"
|
||||
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
|
||||
{...filterRegister("name")}
|
||||
name="name"
|
||||
label="name"
|
||||
label="Name"
|
||||
placeholder="Name"
|
||||
type="text"
|
||||
/>
|
||||
<InputGroup
|
||||
{...filterRegister("email")}
|
||||
name="email"
|
||||
label="email"
|
||||
label="Email"
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
/>
|
||||
<InputGroup
|
||||
{...filterRegister("phone")}
|
||||
name="phone"
|
||||
label="phone"
|
||||
label="Phone"
|
||||
placeholder="Phone"
|
||||
type="tel"
|
||||
/>
|
||||
<Select
|
||||
{...filterRegister("currency")}
|
||||
items={Currencies}
|
||||
label="currency"
|
||||
label="Currency"
|
||||
name="currency"
|
||||
placeholder="Currency"
|
||||
clearable
|
||||
@@ -67,31 +106,36 @@ const CompanyListFilters = ({
|
||||
<InputGroup
|
||||
{...filterRegister("employee_count")}
|
||||
name="employee_count"
|
||||
label="employee count"
|
||||
placeholder="Employee Count"
|
||||
label="Employee count"
|
||||
placeholder="Employee count"
|
||||
type="number"
|
||||
/>
|
||||
|
||||
<div className="flex gap-4 col-span-2">
|
||||
<button
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Button
|
||||
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"
|
||||
>
|
||||
{isMutating ? (
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
|
||||
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="companies-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>Search</span>
|
||||
)}
|
||||
</button>
|
||||
<span>Apply filters</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<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"
|
||||
onClick={() => setIsFilterModalOpen(false)}
|
||||
data-cy="companies-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]"
|
||||
>
|
||||
Close
|
||||
Reset all
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</InlineListFiltersPanel>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import InputGroup from "@/components/FormElements/InputGroup";
|
||||
import { Select } from "@/components/FormElements/select";
|
||||
import { Button } from "@/components/ui-elements/button";
|
||||
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
||||
import {
|
||||
FieldValues,
|
||||
UseFormHandleSubmit,
|
||||
@@ -7,36 +11,63 @@ import {
|
||||
UseFormSetValue,
|
||||
UseFormWatch,
|
||||
} from "react-hook-form";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface CompanyCategoryListFiltersProps {
|
||||
filterRegister: UseFormRegister<FieldValues>;
|
||||
setIsFilterModalOpen: (isOpen: boolean) => void;
|
||||
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 pub = values.published;
|
||||
if (pub !== undefined && pub !== null && pub !== "" && String(pub).trim()) {
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
const CompanyCategoryListFilters = ({
|
||||
filterRegister,
|
||||
setIsFilterModalOpen,
|
||||
isMutating,
|
||||
handleFilterSubmit,
|
||||
search,
|
||||
watch,
|
||||
setValue,
|
||||
onClearAll,
|
||||
}: CompanyCategoryListFiltersProps) => {
|
||||
const watched = watch();
|
||||
const activeCount = useMemo(
|
||||
() => countActive(watched as Record<string, unknown>),
|
||||
[watched],
|
||||
);
|
||||
|
||||
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
|
||||
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"
|
||||
onSubmit={handleFilterSubmit(search)}
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<InputGroup
|
||||
{...filterRegister("q")}
|
||||
name="q"
|
||||
label="search"
|
||||
label="Keyword search"
|
||||
placeholder="Search"
|
||||
type="search"
|
||||
dataCy="company-categories-filter-search-input"
|
||||
@@ -47,37 +78,40 @@ const CompanyCategoryListFilters = ({
|
||||
{ label: "Published", value: "true" },
|
||||
{ label: "Not Published", value: "false" },
|
||||
]}
|
||||
label="published"
|
||||
label="Published"
|
||||
name="published"
|
||||
placeholder="Published Status"
|
||||
placeholder="Published status"
|
||||
clearable
|
||||
value={watch("published")}
|
||||
onClear={() => setValue("published", undefined)}
|
||||
dataCy="company-categories-filter-published-select"
|
||||
/>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
</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="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"
|
||||
>
|
||||
{isMutating ? (
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
<span>Apply filters</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-cy="company-categories-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)}
|
||||
data-cy="company-categories-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]"
|
||||
>
|
||||
Close
|
||||
Reset all
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</InlineListFiltersPanel>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
||||
import IsVisible from "@/components/ui/IsVisible";
|
||||
import ListHeader from "@/components/ui/ListHeader";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
import Modal from "@/components/ui/modal";
|
||||
import Pagination from "@/components/ui/pagination";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import TableSkeleton from "@/components/ui/TableSkeleton";
|
||||
import Pagination from "@/components/ui/pagination";
|
||||
import { _TooltipDefaultParams } from "@/constants/tooltip";
|
||||
import { getPaginatedRowNumber, unixToDate } from "@/utils/shared";
|
||||
import { startTransition } from "react";
|
||||
@@ -37,8 +36,6 @@ const CompanyCategoriesTable = () => {
|
||||
setCurrentCategory,
|
||||
isModalOpen,
|
||||
setIsModalOpen,
|
||||
isFilterModalOpen,
|
||||
setIsFilterModalOpen,
|
||||
filterRegister,
|
||||
handleFilterSubmit,
|
||||
search,
|
||||
@@ -47,17 +44,32 @@ const CompanyCategoriesTable = () => {
|
||||
columns,
|
||||
isMutating,
|
||||
metadata,
|
||||
setParams,
|
||||
shouldShowPagination,
|
||||
clearAllFilters,
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
pagination,
|
||||
handlePaginationChange,
|
||||
} = useCompanyCategoriesPresenter();
|
||||
|
||||
return (
|
||||
<ListWrapper data-cy="company-categories-page">
|
||||
<ListHeader
|
||||
onFilter={() => setIsFilterModalOpen(true)}
|
||||
hasFilter={false}
|
||||
createButtonText="Create Category"
|
||||
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">
|
||||
<TableHeader>
|
||||
<TableRow className="border-none uppercase [&>th]:text-start">
|
||||
@@ -69,13 +81,15 @@ const CompanyCategoriesTable = () => {
|
||||
<TableHead colSpan={100}>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
{isLoading && <TableSkeleton column={columns.length} />}
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
|
||||
) : (
|
||||
<TableBody ref={dataTbodyRef}>
|
||||
<EmptyListWrapper
|
||||
list={optimisticCategories}
|
||||
columns={columns}
|
||||
emptyMessage="No categories were found"
|
||||
isLoading={isLoading}
|
||||
isLoading={false}
|
||||
>
|
||||
{optimisticCategories.map((category, index) => (
|
||||
<TableRow
|
||||
@@ -96,7 +110,6 @@ const CompanyCategoriesTable = () => {
|
||||
{unixToDate({ unix: category.created_at, hasTime: true })}
|
||||
</TableCell>
|
||||
<TableCell colSpan={100}>
|
||||
{/* Published: flip UI immediately via useOptimistic; if togglePublished rejects, React rolls the optimistic row back. */}
|
||||
<Switch
|
||||
withIcon
|
||||
dataCy={`company-categories-published-toggle-${category.id}`}
|
||||
@@ -157,23 +170,9 @@ const CompanyCategoriesTable = () => {
|
||||
))}
|
||||
</EmptyListWrapper>
|
||||
</TableBody>
|
||||
)}
|
||||
</Table>
|
||||
<Modal
|
||||
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>
|
||||
</div>
|
||||
{isModalOpen && (
|
||||
<ConfirmationModal
|
||||
isOpen={isModalOpen}
|
||||
@@ -191,15 +190,9 @@ const CompanyCategoriesTable = () => {
|
||||
)}
|
||||
<IsVisible condition={shouldShowPagination}>
|
||||
<Pagination
|
||||
currentPage={metadata?.page ? metadata.page - 1 : 0}
|
||||
totalPages={metadata?.pages ?? 1}
|
||||
onPageChange={(e) => {
|
||||
setParams((currentParams) => ({
|
||||
...currentParams,
|
||||
offset: e.selected * (metadata?.limit ?? 10),
|
||||
limit: metadata?.limit ?? 10,
|
||||
}));
|
||||
}}
|
||||
currentPage={pagination.currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={handlePaginationChange}
|
||||
/>
|
||||
</IsVisible>
|
||||
</ListWrapper>
|
||||
|
||||
+46
-7
@@ -5,18 +5,18 @@ import {
|
||||
useDeleteCompanyCategoryQuery,
|
||||
useToggleCompanyCategoryPublished,
|
||||
} from "@/hooks/queries";
|
||||
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
||||
import { TCompanyCategory } from "@/lib/api";
|
||||
import { deleteEmptyKeys } from "@/utils/shared";
|
||||
import { useIsMutating } from "@tanstack/react-query";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useOptimistic, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
type TogglePublishedOptimistic = Pick<TCompanyCategory, "id" | "published">;
|
||||
|
||||
const useCompanyCategoriesPresenter = () => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
|
||||
const [currentCategory, setCurrentCategory] = useState<TCompanyCategory>();
|
||||
const [params, setParams] = useState<Record<string, any>>({});
|
||||
const router = useRouter();
|
||||
@@ -32,6 +32,10 @@ const useCompanyCategoriesPresenter = () => {
|
||||
setValue: setFilterValue,
|
||||
} = useForm();
|
||||
|
||||
const tableSectionRef = useRef<HTMLDivElement>(null);
|
||||
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const newParams: Record<string, any> = {
|
||||
...apiDefaultParams,
|
||||
@@ -46,11 +50,39 @@ const useCompanyCategoriesPresenter = () => {
|
||||
}, [searchParams, filterFormReset]);
|
||||
|
||||
const { data, isLoading, isError } = useCompanyCategoriesQuery(params);
|
||||
|
||||
useTableSectionRowAnimations(isLoading, data, {
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
});
|
||||
|
||||
const categories = data?.data.categories;
|
||||
const metadata = data?.meta;
|
||||
const shouldShowPagination =
|
||||
!isLoading &&
|
||||
(data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
||||
|
||||
const pagination = useMemo(
|
||||
() => ({
|
||||
currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
|
||||
totalPages: data?.meta?.pages ?? 1,
|
||||
}),
|
||||
[data?.meta?.page, data?.meta?.pages],
|
||||
);
|
||||
|
||||
const handlePaginationChange = useCallback(
|
||||
(e: { selected: number }) => {
|
||||
const limit = data?.meta?.limit ?? 10;
|
||||
setParams((currentParams) => ({
|
||||
...currentParams,
|
||||
offset: e.selected * limit,
|
||||
limit,
|
||||
}));
|
||||
},
|
||||
[data?.meta?.limit],
|
||||
);
|
||||
|
||||
const [optimisticCategories, addOptimisticPublished] = useOptimistic<
|
||||
TCompanyCategory[],
|
||||
TogglePublishedOptimistic
|
||||
@@ -63,14 +95,17 @@ const useCompanyCategoriesPresenter = () => {
|
||||
const { mutateAsync: togglePublished } =
|
||||
useToggleCompanyCategoryPublished();
|
||||
|
||||
const search = (data: any) => {
|
||||
const newParams = { ...params, ...data, offset: 0 };
|
||||
const search = (formData: any) => {
|
||||
const newParams = { ...params, ...formData, offset: 0 };
|
||||
const cleanedParams = deleteEmptyKeys(newParams);
|
||||
const queryString = new URLSearchParams(cleanedParams).toString();
|
||||
router.push(`${pathName}?${queryString}`);
|
||||
setIsFilterModalOpen(false);
|
||||
};
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
router.push(pathName);
|
||||
}, [pathName, router]);
|
||||
|
||||
const onConfirmDelete = () => {
|
||||
if (!currentCategory) return;
|
||||
deleteCategory(currentCategory?.id);
|
||||
@@ -100,8 +135,6 @@ const useCompanyCategoriesPresenter = () => {
|
||||
togglePublished,
|
||||
setCurrentCategory,
|
||||
onConfirmDelete,
|
||||
isFilterModalOpen,
|
||||
setIsFilterModalOpen,
|
||||
filterRegister,
|
||||
handleFilterSubmit,
|
||||
search,
|
||||
@@ -110,6 +143,12 @@ const useCompanyCategoriesPresenter = () => {
|
||||
isMutating,
|
||||
metadata,
|
||||
shouldShowPagination,
|
||||
clearAllFilters,
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
pagination,
|
||||
handlePaginationChange,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
||||
import IsVisible from "@/components/ui/IsVisible";
|
||||
import ListHeader from "@/components/ui/ListHeader";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
import Modal from "@/components/ui/modal";
|
||||
import Pagination from "@/components/ui/pagination";
|
||||
import {
|
||||
Table,
|
||||
@@ -39,8 +38,6 @@ const CompaniesTable = () => {
|
||||
setCurrentCompany,
|
||||
onConfirmDelete,
|
||||
modalConfig,
|
||||
isFilterModalOpen,
|
||||
setIsFilterModalOpen,
|
||||
filterRegister,
|
||||
handleFilterSubmit,
|
||||
search,
|
||||
@@ -48,39 +45,53 @@ const CompaniesTable = () => {
|
||||
setFilterValue,
|
||||
isMutating,
|
||||
metadata,
|
||||
setParams,
|
||||
shouldShowPagination,
|
||||
clearAllFilters,
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
pagination,
|
||||
handlePaginationChange,
|
||||
} = useCompanyListPresenter();
|
||||
const companies = allCompanies.filter(
|
||||
(c): c is NonNullable<typeof c> => c != null,
|
||||
);
|
||||
return (
|
||||
<ListWrapper>
|
||||
<ListHeader
|
||||
onFilter={() => setIsFilterModalOpen(true)}
|
||||
createButtonText="Create Company"
|
||||
<ListHeader hasFilter={false} createButtonText="Create Company" />
|
||||
<CompanyListFilters
|
||||
setValue={setFilterValue}
|
||||
filterRegister={filterRegister}
|
||||
handleFilterSubmit={handleFilterSubmit}
|
||||
isMutating={isMutating as number}
|
||||
search={search}
|
||||
watch={watch}
|
||||
onClearAll={clearAllFilters}
|
||||
/>
|
||||
<div ref={tableSectionRef}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((columns) => (
|
||||
{columns.map((col) => (
|
||||
<TableHead
|
||||
colSpan={100}
|
||||
className="text-start uppercase"
|
||||
key={columns}
|
||||
key={col}
|
||||
>
|
||||
{columns}
|
||||
{col}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
{isPending && <TableSkeleton column={columns.length} />}
|
||||
<TableBody>
|
||||
{isPending ? (
|
||||
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
|
||||
) : (
|
||||
<TableBody ref={dataTbodyRef}>
|
||||
<EmptyListWrapper
|
||||
list={companies}
|
||||
columns={columns}
|
||||
emptyMessage="No companies were found"
|
||||
isLoading={isPending}
|
||||
isLoading={false}
|
||||
>
|
||||
{companies.map((company, index) => (
|
||||
<TableRow
|
||||
@@ -176,23 +187,9 @@ const CompaniesTable = () => {
|
||||
))}
|
||||
</EmptyListWrapper>
|
||||
</TableBody>
|
||||
)}
|
||||
</Table>
|
||||
<Modal
|
||||
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>
|
||||
</div>
|
||||
{isModalOpen && (
|
||||
<ConfirmationModal
|
||||
isOpen={isModalOpen}
|
||||
@@ -210,15 +207,9 @@ const CompaniesTable = () => {
|
||||
)}
|
||||
<IsVisible condition={shouldShowPagination}>
|
||||
<Pagination
|
||||
currentPage={metadata?.page ? metadata.page - 1 : 0}
|
||||
totalPages={metadata?.pages ?? 1}
|
||||
onPageChange={(e) => {
|
||||
setParams((currentParams) => ({
|
||||
...currentParams,
|
||||
offset: e.selected * (metadata?.limit ?? 10),
|
||||
limit: metadata?.limit ?? 10,
|
||||
}));
|
||||
}}
|
||||
currentPage={pagination.currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={handlePaginationChange}
|
||||
/>
|
||||
</IsVisible>
|
||||
</ListWrapper>
|
||||
|
||||
@@ -5,16 +5,16 @@ import {
|
||||
useCompaniesQuery,
|
||||
useDeleteCompanyQuery,
|
||||
} from "@/hooks/queries";
|
||||
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
||||
import { TCompany } from "@/lib/api";
|
||||
import { deleteEmptyKeys } from "@/utils/shared";
|
||||
import { useIsMutating } from "@tanstack/react-query";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
export const useCompanyListPresenter = () => {
|
||||
const [currentCompany, setCurrentCompany] = useState<TCompany | null>(null);
|
||||
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
|
||||
const [modalConfig, setModalConfig] = useState<
|
||||
Partial<ConfirmationModalProps>
|
||||
>({});
|
||||
@@ -33,6 +33,10 @@ export const useCompanyListPresenter = () => {
|
||||
setValue: setFilterValue,
|
||||
} = useForm();
|
||||
|
||||
const tableSectionRef = useRef<HTMLDivElement>(null);
|
||||
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||
|
||||
const columns = [
|
||||
"row",
|
||||
"name",
|
||||
@@ -61,6 +65,12 @@ export const useCompanyListPresenter = () => {
|
||||
|
||||
const { data, error, isPending, isError } = useCompaniesQuery(params);
|
||||
|
||||
useTableSectionRowAnimations(isPending, data, {
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
});
|
||||
|
||||
const shouldShowPagination =
|
||||
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
||||
|
||||
@@ -68,13 +78,37 @@ export const useCompanyListPresenter = () => {
|
||||
setIsModalOpen(false),
|
||||
);
|
||||
|
||||
const search = (data: any) => {
|
||||
const newParams = { ...params, ...data, offset: 0 };
|
||||
const search = (formData: any) => {
|
||||
const newParams = { ...params, ...formData, offset: 0 };
|
||||
const cleanedParams = deleteEmptyKeys(newParams);
|
||||
const queryString = new URLSearchParams(cleanedParams).toString();
|
||||
router.push(`${pathName}?${queryString}`);
|
||||
setIsFilterModalOpen(false);
|
||||
};
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
router.push(pathName);
|
||||
}, [pathName, router]);
|
||||
|
||||
const pagination = useMemo(
|
||||
() => ({
|
||||
currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
|
||||
totalPages: data?.meta?.pages ?? 1,
|
||||
}),
|
||||
[data?.meta?.page, data?.meta?.pages],
|
||||
);
|
||||
|
||||
const handlePaginationChange = useCallback(
|
||||
(e: { selected: number }) => {
|
||||
const limit = data?.meta?.limit ?? 10;
|
||||
setParams((currentParams) => ({
|
||||
...currentParams,
|
||||
offset: e.selected * limit,
|
||||
limit,
|
||||
}));
|
||||
},
|
||||
[data?.meta?.limit],
|
||||
);
|
||||
|
||||
const onConfirmDelete = () => {
|
||||
if (currentCompany) {
|
||||
deleteCompany(currentCompany.id);
|
||||
@@ -98,8 +132,6 @@ export const useCompanyListPresenter = () => {
|
||||
router,
|
||||
isPending,
|
||||
isError,
|
||||
isFilterModalOpen,
|
||||
setIsFilterModalOpen,
|
||||
filterRegister,
|
||||
handleFilterSubmit,
|
||||
search,
|
||||
@@ -108,5 +140,11 @@ export const useCompanyListPresenter = () => {
|
||||
isMutating,
|
||||
setParams,
|
||||
shouldShowPagination,
|
||||
clearAllFilters,
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
pagination,
|
||||
handlePaginationChange,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 ConfirmationModal from "@/components/ui/ConfirmationModal";
|
||||
import IsVisible from "@/components/ui/IsVisible";
|
||||
import ListHeader from "@/components/ui/ListHeader";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
import Pagination from "@/components/ui/pagination";
|
||||
import {
|
||||
@@ -17,6 +18,7 @@ import TableSkeleton from "@/components/ui/TableSkeleton";
|
||||
import { _TooltipDefaultParams } from "@/constants/tooltip";
|
||||
import { getPaginatedRowNumber, unixToDate } from "@/utils/shared";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import ContactUsListFilters from "./ContactUsListFilters";
|
||||
import { useContactUsListPresenter } from "./useContactUsListPresenter";
|
||||
|
||||
const ContactUsTable = () => {
|
||||
@@ -30,32 +32,56 @@ const ContactUsTable = () => {
|
||||
isModalOpen,
|
||||
currentContact,
|
||||
metadata,
|
||||
setParams,
|
||||
shouldShowPagination,
|
||||
filterRegister,
|
||||
handleFilterSubmit,
|
||||
search,
|
||||
watch,
|
||||
setFilterValue,
|
||||
isMutating,
|
||||
clearAllFilters,
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
pagination,
|
||||
handlePaginationChange,
|
||||
} = useContactUsListPresenter();
|
||||
return (
|
||||
<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>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((columns) => (
|
||||
{columns.map((col) => (
|
||||
<TableHead
|
||||
colSpan={100}
|
||||
className="text-start uppercase"
|
||||
key={columns}
|
||||
key={col}
|
||||
>
|
||||
{columns}
|
||||
{col}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
{isPending && <TableSkeleton column={columns.length} />}
|
||||
<TableBody>
|
||||
{isPending ? (
|
||||
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
|
||||
) : (
|
||||
<TableBody ref={dataTbodyRef}>
|
||||
<EmptyListWrapper
|
||||
list={contacts ?? []}
|
||||
columns={columns}
|
||||
emptyMessage="No contact us is submitted"
|
||||
isLoading={isPending}
|
||||
isLoading={false}
|
||||
>
|
||||
{contacts?.map((item, index) => (
|
||||
<TableRow
|
||||
@@ -106,7 +132,9 @@ const ContactUsTable = () => {
|
||||
))}
|
||||
</EmptyListWrapper>
|
||||
</TableBody>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
{isModalOpen && (
|
||||
<ConfirmationModal
|
||||
isOpen={isModalOpen}
|
||||
@@ -124,15 +152,9 @@ const ContactUsTable = () => {
|
||||
)}
|
||||
<IsVisible condition={shouldShowPagination}>
|
||||
<Pagination
|
||||
currentPage={metadata?.page ? metadata.page - 1 : 0}
|
||||
totalPages={metadata?.pages ?? 1}
|
||||
onPageChange={(e) => {
|
||||
setParams((currentParams) => ({
|
||||
...currentParams,
|
||||
offset: e.selected * (metadata?.limit ?? 10),
|
||||
limit: metadata?.limit ?? 10,
|
||||
}));
|
||||
}}
|
||||
currentPage={pagination.currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={handlePaginationChange}
|
||||
/>
|
||||
</IsVisible>
|
||||
</ListWrapper>
|
||||
|
||||
@@ -4,9 +4,13 @@ import {
|
||||
useDeleteContactUs,
|
||||
useReadAllContactUs,
|
||||
} from "@/hooks/queries/useContactUsQuery";
|
||||
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
||||
import { TContact } from "@/lib/api/types/TContacts";
|
||||
import { deleteEmptyKeys } from "@/utils/shared";
|
||||
import { useIsMutating } from "@tanstack/react-query";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
export const useContactUsListPresenter = () => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -24,6 +28,19 @@ export const useContactUsListPresenter = () => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const isMutating = useIsMutating();
|
||||
|
||||
const {
|
||||
register: filterRegister,
|
||||
handleSubmit: handleFilterSubmit,
|
||||
reset: filterFormReset,
|
||||
watch,
|
||||
setValue: setFilterValue,
|
||||
} = useForm();
|
||||
|
||||
const tableSectionRef = useRef<HTMLDivElement>(null);
|
||||
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const newParams: Record<string, unknown> = {
|
||||
@@ -35,9 +52,17 @@ export const useContactUsListPresenter = () => {
|
||||
newParams[key] = value;
|
||||
}
|
||||
setParams(newParams);
|
||||
}, [searchParams]);
|
||||
filterFormReset(newParams as any);
|
||||
}, [searchParams, filterFormReset]);
|
||||
|
||||
const { data, isPending } = useReadAllContactUs(params);
|
||||
|
||||
useTableSectionRowAnimations(isPending, data, {
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
});
|
||||
|
||||
const { mutate: deleteContactUS } = useDeleteContactUs(() =>
|
||||
setIsModalOpen(false),
|
||||
);
|
||||
@@ -45,6 +70,39 @@ export const useContactUsListPresenter = () => {
|
||||
const shouldShowPagination =
|
||||
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
||||
|
||||
const pagination = useMemo(
|
||||
() => ({
|
||||
currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
|
||||
totalPages: data?.meta?.pages ?? 1,
|
||||
}),
|
||||
[data?.meta?.page, data?.meta?.pages],
|
||||
);
|
||||
|
||||
const handlePaginationChange = useCallback(
|
||||
(e: { selected: number }) => {
|
||||
const limit = data?.meta?.limit ?? 10;
|
||||
setParams((currentParams) => ({
|
||||
...currentParams,
|
||||
offset: e.selected * limit,
|
||||
limit,
|
||||
}));
|
||||
},
|
||||
[data?.meta?.limit],
|
||||
);
|
||||
|
||||
const search = (formData: any) => {
|
||||
const newParams = { ...params, ...formData, offset: 0 };
|
||||
const cleanedParams = deleteEmptyKeys(newParams);
|
||||
const queryString = new URLSearchParams(
|
||||
cleanedParams as Record<string, string>,
|
||||
).toString();
|
||||
router.push(`${pathname}?${queryString}`);
|
||||
};
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
router.push(pathname);
|
||||
}, [pathname, router]);
|
||||
|
||||
const onConfirmDelete = () => {
|
||||
if (!currentContact) return;
|
||||
deleteContactUS(currentContact?.id);
|
||||
@@ -63,5 +121,17 @@ export const useContactUsListPresenter = () => {
|
||||
setCurrentContact,
|
||||
isModalOpen,
|
||||
currentContact,
|
||||
filterRegister,
|
||||
handleFilterSubmit,
|
||||
search,
|
||||
watch,
|
||||
setFilterValue,
|
||||
isMutating,
|
||||
clearAllFilters,
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
pagination,
|
||||
handlePaginationChange,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Select } from "@/components/FormElements/select";
|
||||
import { Button } from "@/components/ui-elements/button";
|
||||
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
||||
import { AdminRoles, CustomerStatus, CustomerType } from "@/constants/enums";
|
||||
import { getEnumAsArray } from "@/utils/shared";
|
||||
import {
|
||||
@@ -8,35 +12,61 @@ import {
|
||||
UseFormSetValue,
|
||||
UseFormWatch,
|
||||
} from "react-hook-form";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface CustomerListFiltersProps {
|
||||
filterRegister: UseFormRegister<FieldValues>;
|
||||
setIsFilterModalOpen: (isOpen: boolean) => void;
|
||||
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;
|
||||
for (const key of ["status", "role", "type"] as const) {
|
||||
const v = values[key];
|
||||
if (v !== undefined && v !== null && v !== "" && String(v).trim()) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
const CustomerListFilters = ({
|
||||
filterRegister,
|
||||
setIsFilterModalOpen,
|
||||
isMutating,
|
||||
handleFilterSubmit,
|
||||
search,
|
||||
watch,
|
||||
setValue,
|
||||
onClearAll,
|
||||
}: CustomerListFiltersProps) => {
|
||||
const watched = watch();
|
||||
const activeCount = useMemo(
|
||||
() => countActive(watched as Record<string, unknown>),
|
||||
[watched],
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
|
||||
onSubmit={handleFilterSubmit(search)}
|
||||
<InlineListFiltersPanel
|
||||
title="Filter customers"
|
||||
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
|
||||
{...filterRegister("status")}
|
||||
items={getEnumAsArray(CustomerStatus)}
|
||||
label="status"
|
||||
label="Status"
|
||||
name="status"
|
||||
placeholder="Status"
|
||||
clearable
|
||||
@@ -46,7 +76,7 @@ const CustomerListFilters = ({
|
||||
<Select
|
||||
{...filterRegister("role")}
|
||||
items={getEnumAsArray(AdminRoles)}
|
||||
label="role"
|
||||
label="Role"
|
||||
name="role"
|
||||
placeholder="Role"
|
||||
clearable
|
||||
@@ -56,34 +86,39 @@ const CustomerListFilters = ({
|
||||
<Select
|
||||
{...filterRegister("type")}
|
||||
items={getEnumAsArray(CustomerType)}
|
||||
label="type"
|
||||
label="Type"
|
||||
name="type"
|
||||
placeholder="Type"
|
||||
clearable
|
||||
value={watch("type")}
|
||||
onClear={() => setValue("type", undefined)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-4 col-span-2">
|
||||
<button
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Button
|
||||
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"
|
||||
>
|
||||
{isMutating ? (
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
|
||||
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="customers-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>Search</span>
|
||||
)}
|
||||
</button>
|
||||
<span>Apply filters</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<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"
|
||||
onClick={() => setIsFilterModalOpen(false)}
|
||||
data-cy="customers-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]"
|
||||
>
|
||||
Close
|
||||
Reset all
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</InlineListFiltersPanel>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
import { ExclamationIcon, PencilSquareIcon, TrashIcon } from "@/assets/icons";
|
||||
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
||||
import { Building } from "@/components/Layouts/sidebar/icons";
|
||||
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
||||
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
||||
import IsVisible from "@/components/ui/IsVisible";
|
||||
import ListHeader from "@/components/ui/ListHeader";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
import Modal from "@/components/ui/modal";
|
||||
import Pagination from "@/components/ui/pagination";
|
||||
import Status from "@/components/ui/Status";
|
||||
@@ -24,7 +25,6 @@ import { Tooltip } from "react-tooltip";
|
||||
import AssignToCompanyModalContent from "./AssignToCompanyModalContent";
|
||||
import CustomerListFilters from "./CustomerListFilters";
|
||||
import useCustomerListPresenter from "./useCustomerListPresenter";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
|
||||
const CustomersTable = () => {
|
||||
const {
|
||||
@@ -44,46 +44,58 @@ const CustomersTable = () => {
|
||||
setCurrentCustomer,
|
||||
isAssignCompanyModalOpen,
|
||||
setIsAssignCompanyModalOpen,
|
||||
isFilterModalOpen,
|
||||
setIsFilterModalOpen,
|
||||
filterRegister,
|
||||
handleFilterSubmit,
|
||||
search,
|
||||
watch,
|
||||
setFilterValue,
|
||||
isMutating,
|
||||
setParams,
|
||||
assignCompanyCompaniesQuery,
|
||||
shouldShowPagination,
|
||||
clearAllFilters,
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
pagination,
|
||||
handlePaginationChange,
|
||||
} = useCustomerListPresenter();
|
||||
|
||||
return (
|
||||
<ListWrapper>
|
||||
<ListHeader
|
||||
onFilter={() => setIsFilterModalOpen(true)}
|
||||
createButtonText="Create Customer"
|
||||
<ListHeader hasFilter={false} createButtonText="Create Customer" />
|
||||
<CustomerListFilters
|
||||
setValue={setFilterValue}
|
||||
filterRegister={filterRegister}
|
||||
handleFilterSubmit={handleFilterSubmit}
|
||||
isMutating={isMutating as number}
|
||||
search={search}
|
||||
watch={watch}
|
||||
onClearAll={clearAllFilters}
|
||||
/>
|
||||
<div ref={tableSectionRef}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((columns) => (
|
||||
{columns.map((col) => (
|
||||
<TableHead
|
||||
colSpan={100}
|
||||
className="text-start font-extrabold uppercase"
|
||||
key={columns}
|
||||
key={col}
|
||||
>
|
||||
{columns}
|
||||
{col}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
{isPending && <TableSkeleton column={columns.length} />}
|
||||
<TableBody>
|
||||
{isPending ? (
|
||||
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
|
||||
) : (
|
||||
<TableBody ref={dataTbodyRef}>
|
||||
<EmptyListWrapper
|
||||
list={allCustomers ?? []}
|
||||
columns={columns}
|
||||
emptyMessage="No Customers were found"
|
||||
isLoading={isPending}
|
||||
isLoading={false}
|
||||
>
|
||||
{allCustomers?.map((customer, index) => (
|
||||
<TableRow key={customer?.id ?? index}>
|
||||
@@ -184,36 +196,16 @@ const CustomersTable = () => {
|
||||
))}
|
||||
</EmptyListWrapper>
|
||||
</TableBody>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
<IsVisible condition={shouldShowPagination}>
|
||||
<Pagination
|
||||
currentPage={metadata?.page ? metadata?.page - 1 : 0}
|
||||
totalPages={metadata?.pages ?? 1}
|
||||
onPageChange={(e) => {
|
||||
setParams((currentParams) => ({
|
||||
...currentParams,
|
||||
offset: e.selected * (metadata?.limit ?? 10),
|
||||
limit: metadata?.limit ?? 10,
|
||||
}));
|
||||
}}
|
||||
currentPage={pagination.currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={handlePaginationChange}
|
||||
/>
|
||||
</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">
|
||||
<Modal
|
||||
classNames="w-full xl:w-1/2"
|
||||
|
||||
@@ -6,18 +6,18 @@ import {
|
||||
useCustomersQuery,
|
||||
useDeleteCustomerQuery,
|
||||
} from "@/hooks/queries";
|
||||
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
||||
import { TAssignCustomerToCompanyCredentials, TCustomer } from "@/lib/api";
|
||||
import { apiDefaultParams } from "@/constants/Api_Params";
|
||||
import { deleteEmptyKeys } from "@/utils/shared";
|
||||
import { useIsMutating } from "@tanstack/react-query";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
const useCustomerListPresenter = () => {
|
||||
const [isAssignCompanyModalOpen, setIsAssignCompanyModalOpen] =
|
||||
useState(false);
|
||||
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
|
||||
const [selectedCompanies, setSelectedCompanies] = useState<
|
||||
TAssignCustomerToCompanyCredentials | undefined
|
||||
>();
|
||||
@@ -27,6 +27,7 @@ const useCustomerListPresenter = () => {
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const pathName = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [params, setParams] = useState<Record<string, any>>({
|
||||
...apiDefaultParams,
|
||||
});
|
||||
@@ -41,7 +42,31 @@ const useCustomerListPresenter = () => {
|
||||
setValue: setFilterValue,
|
||||
} = 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);
|
||||
|
||||
useTableSectionRowAnimations(isPending, data, {
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
});
|
||||
|
||||
const assignCompanyCompaniesQuery = useCompanyFullList({
|
||||
enabled: isAssignCompanyModalOpen,
|
||||
});
|
||||
@@ -55,15 +80,17 @@ const useCustomerListPresenter = () => {
|
||||
},
|
||||
);
|
||||
|
||||
const search = (data: any) => {
|
||||
const newParams = { ...params, ...data, offset: 0 };
|
||||
const search = (formData: any) => {
|
||||
const newParams = { ...params, ...formData, offset: 0 };
|
||||
const cleanedParams = deleteEmptyKeys(newParams);
|
||||
setParams(cleanedParams);
|
||||
const queryString = new URLSearchParams(cleanedParams).toString();
|
||||
router.push(`${pathName}?${queryString}`);
|
||||
setIsFilterModalOpen(false);
|
||||
};
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
router.push(pathName);
|
||||
}, [pathName, router]);
|
||||
|
||||
const columns = [
|
||||
"row",
|
||||
"full name",
|
||||
@@ -85,6 +112,26 @@ const useCustomerListPresenter = () => {
|
||||
const shouldShowPagination =
|
||||
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
||||
|
||||
const pagination = useMemo(
|
||||
() => ({
|
||||
currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
|
||||
totalPages: data?.meta?.pages ?? 1,
|
||||
}),
|
||||
[data?.meta?.page, data?.meta?.pages],
|
||||
);
|
||||
|
||||
const handlePaginationChange = useCallback(
|
||||
(e: { selected: number }) => {
|
||||
const limit = data?.meta?.limit ?? 10;
|
||||
setParams((currentParams) => ({
|
||||
...currentParams,
|
||||
offset: e.selected * limit,
|
||||
limit,
|
||||
}));
|
||||
},
|
||||
[data?.meta?.limit],
|
||||
);
|
||||
|
||||
return {
|
||||
allCustomers: customers,
|
||||
metadata: data?.meta,
|
||||
@@ -102,8 +149,6 @@ const useCustomerListPresenter = () => {
|
||||
router,
|
||||
isModalOpen,
|
||||
setIsModalOpen,
|
||||
isFilterModalOpen,
|
||||
setIsFilterModalOpen,
|
||||
filterRegister,
|
||||
handleFilterSubmit,
|
||||
search,
|
||||
@@ -113,6 +158,12 @@ const useCustomerListPresenter = () => {
|
||||
setParams,
|
||||
assignCompanyCompaniesQuery,
|
||||
shouldShowPagination,
|
||||
clearAllFilters,
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
pagination,
|
||||
handlePaginationChange,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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 { cn } from "@/lib/utils";
|
||||
import { capitalize, getPaginatedRowNumber, unixToDate } from "@/utils/shared";
|
||||
import FeedbackListFilters from "./FeedbackListFilters";
|
||||
import useFeedbackTablePresenter from "./useFeedbackTablePresenter";
|
||||
|
||||
interface IProps {
|
||||
@@ -30,8 +31,18 @@ const FeedbackTable = ({ id, paramKey }: IProps) => {
|
||||
router,
|
||||
getShowcaseSectionTitle,
|
||||
metadata,
|
||||
setParams,
|
||||
shouldShowPagination,
|
||||
filterRegister,
|
||||
handleFilterSubmit,
|
||||
search,
|
||||
watch,
|
||||
clearAllFilters,
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
pagination,
|
||||
handlePaginationChange,
|
||||
isMutating,
|
||||
} = useFeedbackTablePresenter({ id, paramKey });
|
||||
|
||||
return (
|
||||
@@ -42,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"
|
||||
>
|
||||
<FeedbackListFilters
|
||||
filterRegister={filterRegister}
|
||||
handleFilterSubmit={handleFilterSubmit}
|
||||
isMutating={isMutating as number}
|
||||
search={search}
|
||||
watch={watch}
|
||||
onClearAll={clearAllFilters}
|
||||
/>
|
||||
<div ref={tableSectionRef}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -52,14 +72,15 @@ const FeedbackTable = ({ id, paramKey }: IProps) => {
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
{isLoading && <TableSkeleton column={columns.length} />}
|
||||
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
|
||||
) : (
|
||||
<TableBody ref={dataTbodyRef}>
|
||||
<EmptyListWrapper
|
||||
list={feedback ?? []}
|
||||
columns={columns}
|
||||
emptyMessage="No feedback were found"
|
||||
isLoading={isLoading}
|
||||
isLoading={false}
|
||||
>
|
||||
{(feedback ?? []).map((item, index) => (
|
||||
<TableRow
|
||||
@@ -88,6 +109,7 @@ const FeedbackTable = ({ id, paramKey }: IProps) => {
|
||||
colSpan={100}
|
||||
>
|
||||
<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"
|
||||
onClick={() => {
|
||||
router.push(`${pathName}/${item.id}`);
|
||||
@@ -100,18 +122,14 @@ const FeedbackTable = ({ id, paramKey }: IProps) => {
|
||||
))}
|
||||
</EmptyListWrapper>
|
||||
</TableBody>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
<IsVisible condition={shouldShowPagination}>
|
||||
<Pagination
|
||||
currentPage={metadata?.page ? metadata.page - 1 : 0}
|
||||
totalPages={metadata?.pages ?? 1}
|
||||
onPageChange={(e) => {
|
||||
setParams((currentParams) => ({
|
||||
...currentParams,
|
||||
offset: e.selected * (metadata?.limit ?? 10),
|
||||
limit: metadata?.limit ?? 10,
|
||||
}));
|
||||
}}
|
||||
currentPage={pagination.currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={handlePaginationChange}
|
||||
/>
|
||||
</IsVisible>
|
||||
</ShowcaseSection>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
import { apiDefaultParams } from "@/constants/Api_Params";
|
||||
import { useGetFeedback } from "@/hooks/queries";
|
||||
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
||||
import { deleteEmptyKeys } from "@/utils/shared";
|
||||
import { useIsMutating } from "@tanstack/react-query";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
interface IProps {
|
||||
id: string;
|
||||
@@ -14,6 +18,19 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathName = usePathname();
|
||||
const isMutating = useIsMutating();
|
||||
|
||||
const {
|
||||
register: filterRegister,
|
||||
handleSubmit: handleFilterSubmit,
|
||||
reset: filterFormReset,
|
||||
watch,
|
||||
setValue: setFilterValue,
|
||||
} = useForm();
|
||||
|
||||
const tableSectionRef = useRef<HTMLDivElement>(null);
|
||||
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const newParams: Record<string, unknown> = {
|
||||
@@ -25,7 +42,8 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
|
||||
newParams[key] = value;
|
||||
}
|
||||
setParams(newParams);
|
||||
}, [searchParams]);
|
||||
filterFormReset(newParams as any);
|
||||
}, [searchParams, filterFormReset]);
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({ ...params, [paramKey]: id }),
|
||||
@@ -33,6 +51,13 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
|
||||
);
|
||||
|
||||
const { data, isLoading } = useGetFeedback(queryParams);
|
||||
|
||||
useTableSectionRowAnimations(isLoading, data, {
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
});
|
||||
|
||||
const columns = [
|
||||
"row",
|
||||
"feedback type",
|
||||
@@ -45,6 +70,39 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
|
||||
!isLoading &&
|
||||
(data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
||||
|
||||
const pagination = useMemo(
|
||||
() => ({
|
||||
currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
|
||||
totalPages: data?.meta?.pages ?? 1,
|
||||
}),
|
||||
[data?.meta?.page, data?.meta?.pages],
|
||||
);
|
||||
|
||||
const handlePaginationChange = useCallback(
|
||||
(e: { selected: number }) => {
|
||||
const limit = data?.meta?.limit ?? 10;
|
||||
setParams((currentParams) => ({
|
||||
...currentParams,
|
||||
offset: e.selected * limit,
|
||||
limit,
|
||||
}));
|
||||
},
|
||||
[data?.meta?.limit],
|
||||
);
|
||||
|
||||
const search = (formData: any) => {
|
||||
const next = { ...params, ...formData, offset: 0 };
|
||||
const cleaned = deleteEmptyKeys(next);
|
||||
const queryString = new URLSearchParams(
|
||||
cleaned as Record<string, string>,
|
||||
).toString();
|
||||
router.push(`${pathName}?${queryString}`);
|
||||
};
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
router.push(pathName);
|
||||
}, [pathName, router]);
|
||||
|
||||
const getShowcaseSectionTitle = () => {
|
||||
if (paramKey === "tender_id") {
|
||||
const title = data?.data?.[0]?.tender?.title;
|
||||
@@ -62,6 +120,18 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
|
||||
metadata: data?.meta,
|
||||
setParams,
|
||||
shouldShowPagination,
|
||||
filterRegister,
|
||||
handleFilterSubmit,
|
||||
search,
|
||||
watch,
|
||||
setFilterValue,
|
||||
clearAllFilters,
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
pagination,
|
||||
handlePaginationChange,
|
||||
isMutating,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import InputGroup from "@/components/FormElements/InputGroup";
|
||||
import { Select } from "@/components/FormElements/select";
|
||||
import { Button } from "@/components/ui-elements/button";
|
||||
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
||||
import { INQUIRY_STATUS } from "@/constants/enums";
|
||||
import {
|
||||
FieldValues,
|
||||
@@ -8,15 +12,16 @@ import {
|
||||
UseFormSetValue,
|
||||
UseFormWatch,
|
||||
} from "react-hook-form";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface InquiriesListFiltersProps {
|
||||
filterRegister: UseFormRegister<FieldValues>;
|
||||
setIsFilterModalOpen: (isOpen: boolean) => void;
|
||||
isMutating: number;
|
||||
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
|
||||
search: (data: any) => void;
|
||||
watch: UseFormWatch<FieldValues>;
|
||||
setValue: UseFormSetValue<any>;
|
||||
onClearAll: () => void;
|
||||
}
|
||||
|
||||
const statusOptions = Object.values(INQUIRY_STATUS).map((status) => ({
|
||||
@@ -24,58 +29,97 @@ const statusOptions = Object.values(INQUIRY_STATUS).map((status) => ({
|
||||
value: status,
|
||||
}));
|
||||
|
||||
function countActive(values: Record<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 = ({
|
||||
filterRegister,
|
||||
setIsFilterModalOpen,
|
||||
isMutating,
|
||||
handleFilterSubmit,
|
||||
search,
|
||||
setValue,
|
||||
watch,
|
||||
onClearAll,
|
||||
}: InquiriesListFiltersProps) => {
|
||||
const watched = watch();
|
||||
const activeCount = useMemo(
|
||||
() => countActive(watched as Record<string, unknown>),
|
||||
[watched],
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
|
||||
onSubmit={handleFilterSubmit(search)}
|
||||
<InlineListFiltersPanel
|
||||
title="Filter inquiries"
|
||||
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
|
||||
{...filterRegister("q")}
|
||||
name="q"
|
||||
label="search"
|
||||
label="Keyword search"
|
||||
placeholder="Search"
|
||||
type="search"
|
||||
dataCy="inquiries-filter-q-input"
|
||||
/>
|
||||
<Select
|
||||
{...filterRegister("status")}
|
||||
name="status"
|
||||
label="status"
|
||||
placeholder="Select Status"
|
||||
label="Status"
|
||||
placeholder="Select status"
|
||||
items={statusOptions}
|
||||
clearable
|
||||
value={watch("status")}
|
||||
onClear={() => setValue("status", "")}
|
||||
dataCy="inquiries-filter-status-select"
|
||||
/>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Button
|
||||
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"
|
||||
>
|
||||
{isMutating ? (
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
|
||||
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="inquiries-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>Search</span>
|
||||
)}
|
||||
</button>
|
||||
<span>Apply filters</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<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"
|
||||
onClick={() => setIsFilterModalOpen(false)}
|
||||
data-cy="inquiries-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]"
|
||||
>
|
||||
Close
|
||||
Reset all
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</InlineListFiltersPanel>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -34,8 +34,6 @@ const InquiriesTable = () => {
|
||||
columns,
|
||||
inquiries,
|
||||
allInquiries,
|
||||
isFilterModalOpen,
|
||||
setIsFilterModalOpen,
|
||||
isPending,
|
||||
currentInquiry,
|
||||
isDeleteModalOpen,
|
||||
@@ -51,16 +49,28 @@ const InquiriesTable = () => {
|
||||
watch,
|
||||
setFilterValue,
|
||||
isMutating,
|
||||
setParams,
|
||||
shouldShowPagination,
|
||||
clearAllFilters,
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
pagination,
|
||||
handlePaginationChange,
|
||||
} = useInquiriesListPresenter();
|
||||
|
||||
return (
|
||||
<ListWrapper>
|
||||
<ListHeader
|
||||
onFilter={() => setIsFilterModalOpen(true)}
|
||||
hasCreate={false}
|
||||
<ListHeader hasFilter={false} hasCreate={false} />
|
||||
<InquiriesListFilters
|
||||
setValue={setFilterValue}
|
||||
filterRegister={filterRegister}
|
||||
handleFilterSubmit={handleFilterSubmit}
|
||||
isMutating={isMutating as number}
|
||||
search={search}
|
||||
watch={watch}
|
||||
onClearAll={clearAllFilters}
|
||||
/>
|
||||
<div ref={tableSectionRef}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-none uppercase [&>th]:text-start">
|
||||
@@ -71,13 +81,15 @@ const InquiriesTable = () => {
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
{isPending && <TableSkeleton column={columns.length} />}
|
||||
<TableBody>
|
||||
{isPending ? (
|
||||
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
|
||||
) : (
|
||||
<TableBody ref={dataTbodyRef}>
|
||||
<EmptyListWrapper
|
||||
columns={columns}
|
||||
list={inquiries ?? []}
|
||||
emptyMessage="No Inquiries were found"
|
||||
isLoading={isPending}
|
||||
isLoading={false}
|
||||
>
|
||||
{inquiries?.map((inquiry: TInquiries, index: number) => (
|
||||
<TableRow key={inquiry?.id ?? index}>
|
||||
@@ -108,6 +120,7 @@ const InquiriesTable = () => {
|
||||
<TableCell className="text-start xl:pr-7.5">
|
||||
<div className="flex items-center justify-start gap-x-3.5">
|
||||
<button
|
||||
type="button"
|
||||
data-tooltip-id="change-status"
|
||||
data-tooltip-content="Change Status"
|
||||
data-tooltip-place="top"
|
||||
@@ -124,6 +137,7 @@ const InquiriesTable = () => {
|
||||
<PencilSquareIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-tooltip-id="delete"
|
||||
data-tooltip-content="Delete"
|
||||
data-tooltip-place="top"
|
||||
@@ -143,38 +157,9 @@ const InquiriesTable = () => {
|
||||
))}
|
||||
</EmptyListWrapper>
|
||||
</TableBody>
|
||||
)}
|
||||
</Table>
|
||||
<Modal
|
||||
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>
|
||||
</div>
|
||||
<Modal
|
||||
isOpen={isChangeStatusModalOpen}
|
||||
onClose={() => setIsChangeStatusModalOpen(false)}
|
||||
@@ -192,6 +177,13 @@ const InquiriesTable = () => {
|
||||
currentStatus={currentInquiry?.status ?? "pending"}
|
||||
/>
|
||||
</Modal>
|
||||
<IsVisible condition={shouldShowPagination}>
|
||||
<Pagination
|
||||
currentPage={pagination.currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={handlePaginationChange}
|
||||
/>
|
||||
</IsVisible>
|
||||
{isDeleteModalOpen && (
|
||||
<ConfirmationModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
|
||||
@@ -4,15 +4,15 @@ import {
|
||||
useDeleteInquiry,
|
||||
useGetInquiries,
|
||||
} from "@/hooks/queries/useInquiryQueries";
|
||||
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
||||
import { TInquiries } from "@/lib/api";
|
||||
import { deleteEmptyKeys } from "@/utils/shared";
|
||||
import { useIsMutating } from "@tanstack/react-query";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
const useInquiriesListPresenter = () => {
|
||||
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isChangeStatusModalOpen, setIsChangeStatusModalOpen] = useState(false);
|
||||
const [currentInquiry, setCurrentInquiry] = useState<TInquiries | null>(null);
|
||||
@@ -39,6 +39,10 @@ const useInquiriesListPresenter = () => {
|
||||
setValue: setFilterValue,
|
||||
} = useForm();
|
||||
|
||||
const tableSectionRef = useRef<HTMLDivElement>(null);
|
||||
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const newParams: Record<string, any> = {
|
||||
offset: 0,
|
||||
@@ -52,30 +56,61 @@ const useInquiriesListPresenter = () => {
|
||||
}, [searchParams, filterFormReset]);
|
||||
|
||||
const { data, isPending } = useGetInquiries(params);
|
||||
|
||||
useTableSectionRowAnimations(isPending, data, {
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
});
|
||||
|
||||
const shouldShowPagination =
|
||||
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
||||
|
||||
const pagination = useMemo(
|
||||
() => ({
|
||||
currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
|
||||
totalPages: data?.meta?.pages ?? 1,
|
||||
}),
|
||||
[data?.meta?.page, data?.meta?.pages],
|
||||
);
|
||||
|
||||
const handlePaginationChange = useCallback(
|
||||
(e: { selected: number }) => {
|
||||
const limit = data?.meta?.limit ?? 10;
|
||||
setParams((currentParams) => ({
|
||||
...currentParams,
|
||||
offset: e.selected * limit,
|
||||
limit,
|
||||
}));
|
||||
},
|
||||
[data?.meta?.limit],
|
||||
);
|
||||
|
||||
const { mutate } = useDeleteInquiry(() => setIsDeleteModalOpen(false));
|
||||
const { mutate: changeStatus } =
|
||||
useChangeInquiryStatus(() => setIsChangeStatusModalOpen(false));
|
||||
|
||||
const search = (data: any) => {
|
||||
const newParams = { ...params, ...data, offset: 0 };
|
||||
const search = (formData: any) => {
|
||||
const newParams = { ...params, ...formData, offset: 0 };
|
||||
const cleanedParams = deleteEmptyKeys(newParams);
|
||||
const queryString = new URLSearchParams(cleanedParams).toString();
|
||||
router.push(`${pathname}?${queryString}`);
|
||||
setIsFilterModalOpen(false);
|
||||
};
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
router.push(pathname);
|
||||
}, [pathname, router]);
|
||||
|
||||
const deleteInquiry = () => {
|
||||
if (!currentInquiry?.id) return;
|
||||
mutate(currentInquiry.id);
|
||||
};
|
||||
|
||||
const handleChangeStatus = (data: any) => {
|
||||
const handleChangeStatus = (payload: any) => {
|
||||
if (!currentInquiry?.id) return;
|
||||
changeStatus({
|
||||
id: currentInquiry.id,
|
||||
credentials: data,
|
||||
credentials: payload,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -83,8 +118,6 @@ const useInquiriesListPresenter = () => {
|
||||
columns,
|
||||
inquiries: data?.data.inquiries,
|
||||
allInquiries: data,
|
||||
isFilterModalOpen,
|
||||
setIsFilterModalOpen,
|
||||
isPending,
|
||||
isDeleteModalOpen,
|
||||
setIsDeleteModalOpen,
|
||||
@@ -103,6 +136,12 @@ const useInquiriesListPresenter = () => {
|
||||
isMutating,
|
||||
setParams,
|
||||
shouldShowPagination,
|
||||
clearAllFilters,
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
pagination,
|
||||
handlePaginationChange,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import InputGroup from "@/components/FormElements/InputGroup";
|
||||
import { Select } from "@/components/FormElements/select";
|
||||
import { Button } from "@/components/ui-elements/button";
|
||||
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
||||
import {
|
||||
FieldValues,
|
||||
UseFormHandleSubmit,
|
||||
@@ -7,29 +11,64 @@ import {
|
||||
UseFormSetValue,
|
||||
UseFormWatch,
|
||||
} from "react-hook-form";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface NotificationHistoryListFiltersProps {
|
||||
filterRegister: UseFormRegister<FieldValues>;
|
||||
setIsFilterModalOpen: (isOpen: boolean) => void;
|
||||
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 seen = values.seen;
|
||||
if (
|
||||
seen !== undefined &&
|
||||
seen !== null &&
|
||||
seen !== "" &&
|
||||
String(seen).trim()
|
||||
) {
|
||||
n++;
|
||||
}
|
||||
const searchVal = values.search;
|
||||
if (typeof searchVal === "string" && searchVal.trim()) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
const NotificationHistoryListFilters = ({
|
||||
filterRegister,
|
||||
setIsFilterModalOpen,
|
||||
isMutating,
|
||||
handleFilterSubmit,
|
||||
search,
|
||||
watch,
|
||||
setValue,
|
||||
onClearAll,
|
||||
}: NotificationHistoryListFiltersProps) => {
|
||||
const watched = watch();
|
||||
const activeCount = useMemo(
|
||||
() => countActive(watched as Record<string, unknown>),
|
||||
[watched],
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
|
||||
onSubmit={handleFilterSubmit(search)}
|
||||
<InlineListFiltersPanel
|
||||
title="Filter notifications"
|
||||
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
|
||||
{...filterRegister("seen")}
|
||||
items={[
|
||||
@@ -38,35 +77,45 @@ const NotificationHistoryListFilters = ({
|
||||
]}
|
||||
label="Seen"
|
||||
name="seen"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
value={watch("seen")}
|
||||
onClear={() => setValue("seen", undefined)}
|
||||
/>
|
||||
<InputGroup
|
||||
{...filterRegister("search")}
|
||||
name="search"
|
||||
label="search"
|
||||
placeholder="search"
|
||||
label="Search"
|
||||
placeholder="Search"
|
||||
type="search"
|
||||
dataCy="notification-history-filter-search-input"
|
||||
/>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Button
|
||||
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"
|
||||
>
|
||||
{isMutating ? (
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
|
||||
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="notification-history-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>Search</span>
|
||||
)}
|
||||
</button>
|
||||
<span>Apply filters</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<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"
|
||||
onClick={() => setIsFilterModalOpen(false)}
|
||||
data-cy="notification-history-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]"
|
||||
>
|
||||
Close
|
||||
Reset all
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</InlineListFiltersPanel>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ import { Tooltip } from "react-tooltip";
|
||||
import Boolean from "@/components/ui/Boolean";
|
||||
import ListHeader from "@/components/ui/ListHeader";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
import Modal from "@/components/ui/modal";
|
||||
import NotificationHistoryListFilters from "./NotificationHistoryListFilters";
|
||||
import useNotificationHistoryTablePresenter from "./useNotificationHistoryTablePresenter";
|
||||
|
||||
@@ -36,43 +35,56 @@ const NotificationHistoryTable = () => {
|
||||
notificationHistory,
|
||||
pathName,
|
||||
router,
|
||||
isFilterModalOpen,
|
||||
setIsFilterModalOpen,
|
||||
setParams,
|
||||
handleSubmit,
|
||||
register,
|
||||
handleFilterSubmit,
|
||||
filterRegister,
|
||||
setValue,
|
||||
watch,
|
||||
search,
|
||||
isMutating,
|
||||
shouldShowPagination,
|
||||
clearAllFilters,
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
pagination,
|
||||
handlePaginationChange,
|
||||
} = useNotificationHistoryTablePresenter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListWrapper>
|
||||
<ListHeader
|
||||
createButtonText="Create notification"
|
||||
onFilter={() => setIsFilterModalOpen(true)}
|
||||
<ListHeader hasFilter={false} createButtonText="Create notification" />
|
||||
<NotificationHistoryListFilters
|
||||
filterRegister={filterRegister}
|
||||
handleFilterSubmit={handleFilterSubmit}
|
||||
isMutating={isMutating as number}
|
||||
watch={watch}
|
||||
setValue={setValue}
|
||||
search={search}
|
||||
onClearAll={clearAllFilters}
|
||||
/>
|
||||
<div ref={tableSectionRef}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<TableHead key={column}>
|
||||
{column}
|
||||
</TableHead>
|
||||
<TableHead key={column}>{column}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
{isLoading && (
|
||||
<TableSkeleton column={columns.length} cellColSpan={1} />
|
||||
)}
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableSkeleton
|
||||
ref={skeletonTbodyRef}
|
||||
column={columns.length}
|
||||
cellColSpan={1}
|
||||
/>
|
||||
) : (
|
||||
<TableBody ref={dataTbodyRef}>
|
||||
<EmptyListWrapper
|
||||
list={notificationHistory ?? []}
|
||||
columns={columns}
|
||||
emptyMessage="No notifications were found"
|
||||
isLoading={false}
|
||||
>
|
||||
{(notificationHistory ?? []).map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
@@ -91,18 +103,12 @@ const NotificationHistoryTable = () => {
|
||||
unix: item.created_at,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.recipient?.full_name}
|
||||
</TableCell>
|
||||
<TableCell>{item.recipient?.full_name}</TableCell>
|
||||
<TableCell className="truncate">
|
||||
{truncateString(item.message, 20, 20)}
|
||||
</TableCell>
|
||||
<TableCell className="capitalize">
|
||||
{item.priority}
|
||||
</TableCell>
|
||||
<TableCell className="capitalize">
|
||||
{item.type}
|
||||
</TableCell>
|
||||
<TableCell className="capitalize">{item.priority}</TableCell>
|
||||
<TableCell className="capitalize">{item.type}</TableCell>
|
||||
<TableCell className="capitalize">
|
||||
<Boolean value={item.seen} />
|
||||
</TableCell>
|
||||
@@ -111,6 +117,7 @@ const NotificationHistoryTable = () => {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
data-tooltip-id="details"
|
||||
data-tooltip-content="Details"
|
||||
data-tooltip-place="top"
|
||||
@@ -130,36 +137,17 @@ const NotificationHistoryTable = () => {
|
||||
))}
|
||||
</EmptyListWrapper>
|
||||
</TableBody>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
<IsVisible condition={shouldShowPagination}>
|
||||
<Pagination
|
||||
currentPage={metadata?.page ? metadata?.page - 1 : 0}
|
||||
totalPages={metadata?.pages ?? 1}
|
||||
onPageChange={(e) => {
|
||||
setParams((currentParams: Record<string, any>) => ({
|
||||
...currentParams,
|
||||
offset: e.selected * (metadata?.limit ?? 10),
|
||||
limit: metadata?.limit ?? 10,
|
||||
}));
|
||||
}}
|
||||
currentPage={pagination.currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={handlePaginationChange}
|
||||
/>
|
||||
</IsVisible>
|
||||
</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";
|
||||
import { apiDefaultParams } from "@/constants/Api_Params";
|
||||
import { useGetNotificationHistoryQuery } from "@/hooks/queries/useNotificationQueries";
|
||||
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
||||
import { deleteEmptyKeys } from "@/utils/shared";
|
||||
import { useIsMutating } from "@tanstack/react-query";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
const useNotificationHistoryTablePresenter = () => {
|
||||
const { register, handleSubmit, watch, setValue } = useForm();
|
||||
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
|
||||
const {
|
||||
register: filterRegister,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
reset: filterFormReset,
|
||||
} = useForm();
|
||||
const [params, setParams] = useState<Record<string, any>>({
|
||||
...apiDefaultParams,
|
||||
});
|
||||
const searchParams = useSearchParams();
|
||||
const tableSectionRef = useRef<HTMLDivElement>(null);
|
||||
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
|
||||
|
||||
const { data, isLoading } = useGetNotificationHistoryQuery(params);
|
||||
|
||||
useTableSectionRowAnimations(isLoading, data, {
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
});
|
||||
|
||||
const shouldShowPagination =
|
||||
!isLoading && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
|
||||
const isMutating = useIsMutating();
|
||||
@@ -32,14 +50,51 @@ const useNotificationHistoryTablePresenter = () => {
|
||||
];
|
||||
const pathName = usePathname();
|
||||
const router = useRouter();
|
||||
const search = (data: any) => {
|
||||
const newParams = { ...params, ...data, offset: 0 };
|
||||
|
||||
useEffect(() => {
|
||||
const newParams: Record<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);
|
||||
setParams(cleanedParams);
|
||||
const queryString = new URLSearchParams(cleanedParams).toString();
|
||||
router.push(`${pathName}?${queryString}`);
|
||||
setIsFilterModalOpen(false);
|
||||
};
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
router.push(pathName);
|
||||
}, [pathName, router]);
|
||||
|
||||
return {
|
||||
notificationHistory: data?.data,
|
||||
metadata: data?.meta,
|
||||
@@ -48,15 +103,20 @@ const useNotificationHistoryTablePresenter = () => {
|
||||
router,
|
||||
pathName,
|
||||
setParams,
|
||||
register,
|
||||
filterRegister,
|
||||
handleSubmit,
|
||||
handleFilterSubmit: handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
isFilterModalOpen,
|
||||
setIsFilterModalOpen,
|
||||
isMutating,
|
||||
search,
|
||||
shouldShowPagination,
|
||||
clearAllFilters,
|
||||
tableSectionRef,
|
||||
skeletonTbodyRef,
|
||||
dataTbodyRef,
|
||||
pagination,
|
||||
handlePaginationChange,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronUpIcon, GlobeIcon, SearchIcon } from "@/assets/icons";
|
||||
import { GlobeIcon, SearchIcon } from "@/assets/icons";
|
||||
import DatePicker from "@/components/FormElements/DatePicker/DatePicker";
|
||||
import InputGroup from "@/components/FormElements/InputGroup";
|
||||
import { Select } from "@/components/FormElements/select";
|
||||
import { Button } from "@/components/ui-elements/button";
|
||||
import CollapsibleFilterSection from "@/components/ui/CollapsibleFilterSection";
|
||||
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
|
||||
import { Countries } from "@/constants/countries";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Control,
|
||||
FieldValues,
|
||||
@@ -15,8 +17,6 @@ import {
|
||||
UseFormSetValue,
|
||||
UseFormWatch,
|
||||
} from "react-hook-form";
|
||||
import gsap from "gsap";
|
||||
import { type ReactNode, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface TenderListFiltersProps {
|
||||
filterRegister: UseFormRegister<FieldValues>;
|
||||
@@ -71,69 +71,6 @@ function countActiveFilters(values: Record<string, unknown>): number {
|
||||
return n;
|
||||
}
|
||||
|
||||
function CollapsibleFilterCard({
|
||||
title,
|
||||
subtitle,
|
||||
badge,
|
||||
defaultOpen,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
badge?: string;
|
||||
defaultOpen?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(Boolean(defaultOpen));
|
||||
|
||||
return (
|
||||
<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 = ({
|
||||
filterRegister,
|
||||
control,
|
||||
@@ -145,186 +82,19 @@ const TenderListFilters = ({
|
||||
onClearAll,
|
||||
}: TenderListFiltersProps) => {
|
||||
const watched = watch();
|
||||
const activeCount = useMemo(() => countActiveFilters(watched as Record<string, unknown>), [watched]);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
/** When true, header stays flush (square bottom) with the panel — keep true until collapse GSAP finishes so radius does not pop early. */
|
||||
const [headerFlushBottom, setHeaderFlushBottom] = useState(false);
|
||||
|
||||
const panelOuterRef = useRef<HTMLDivElement>(null);
|
||||
const panelInnerRef = useRef<HTMLDivElement>(null);
|
||||
const chevronRef = useRef<HTMLSpanElement>(null);
|
||||
const prevPanelOpen = useRef(panelOpen);
|
||||
|
||||
/** Initial dimensions without animating (avoids flash + Strict Mode double-invoke issues). */
|
||||
useLayoutEffect(() => {
|
||||
const outer = panelOuterRef.current;
|
||||
const inner = panelInnerRef.current;
|
||||
const chevron = chevronRef.current;
|
||||
if (!outer || !inner) return;
|
||||
|
||||
gsap.set(outer, { height: panelOpen ? "auto" : 0 });
|
||||
gsap.set(inner, {
|
||||
opacity: panelOpen ? 1 : 0,
|
||||
y: panelOpen ? 0 : -14,
|
||||
scale: panelOpen ? 1 : 0.995,
|
||||
});
|
||||
if (chevron) gsap.set(chevron, { rotation: panelOpen ? 0 : 180, transformOrigin: "50% 50%" });
|
||||
if (!panelOpen) {
|
||||
setHeaderFlushBottom(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- run once on mount for initial `panelOpen`
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const outer = panelOuterRef.current;
|
||||
const inner = panelInnerRef.current;
|
||||
const chevron = chevronRef.current;
|
||||
if (!outer || !inner) return;
|
||||
|
||||
if (prevPanelOpen.current === panelOpen) return;
|
||||
prevPanelOpen.current = panelOpen;
|
||||
|
||||
const reduced =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
gsap.killTweensOf([outer, inner, chevron].filter(Boolean));
|
||||
|
||||
if (reduced) {
|
||||
gsap.set(outer, { height: panelOpen ? "auto" : 0 });
|
||||
gsap.set(inner, { opacity: panelOpen ? 1 : 0, y: panelOpen ? 0 : -10, scale: 1 });
|
||||
if (chevron) gsap.set(chevron, { rotation: panelOpen ? 0 : 180 });
|
||||
setHeaderFlushBottom(panelOpen);
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
if (panelOpen) {
|
||||
setHeaderFlushBottom(true);
|
||||
const targetH = inner.scrollHeight;
|
||||
gsap.set(outer, { height: 0 });
|
||||
gsap.set(inner, { opacity: 0, y: -36, scale: 0.985 });
|
||||
|
||||
const tl = gsap.timeline({ defaults: { ease: "none" } });
|
||||
tl.to(outer, {
|
||||
height: targetH,
|
||||
duration: 0.62,
|
||||
ease: "power4.out",
|
||||
onComplete: () => {
|
||||
gsap.set(outer, { height: "auto" });
|
||||
},
|
||||
}).to(
|
||||
inner,
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
duration: 0.52,
|
||||
ease: "power3.out",
|
||||
},
|
||||
"-=0.5",
|
||||
const activeCount = useMemo(
|
||||
() => countActiveFilters(watched as Record<string, unknown>),
|
||||
[watched],
|
||||
);
|
||||
|
||||
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="relative mb-8">
|
||||
<div className="relative overflow-hidden rounded-[1.75rem] border border-stroke/60 bg-white dark:border-dark-3 dark:bg-dark-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPanelOpen((o) => !o)}
|
||||
aria-expanded={panelOpen}
|
||||
aria-label={panelOpen ? "Collapse tender filters" : "Expand tender filters"}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-start gap-x-3 gap-y-3 px-4 py-4 text-start transition-colors sm:items-center sm:justify-between sm:gap-4 sm:px-6 sm:py-5",
|
||||
"rounded-[1.65rem] border border-transparent",
|
||||
"hover:border-stroke/50 hover:bg-white/70 dark:hover:border-white/[0.08] dark:hover:bg-dark-3/50",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-dark-2",
|
||||
headerFlushBottom &&
|
||||
"rounded-b-none border-b border-stroke/45 dark:border-white/[0.07]",
|
||||
)}
|
||||
<InlineListFiltersPanel
|
||||
title="Refine tenders"
|
||||
activeFilterCount={activeCount}
|
||||
expandAriaLabel="Expand tender filters"
|
||||
collapseAriaLabel="Collapse tender filters"
|
||||
panelToggleDataCy="tenders-filter-panel-toggle"
|
||||
>
|
||||
<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
|
||||
className="space-y-4 px-4 pb-6 pt-1 sm:px-6"
|
||||
onSubmit={handleFilterSubmit(search)}
|
||||
@@ -338,7 +108,9 @@ const TenderListFilters = ({
|
||||
label="Keyword search"
|
||||
type="text"
|
||||
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"
|
||||
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
|
||||
type="submit"
|
||||
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]"
|
||||
label={
|
||||
isMutating ? (
|
||||
@@ -367,7 +138,7 @@ const TenderListFilters = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CollapsibleFilterCard
|
||||
<CollapsibleFilterSection
|
||||
title="Scope & geography"
|
||||
subtitle="Country pick or comma-separated codes for multi-region searches."
|
||||
>
|
||||
@@ -391,9 +162,9 @@ const TenderListFilters = ({
|
||||
placeholder="e.g. SWE, DEU"
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleFilterCard>
|
||||
</CollapsibleFilterSection>
|
||||
|
||||
<CollapsibleFilterCard
|
||||
<CollapsibleFilterSection
|
||||
title="Notice & classification"
|
||||
subtitle="TED notice dimensions and CPV-style filters."
|
||||
>
|
||||
@@ -447,9 +218,9 @@ const TenderListFilters = ({
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleFilterCard>
|
||||
</CollapsibleFilterSection>
|
||||
|
||||
<CollapsibleFilterCard
|
||||
<CollapsibleFilterSection
|
||||
title="Date ranges"
|
||||
subtitle="Unix-backed ranges — pick start/end; cleared ranges are omitted from the query."
|
||||
badge="Time travel"
|
||||
@@ -463,6 +234,7 @@ const TenderListFilters = ({
|
||||
range
|
||||
timePicker
|
||||
clearable
|
||||
portal
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
timePicker
|
||||
clearable
|
||||
portal
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
timePicker
|
||||
clearable
|
||||
portal
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
timePicker
|
||||
clearable
|
||||
portal
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleFilterCard>
|
||||
</CollapsibleFilterSection>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InlineListFiltersPanel>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 mappedValues = { ...values };
|
||||
|
||||
@@ -250,7 +281,7 @@ const useTenderListPresenter = () => {
|
||||
newParams[key] = value;
|
||||
}
|
||||
setParams(newParams);
|
||||
filterFormReset(buildTenderFilterFormValues(newParams));
|
||||
filterFormReset(mergeTenderFilterFormState(newParams));
|
||||
}, [searchParams, filterFormReset]);
|
||||
|
||||
const { data, error, isPending } = useTendersQuery(params);
|
||||
@@ -377,8 +408,14 @@ const useTenderListPresenter = () => {
|
||||
};
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
const cleared: Record<string, any> = {
|
||||
...apiDefaultParams,
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
};
|
||||
filterFormReset(mergeTenderFilterFormState(cleared));
|
||||
router.push(pathName);
|
||||
}, [pathName, router]);
|
||||
}, [pathName, router, filterFormReset]);
|
||||
const columns = [
|
||||
"row",
|
||||
"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 Lottie from "lottie-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
interface IProps {
|
||||
className?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen route-style loader (orbs, rings, shimmer). Pass `className` to adapt
|
||||
* the shell—for example `min-h-48 flex-none` inside compact panels.
|
||||
* Extract of `animations/main.json` from `public/lottie/loading.lottie` (regenerate when
|
||||
* the .lottie changes: `unzip -p public/lottie/loading.lottie animations/main.json > src/assets/lottie/loading-main.json`).
|
||||
*/
|
||||
const LOTTIE_RAW = rawLoadingMain as object;
|
||||
|
||||
/**
|
||||
* Route-level loader: SVG Lottie via `lottie-react` (reliable on refresh). Source animation
|
||||
* stays in sync with `public/lottie/loading.lottie` via the bundled extract above.
|
||||
*/
|
||||
const Loading = ({ className }: IProps) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const mode = mounted && resolvedTheme === "dark" ? "dark" : "light";
|
||||
|
||||
const animationData = useMemo(
|
||||
() => applyLoadingLottieTheme(LOTTIE_RAW, mode),
|
||||
[mode],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes app-route-loading-shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(400%); }
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
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,
|
||||
)}
|
||||
>
|
||||
{/* Soft gradient wash */}
|
||||
<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
|
||||
/>
|
||||
|
||||
{/* Floating orbs */}
|
||||
<div
|
||||
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
|
||||
/>
|
||||
<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 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" />
|
||||
|
||||
{/* Subtle grid */}
|
||||
<div
|
||||
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="relative z-10 flex flex-col items-center gap-6 px-8">
|
||||
<div className="relative aspect-square w-[min(200px,55vw)] shrink-0">
|
||||
<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
|
||||
/>
|
||||
|
||||
<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 className="flex flex-col items-center gap-3 text-center">
|
||||
<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 className="font-mono text-[11px] font-semibold uppercase tracking-[0.45em] text-gray-6 dark:text-dark-6">
|
||||
Loading
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Shimmer bar */}
|
||||
<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",
|
||||
}}
|
||||
/>
|
||||
<span className="sr-only">Loading, please wait.</span>
|
||||
</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) => {
|
||||
const pathName = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const rowJustify =
|
||||
hasFilter && hasCreate
|
||||
? "justify-between"
|
||||
: hasCreate && !hasFilter
|
||||
? "justify-center md:justify-end"
|
||||
: "justify-start";
|
||||
|
||||
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}>
|
||||
<Button
|
||||
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