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