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
+101 -85
View File
@@ -1,7 +1,13 @@
"use client";
import { SearchIcon } from "@/assets/icons";
import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select";
import { Button } from "@/components/ui-elements/button";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import { AdminStatus } from "@/constants/enums";
import { getEnumAsArray } from "@/utils/shared";
import { useMemo } from "react";
import {
FieldValues,
UseFormHandleSubmit,
@@ -12,103 +18,113 @@ import {
interface AdminListFiltersProps {
filterRegister: UseFormRegister<FieldValues>;
setIsFilterModalOpen: (isOpen: boolean) => void;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
search: (data: any) => void;
watch: UseFormWatch<FieldValues>;
setValue: UseFormSetValue<any>;
onClearAll: () => void;
}
const AdminListFilters = ({
function countActiveAdminFilters(values: Record<string, unknown>): number {
let n = 0;
const searchVal = values.search;
if (typeof searchVal === "string" && searchVal.trim()) n++;
const statusVal = values.status;
if (
statusVal !== undefined &&
statusVal !== null &&
statusVal !== "" &&
String(statusVal).trim()
) {
n++;
}
return n;
}
export default function AdminListFilters({
filterRegister,
setIsFilterModalOpen,
isMutating,
handleFilterSubmit,
search,
watch,
setValue,
}: AdminListFiltersProps) => {
return (
<form
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
onSubmit={handleFilterSubmit(search)}
data-cy="admins-filter-form"
>
<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>
onClearAll,
}: AdminListFiltersProps) {
const watched = watch();
const activeCount = useMemo(
() => countActiveAdminFilters(watched as Record<string, unknown>),
[watched],
);
};
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 { AdminStatus } from "@/constants/enums";
import { _TooltipDefaultParams } from "@/constants/tooltip";
import { getPaginatedRowNumber } from "@/utils/shared";
import { Tooltip } from "react-tooltip";
import AdminListFilters from "./AdminListFilters";
import ChangeStatusModalContent from "./ChangeStatusModalContent";
@@ -28,8 +27,7 @@ import { useAdminsPresenter } from "./useAdminsPresenter";
const AdminsTable = () => {
const {
allUsers,
router,
usersList,
isPending,
onConfirmDelete,
isModalOpen,
@@ -37,9 +35,6 @@ const AdminsTable = () => {
isChangeStatusModalOpen,
setIsChangeStatusModalOpen,
columns,
setCurrentUser,
isFilterModalOpen,
setIsFilterModalOpen,
changeStatus,
currentUser,
setStatus,
@@ -50,120 +45,136 @@ const AdminsTable = () => {
filterRegister,
isMutating,
watch,
metadata,
setParams,
shouldShowPagination,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
getRowNumber,
pagination,
handlePaginationChange,
navigateToEditAdmin,
openDeleteModalForUser,
openChangeStatusModalForUser,
} = useAdminsPresenter();
return (
<ListWrapper data-cy="admins-page">
<ListHeader
onFilter={() => setIsFilterModalOpen(true)}
hasFilter={false}
createButtonText="Create Admin"
dataCyPrefix="admins"
/>
<Table data-cy="admins-table">
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
{columns.map((column, index) => (
<TableHead
key={column}
colSpan={100}
data-cy={`admins-table-header-${index}`}
>
{column}
</TableHead>
))}
</TableRow>
</TableHeader>
{isPending && <TableSkeleton column={columns.length} />}
<TableBody>
<EmptyListWrapper
list={allUsers}
columns={columns}
emptyMessage="No admins were found"
isLoading={isPending}
>
{allUsers.map((item, index) => (
<TableRow
key={item?.id || index}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
data-cy={`admins-table-row-${item?.id ?? index}`}
>
<TableCell className="text-start" colSpan={100} data-cy="admins-row-number">
{getPaginatedRowNumber({
index,
page: metadata?.page,
limit: metadata?.limit,
})}
</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>
<AdminListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
watch={watch}
onClearAll={clearAllFilters}
/>
<div ref={tableSectionRef}>
<Table data-cy="admins-table">
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
{columns.map((column, index) => (
<TableHead
key={column}
colSpan={100}
data-cy={`admins-table-header-${index}`}
>
{column}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableCell className="text-start" colSpan={100} data-cy="admins-row-username">
{item?.username}
</TableCell>
<TableCell className="text-start" colSpan={100} data-cy="admins-row-status">
<Status status={item?.status ?? ""}>{item?.status}</Status>
</TableCell>
{isPending ? (
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
) : (
<TableBody ref={dataTbodyRef}>
<EmptyListWrapper
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">
<div className="flex items-center justify-start gap-x-3.5">
<button
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
data-cy={`admins-row-delete-${item?.id ?? index}`}
onClick={() => {
setCurrentUser(item);
setIsModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Admin </span>
<TrashIcon />
</button>
<button
data-tooltip-id="change-status"
data-tooltip-content="Change status"
data-tooltip-place="top"
className="hover:text-yellow-dark"
data-cy={`admins-row-manage-status-${item?.id ?? index}`}
onClick={() => {
setCurrentUser(item);
setIsChangeStatusModalOpen(true);
}}
>
<Tooltip
{..._TooltipDefaultParams({ id: "change-status" })}
/>
<span className="sr-only">Change Admin Status</span>
<UserSettingIcon />
</button>
<button
data-tooltip-id="edit"
data-tooltip-content="Edit"
data-tooltip-place="top"
className="hover:text-primary"
data-cy={`admins-row-edit-${item?.id ?? index}`}
onClick={() => router.push(`/admins/edit/${item.id}`)}
>
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
<span className="sr-only">Edit Admin </span>
<PencilSquareIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
</Table>
<TableCell className="text-start" colSpan={100} data-cy="admins-row-username">
{item?.username}
</TableCell>
<TableCell className="text-start" colSpan={100} data-cy="admins-row-status">
<Status status={item?.status ?? ""}>{item?.status}</Status>
</TableCell>
<TableCell className="text-start xl:pr-7.5" data-cy="admins-row-actions">
<div className="flex items-center justify-start gap-x-3.5">
<button
type="button"
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
data-cy={`admins-row-delete-${item?.id ?? index}`}
onClick={() => openDeleteModalForUser(item)}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Admin </span>
<TrashIcon />
</button>
<button
type="button"
data-tooltip-id="change-status"
data-tooltip-content="Change status"
data-tooltip-place="top"
className="hover:text-yellow-dark"
data-cy={`admins-row-manage-status-${item?.id ?? index}`}
onClick={() => openChangeStatusModalForUser(item)}
>
<Tooltip
{..._TooltipDefaultParams({ id: "change-status" })}
/>
<span className="sr-only">Change Admin Status</span>
<UserSettingIcon />
</button>
<button
type="button"
data-tooltip-id="edit"
data-tooltip-content="Edit"
data-tooltip-place="top"
className="hover:text-primary"
data-cy={`admins-row-edit-${item?.id ?? index}`}
onClick={() => navigateToEditAdmin(item.id)}
>
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
<span className="sr-only">Edit Admin </span>
<PencilSquareIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
)}
</Table>
</div>
<Modal
isOpen={isChangeStatusModalOpen}
onClose={() => setIsChangeStatusModalOpen(false)}
@@ -179,22 +190,6 @@ const AdminsTable = () => {
/>
</Modal>
<Modal
isOpen={isFilterModalOpen}
onClose={() => setIsFilterModalOpen(false)}
classNames="w-full xl:w-1/2"
showButtons={false}
>
<AdminListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
setIsFilterModalOpen={setIsFilterModalOpen}
watch={watch}
/>
</Modal>
{isModalOpen && (
<ConfirmationModal
isOpen={isModalOpen}
@@ -212,15 +207,9 @@ const AdminsTable = () => {
)}
<IsVisible condition={shouldShowPagination}>
<Pagination
currentPage={metadata?.page ? metadata.page - 1 : 0}
totalPages={metadata?.pages ?? 1}
onPageChange={(e) => {
setParams((currentParams) => ({
...currentParams,
offset: e.selected * (metadata?.limit ?? 10),
limit: metadata?.limit ?? 10,
}));
}}
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={handlePaginationChange}
/>
</IsVisible>
</ListWrapper>
@@ -8,15 +8,22 @@ import {
useGetAdminListQuery,
} from "@/hooks/queries";
import { IUser, TChangeAdminStatusCredentials } from "@/lib/api";
import { deleteEmptyKeys } from "@/utils/shared";
import { deleteEmptyKeys, getPaginatedRowNumber } from "@/utils/shared";
import gsap from "gsap";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useForm } from "react-hook-form";
export const useAdminsPresenter = () => {
const [isChangeStatusModalOpen, setIsChangeStatusModalOpen] = useState(false);
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const [currentUser, setCurrentUser] = useState<IUser | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [status, setStatus] = useState<string>(
@@ -46,8 +53,12 @@ export const useAdminsPresenter = () => {
const [params, setParams] = useState<Record<string, any>>({});
const pathName = usePathname();
const tableSectionRef = useRef<HTMLDivElement>(null);
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
useEffect(() => {
const newParams: Record<string, any> = {
const newParams: Record<string, unknown> = {
...apiDefaultParams,
offset: 0,
limit: 10,
@@ -58,12 +69,112 @@ export const useAdminsPresenter = () => {
setParams(newParams);
filterFormReset(newParams);
}, [searchParams, filterFormReset]);
const { data, isPending } = useGetAdminListQuery({
...apiDefaultParams,
...params,
});
const shouldShowPagination =
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
const usersList = data?.data?.users ?? [];
const metadata = data?.meta;
const pagination = useMemo(
() => ({
currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
totalPages: data?.meta?.pages ?? 1,
}),
[data?.meta?.page, data?.meta?.pages],
);
const getRowNumber = useCallback(
(index: number) =>
getPaginatedRowNumber({
index,
page: data?.meta?.page,
limit: data?.meta?.limit,
}),
[data?.meta?.limit, data?.meta?.page],
);
const handlePaginationChange = useCallback(
(e: { selected: number }) => {
const limit = data?.meta?.limit ?? 10;
setParams((currentParams) => ({
...currentParams,
offset: e.selected * limit,
limit,
}));
},
[data?.meta?.limit, setParams],
);
const navigateToEditAdmin = useCallback(
(userId: string) => {
router.push(`/admins/edit/${userId}`);
},
[router],
);
const openDeleteModalForUser = useCallback((user: IUser) => {
setCurrentUser(user);
setIsModalOpen(true);
}, []);
const openChangeStatusModalForUser = useCallback((user: IUser) => {
setCurrentUser(user);
setIsChangeStatusModalOpen(true);
}, []);
useLayoutEffect(() => {
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const ctx = gsap.context(() => {
if (reduced) return;
if (isPending) {
const tbody = skeletonTbodyRef.current;
if (!tbody) return;
const rows = tbody.querySelectorAll("tr");
gsap.fromTo(
rows,
{ opacity: 0.35, y: 14 },
{
opacity: 1,
y: 0,
duration: 0.42,
stagger: 0.032,
ease: "power2.out",
},
);
return;
}
const tbody = dataTbodyRef.current;
if (!tbody) return;
const rows = tbody.querySelectorAll("tr");
if (!rows.length) return;
gsap.fromTo(
rows,
{ opacity: 0, y: 18 },
{
opacity: 1,
y: 0,
duration: 0.46,
stagger: 0.05,
ease: "power3.out",
clearProps: "opacity,transform",
},
);
}, tableSectionRef);
return () => ctx.revert();
}, [isPending, data]);
const { mutate: deleteAdmin } = useDeleteAdmin(() => {
setIsModalOpen(false);
});
@@ -73,6 +184,7 @@ export const useAdminsPresenter = () => {
deleteAdmin(currentUser.id);
}
};
const { mutate: changeStatus } = useChangeAdminStatus({
id: currentUser?.id ?? "",
successCallback: () => {
@@ -80,15 +192,17 @@ export const useAdminsPresenter = () => {
},
});
const search = (data: any) => {
const newParams = { ...params, ...data, offset: 0 };
const search = (formData: any) => {
const newParams = { ...params, ...formData, offset: 0 };
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = new URLSearchParams(cleanedParams).toString();
router.push(`${pathName}?${queryString}`);
setIsFilterModalOpen(false);
};
const allUsers = data?.data?.users ?? [];
const metadata = data?.meta;
const clearAllFilters = useCallback(() => {
router.push(pathName);
}, [pathName, router]);
const columns = [
"row",
"full name",
@@ -97,8 +211,10 @@ export const useAdminsPresenter = () => {
"status",
"actions",
];
return {
allUsers,
allUsers: usersList,
usersList,
setFilterValue,
metadata,
columns,
@@ -107,7 +223,6 @@ export const useAdminsPresenter = () => {
isModalOpen,
setIsModalOpen,
isMutating,
router,
setCurrentUser,
pathName,
status,
@@ -119,13 +234,21 @@ export const useAdminsPresenter = () => {
register,
errors,
handleSubmit,
isFilterModalOpen,
setIsFilterModalOpen,
setParams,
filterRegister,
handleFilterSubmit,
search,
watch,
shouldShowPagination,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
getRowNumber,
pagination,
handlePaginationChange,
navigateToEditAdmin,
openDeleteModalForUser,
openChangeStatusModalForUser,
};
};
+79 -41
View File
@@ -1,4 +1,8 @@
"use client";
import InputGroup from "@/components/FormElements/InputGroup";
import { Button } from "@/components/ui-elements/button";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import {
FieldValues,
UseFormHandleSubmit,
@@ -6,64 +10,98 @@ import {
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
import { useMemo } from "react";
interface CmsListFiltersProps {
filterRegister: UseFormRegister<FieldValues>;
setIsFilterModalOpen: (isOpen: boolean) => void;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
search: (data: any) => void;
watch: UseFormWatch<FieldValues>;
setValue: UseFormSetValue<any>;
onClearAll: () => void;
}
function countActive(values: Record<string, unknown>): number {
let n = 0;
const q = values.q;
if (typeof q === "string" && q.trim()) n++;
const key = values.key;
if (typeof key === "string" && key.trim()) n++;
return n;
}
const CmsListFilters = ({
filterRegister,
setIsFilterModalOpen,
isMutating,
handleFilterSubmit,
search,
watch,
onClearAll,
}: CmsListFiltersProps) => {
return (
<form
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
onSubmit={handleFilterSubmit(search)}
>
<InputGroup
{...filterRegister("q")}
name="q"
label="search"
placeholder="Search"
type="search"
/>
<InputGroup
{...filterRegister("key")}
name="key"
label="key"
placeholder="Key"
type="text"
/>
const watched = watch();
const activeCount = useMemo(
() => countActive(watched as Record<string, unknown>),
[watched],
);
<div className="flex gap-4">
<button
type="submit"
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
>
{isMutating ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
) : (
<span>Search</span>
)}
</button>
<button
type="button"
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
onClick={() => setIsFilterModalOpen(false)}
>
Close
</button>
</div>
</form>
return (
<InlineListFiltersPanel
title="Filter CMS entries"
description="Search by keyword or exact key — inline, no popup."
activeFilterCount={activeCount}
expandAriaLabel="Expand CMS filters"
collapseAriaLabel="Collapse CMS filters"
panelToggleDataCy="cms-filter-panel-toggle"
>
<form
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
onSubmit={handleFilterSubmit(search)}
data-cy="cms-filter-form"
>
<div className="grid gap-4 sm:grid-cols-2">
<InputGroup
{...filterRegister("q")}
name="q"
label="Keyword search"
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 ConfirmationModal from "@/components/ui/ConfirmationModal";
import ListHeader from "@/components/ui/ListHeader";
import Modal from "@/components/ui/modal";
import ListWrapper from "@/components/ui/ListWrapper";
import {
Table,
TableBody,
@@ -18,7 +18,6 @@ import { getPaginatedRowNumber, unixToDate } from "@/utils/shared";
import { Tooltip } from "react-tooltip";
import CmsListFilters from "./CmsListFilters";
import useCmsTablePresenter from "./useCmsTablePresenter";
import ListWrapper from "@/components/ui/ListWrapper";
const CmsTable = () => {
const {
@@ -32,103 +31,106 @@ const CmsTable = () => {
onConfirmDelete,
router,
pathname,
isFilterModalOpen,
setIsFilterModalOpen,
filterRegister,
handleFilterSubmit,
search,
watch,
setFilterValue,
isMutating,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
} = useCmsTablePresenter();
return (
<ListWrapper>
<ListHeader onFilter={() => setIsFilterModalOpen(true)} />
<Table>
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
{columns.map((column) => (
<TableHead key={column} colSpan={100}>
{column}
</TableHead>
))}
</TableRow>
</TableHeader>
{isPending && <TableSkeleton column={columns.length} />}
<TableBody>
<EmptyListWrapper
list={data?.data.cms ?? []}
columns={columns}
emptyMessage="No Cms were found"
>
{data?.data.cms.map((item, index) => (
<TableRow
key={item?.id || index}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
<ListHeader hasFilter={false} />
<CmsListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
watch={watch}
onClearAll={clearAllFilters}
/>
<div ref={tableSectionRef}>
<Table>
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
{columns.map((column) => (
<TableHead key={column} colSpan={100}>
{column}
</TableHead>
))}
</TableRow>
</TableHeader>
{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}>
{getPaginatedRowNumber({
index,
page: data?.meta?.page,
limit: data?.meta?.limit,
})}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item.key}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{unixToDate({ unix: item.created_at, hasTime: true })}
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentCms(item);
setIsModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Marketing </span>
<TrashIcon />
</button>
<button
data-tooltip-id="edit"
data-tooltip-content="Edit"
data-tooltip-place="top"
className="hover:text-primary"
onClick={() => router.push(`${pathname}/edit/${item.id}`)}
>
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
<span className="sr-only">Edit Marketing </span>
<PencilSquareIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
</Table>
<Modal
isOpen={isFilterModalOpen}
onClose={() => setIsFilterModalOpen(false)}
classNames="w-full xl:w-1/2"
showButtons={false}
>
<CmsListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
setIsFilterModalOpen={setIsFilterModalOpen}
watch={watch}
/>
</Modal>
{data?.data.cms.map((item, index) => (
<TableRow
key={item?.id || index}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<TableCell className="text-start" colSpan={100}>
{getPaginatedRowNumber({
index,
page: data?.meta?.page,
limit: data?.meta?.limit,
})}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item.key}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{unixToDate({ unix: item.created_at, hasTime: true })}
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentCms(item);
setIsModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Marketing </span>
<TrashIcon />
</button>
<button
data-tooltip-id="edit"
data-tooltip-content="Edit"
data-tooltip-place="top"
className="hover:text-primary"
onClick={() =>
router.push(`${pathname}/edit/${item.id}`)
}
>
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
<span className="sr-only">Edit Marketing </span>
<PencilSquareIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
)}
</Table>
</div>
{isModalOpen && (
<ConfirmationModal
isOpen={isModalOpen}
@@ -1,15 +1,15 @@
"use client";
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { useDeleteCms, useGetCmsList } from "@/hooks/queries/useCmsQueries";
import { TCms } from "@/lib/api/types/TCms";
import { deleteEmptyKeys } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
const useCmsTablePresenter = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const [currentCms, setCurrentCms] = useState<TCms | null>(null);
const [params, setParams] = useState<Record<string, any>>({});
const columns = ["row", "key", "created at", "actions"];
@@ -26,6 +26,10 @@ const useCmsTablePresenter = () => {
setValue: setFilterValue,
} = useForm();
const tableSectionRef = useRef<HTMLDivElement>(null);
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
useEffect(() => {
const newParams: Record<string, any> = {};
for (const [key, value] of searchParams.entries()) {
@@ -37,19 +41,28 @@ const useCmsTablePresenter = () => {
const { isPending, data } = useGetCmsList(params);
useTableSectionRowAnimations(isPending, data, {
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
});
const { mutate: deleteCms } = useDeleteCms(() => {
setIsModalOpen(false);
setCurrentCms(null);
});
const search = (data: any) => {
const newParams = { ...params, ...data };
const search = (formData: any) => {
const newParams = { ...params, ...formData };
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = new URLSearchParams(cleanedParams).toString();
router.push(`${pathname}?${queryString}`);
setIsFilterModalOpen(false);
};
const clearAllFilters = useCallback(() => {
router.push(pathname);
}, [pathname, router]);
const onConfirmDelete = () => {
if (!currentCms?.id) return;
deleteCms(currentCms.id);
@@ -65,14 +78,16 @@ const useCmsTablePresenter = () => {
onConfirmDelete,
router,
pathname,
isFilterModalOpen,
setIsFilterModalOpen,
filterRegister,
handleFilterSubmit,
search,
watch,
setFilterValue,
isMutating,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
};
};
@@ -1,5 +1,9 @@
"use client";
import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select";
import { Button } from "@/components/ui-elements/button";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import { Currencies } from "@/constants/enums";
import {
FieldValues,
@@ -8,90 +12,130 @@ import {
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
import { useMemo } from "react";
interface CompanyListFiltersProps {
filterRegister: UseFormRegister<FieldValues>;
setIsFilterModalOpen: (isOpen: boolean) => void;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
search: (data: any) => void;
watch: UseFormWatch<FieldValues>;
setValue: UseFormSetValue<any>;
onClearAll: () => void;
}
function countActive(values: Record<string, unknown>): number {
let n = 0;
const keys = ["name", "email", "phone"] as const;
for (const k of keys) {
const v = values[k];
if (typeof v === "string" && v.trim()) n++;
}
const cur = values.currency;
if (cur !== undefined && cur !== null && cur !== "" && String(cur).trim()) {
n++;
}
const ec = values.employee_count;
if (ec !== undefined && ec !== null && ec !== "" && String(ec).trim()) {
n++;
}
return n;
}
const CompanyListFilters = ({
filterRegister,
setIsFilterModalOpen,
isMutating,
handleFilterSubmit,
search,
watch,
setValue,
onClearAll,
}: CompanyListFiltersProps) => {
return (
<form
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
onSubmit={handleFilterSubmit(search)}
>
<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"
/>
const watched = watch();
const activeCount = useMemo(
() => countActive(watched as Record<string, unknown>),
[watched],
);
<div className="flex gap-4 col-span-2">
<button
type="submit"
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
>
{isMutating ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
) : (
<span>Search</span>
)}
</button>
<button
type="button"
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
onClick={() => setIsFilterModalOpen(false)}
>
Close
</button>
</div>
</form>
return (
<InlineListFiltersPanel
title="Filter companies"
description="Filter by identity, currency, or headcount — inline, no popup."
activeFilterCount={activeCount}
expandAriaLabel="Expand company filters"
collapseAriaLabel="Collapse company filters"
panelToggleDataCy="companies-filter-panel-toggle"
>
<form
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
onSubmit={handleFilterSubmit(search)}
data-cy="companies-filter-form"
>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<InputGroup
{...filterRegister("name")}
name="name"
label="Name"
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 { Select } from "@/components/FormElements/select";
import { Button } from "@/components/ui-elements/button";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import {
FieldValues,
UseFormHandleSubmit,
@@ -7,77 +11,107 @@ import {
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
import { useMemo } from "react";
interface CompanyCategoryListFiltersProps {
filterRegister: UseFormRegister<FieldValues>;
setIsFilterModalOpen: (isOpen: boolean) => void;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
search: (data: any) => void;
watch: UseFormWatch<FieldValues>;
setValue: UseFormSetValue<any>;
onClearAll: () => void;
}
function countActive(values: Record<string, unknown>): number {
let n = 0;
const q = values.q;
if (typeof q === "string" && q.trim()) n++;
const pub = values.published;
if (pub !== undefined && pub !== null && pub !== "" && String(pub).trim()) {
n++;
}
return n;
}
const CompanyCategoryListFilters = ({
filterRegister,
setIsFilterModalOpen,
isMutating,
handleFilterSubmit,
search,
watch,
setValue,
onClearAll,
}: CompanyCategoryListFiltersProps) => {
return (
<form
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
data-cy="company-categories-filter-form"
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"
/>
const watched = watch();
const activeCount = useMemo(
() => countActive(watched as Record<string, unknown>),
[watched],
);
<div className="flex gap-4">
<button
type="submit"
data-cy="company-categories-filter-submit-button"
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
>
{isMutating ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
) : (
<span>Search</span>
)}
</button>
<button
type="button"
data-cy="company-categories-filter-close-button"
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
onClick={() => setIsFilterModalOpen(false)}
>
Close
</button>
</div>
</form>
return (
<InlineListFiltersPanel
title="Filter categories"
description="Keyword search and published state — inline, no popup."
activeFilterCount={activeCount}
expandAriaLabel="Expand category filters"
collapseAriaLabel="Collapse category filters"
panelToggleDataCy="company-categories-filter-panel-toggle"
>
<form
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
data-cy="company-categories-filter-form"
onSubmit={handleFilterSubmit(search)}
>
<div className="grid gap-4 sm:grid-cols-2">
<InputGroup
{...filterRegister("q")}
name="q"
label="Keyword 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>
<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 ListHeader from "@/components/ui/ListHeader";
import ListWrapper from "@/components/ui/ListWrapper";
import Modal from "@/components/ui/modal";
import Pagination from "@/components/ui/pagination";
import {
Table,
TableBody,
@@ -16,7 +16,6 @@ import {
TableRow,
} from "@/components/ui/table";
import TableSkeleton from "@/components/ui/TableSkeleton";
import Pagination from "@/components/ui/pagination";
import { _TooltipDefaultParams } from "@/constants/tooltip";
import { getPaginatedRowNumber, unixToDate } from "@/utils/shared";
import { startTransition } from "react";
@@ -37,8 +36,6 @@ const CompanyCategoriesTable = () => {
setCurrentCategory,
isModalOpen,
setIsModalOpen,
isFilterModalOpen,
setIsFilterModalOpen,
filterRegister,
handleFilterSubmit,
search,
@@ -47,133 +44,135 @@ const CompanyCategoriesTable = () => {
columns,
isMutating,
metadata,
setParams,
shouldShowPagination,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
} = useCompanyCategoriesPresenter();
return (
<ListWrapper data-cy="company-categories-page">
<ListHeader
onFilter={() => setIsFilterModalOpen(true)}
hasFilter={false}
createButtonText="Create Category"
dataCyPrefix="company-categories"
/>
<Table data-cy="company-categories-table">
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
<TableHead colSpan={100}>Row</TableHead>
<TableHead colSpan={100}>Title</TableHead>
<TableHead colSpan={100}>Description</TableHead>
<TableHead colSpan={100}>Created at</TableHead>
<TableHead colSpan={100}>Published</TableHead>
<TableHead colSpan={100}>Actions</TableHead>
</TableRow>
</TableHeader>
{isLoading && <TableSkeleton column={columns.length} />}
<TableBody>
<EmptyListWrapper
list={optimisticCategories}
columns={columns}
emptyMessage="No categories were found"
isLoading={isLoading}
>
{optimisticCategories.map((category, index) => (
<TableRow
key={category.id}
data-cy={`company-categories-table-row-${category.id}`}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
<CompanyCategoryListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
watch={watch}
onClearAll={clearAllFilters}
/>
<div ref={tableSectionRef}>
<Table data-cy="company-categories-table">
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
<TableHead colSpan={100}>Row</TableHead>
<TableHead colSpan={100}>Title</TableHead>
<TableHead colSpan={100}>Description</TableHead>
<TableHead colSpan={100}>Created at</TableHead>
<TableHead colSpan={100}>Published</TableHead>
<TableHead colSpan={100}>Actions</TableHead>
</TableRow>
</TableHeader>
{isLoading ? (
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
) : (
<TableBody ref={dataTbodyRef}>
<EmptyListWrapper
list={optimisticCategories}
columns={columns}
emptyMessage="No categories were found"
isLoading={false}
>
<TableCell colSpan={100}>
{getPaginatedRowNumber({
index,
page: metadata?.page,
limit: metadata?.limit,
})}
</TableCell>
<TableCell colSpan={100}>{category.name}</TableCell>
<TableCell colSpan={100}>{category.description}</TableCell>
<TableCell colSpan={100}>
{unixToDate({ unix: category.created_at, hasTime: true })}
</TableCell>
<TableCell colSpan={100}>
{/* Published: flip UI immediately via useOptimistic; if togglePublished rejects, React rolls the optimistic row back. */}
<Switch
withIcon
dataCy={`company-categories-published-toggle-${category.id}`}
onToggle={() => {}}
onChange={() => {
const nextPublished = !category.published;
startTransition(async () => {
addOptimisticPublished({
id: category.id,
published: nextPublished,
});
try {
await togglePublished(category.id);
} catch {
// useOptimistic automatically reverts when the transition ends
}
});
}}
checked={category.published}
/>
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
type="button"
data-cy={`company-categories-row-delete-${category.id}`}
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentCategory(category);
setIsModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Admin </span>
<TrashIcon />
</button>
<button
type="button"
data-cy={`company-categories-row-edit-${category.id}`}
data-tooltip-id="edit"
data-tooltip-content="Edit"
data-tooltip-place="top"
className="hover:text-primary"
onClick={() =>
router.push(`${pathName}/edit/${category.id}`)
}
>
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
<span className="sr-only">Edit Category</span>
<PencilSquareIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
</Table>
<Modal
isOpen={isFilterModalOpen}
onClose={() => setIsFilterModalOpen(false)}
classNames="w-full xl:w-1/2"
showButtons={false}
>
<CompanyCategoryListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
setIsFilterModalOpen={setIsFilterModalOpen}
watch={watch}
/>
</Modal>
{optimisticCategories.map((category, index) => (
<TableRow
key={category.id}
data-cy={`company-categories-table-row-${category.id}`}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<TableCell colSpan={100}>
{getPaginatedRowNumber({
index,
page: metadata?.page,
limit: metadata?.limit,
})}
</TableCell>
<TableCell colSpan={100}>{category.name}</TableCell>
<TableCell colSpan={100}>{category.description}</TableCell>
<TableCell colSpan={100}>
{unixToDate({ unix: category.created_at, hasTime: true })}
</TableCell>
<TableCell colSpan={100}>
<Switch
withIcon
dataCy={`company-categories-published-toggle-${category.id}`}
onToggle={() => {}}
onChange={() => {
const nextPublished = !category.published;
startTransition(async () => {
addOptimisticPublished({
id: category.id,
published: nextPublished,
});
try {
await togglePublished(category.id);
} catch {
// useOptimistic automatically reverts when the transition ends
}
});
}}
checked={category.published}
/>
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
type="button"
data-cy={`company-categories-row-delete-${category.id}`}
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentCategory(category);
setIsModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Admin </span>
<TrashIcon />
</button>
<button
type="button"
data-cy={`company-categories-row-edit-${category.id}`}
data-tooltip-id="edit"
data-tooltip-content="Edit"
data-tooltip-place="top"
className="hover:text-primary"
onClick={() =>
router.push(`${pathName}/edit/${category.id}`)
}
>
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
<span className="sr-only">Edit Category</span>
<PencilSquareIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
)}
</Table>
</div>
{isModalOpen && (
<ConfirmationModal
isOpen={isModalOpen}
@@ -191,15 +190,9 @@ const CompanyCategoriesTable = () => {
)}
<IsVisible condition={shouldShowPagination}>
<Pagination
currentPage={metadata?.page ? metadata.page - 1 : 0}
totalPages={metadata?.pages ?? 1}
onPageChange={(e) => {
setParams((currentParams) => ({
...currentParams,
offset: e.selected * (metadata?.limit ?? 10),
limit: metadata?.limit ?? 10,
}));
}}
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={handlePaginationChange}
/>
</IsVisible>
</ListWrapper>
@@ -5,18 +5,18 @@ import {
useDeleteCompanyCategoryQuery,
useToggleCompanyCategoryPublished,
} from "@/hooks/queries";
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { TCompanyCategory } from "@/lib/api";
import { deleteEmptyKeys } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useOptimistic, useState } from "react";
import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState } from "react";
import { useForm } from "react-hook-form";
type TogglePublishedOptimistic = Pick<TCompanyCategory, "id" | "published">;
const useCompanyCategoriesPresenter = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const [currentCategory, setCurrentCategory] = useState<TCompanyCategory>();
const [params, setParams] = useState<Record<string, any>>({});
const router = useRouter();
@@ -32,6 +32,10 @@ const useCompanyCategoriesPresenter = () => {
setValue: setFilterValue,
} = useForm();
const tableSectionRef = useRef<HTMLDivElement>(null);
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
useEffect(() => {
const newParams: Record<string, any> = {
...apiDefaultParams,
@@ -46,11 +50,39 @@ const useCompanyCategoriesPresenter = () => {
}, [searchParams, filterFormReset]);
const { data, isLoading, isError } = useCompanyCategoriesQuery(params);
useTableSectionRowAnimations(isLoading, data, {
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
});
const categories = data?.data.categories;
const metadata = data?.meta;
const shouldShowPagination =
!isLoading &&
(data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
const pagination = useMemo(
() => ({
currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
totalPages: data?.meta?.pages ?? 1,
}),
[data?.meta?.page, data?.meta?.pages],
);
const handlePaginationChange = useCallback(
(e: { selected: number }) => {
const limit = data?.meta?.limit ?? 10;
setParams((currentParams) => ({
...currentParams,
offset: e.selected * limit,
limit,
}));
},
[data?.meta?.limit],
);
const [optimisticCategories, addOptimisticPublished] = useOptimistic<
TCompanyCategory[],
TogglePublishedOptimistic
@@ -63,14 +95,17 @@ const useCompanyCategoriesPresenter = () => {
const { mutateAsync: togglePublished } =
useToggleCompanyCategoryPublished();
const search = (data: any) => {
const newParams = { ...params, ...data, offset: 0 };
const search = (formData: any) => {
const newParams = { ...params, ...formData, offset: 0 };
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = new URLSearchParams(cleanedParams).toString();
router.push(`${pathName}?${queryString}`);
setIsFilterModalOpen(false);
};
const clearAllFilters = useCallback(() => {
router.push(pathName);
}, [pathName, router]);
const onConfirmDelete = () => {
if (!currentCategory) return;
deleteCategory(currentCategory?.id);
@@ -100,8 +135,6 @@ const useCompanyCategoriesPresenter = () => {
togglePublished,
setCurrentCategory,
onConfirmDelete,
isFilterModalOpen,
setIsFilterModalOpen,
filterRegister,
handleFilterSubmit,
search,
@@ -110,6 +143,12 @@ const useCompanyCategoriesPresenter = () => {
isMutating,
metadata,
shouldShowPagination,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
};
};
+139 -148
View File
@@ -10,7 +10,6 @@ import ConfirmationModal from "@/components/ui/ConfirmationModal";
import IsVisible from "@/components/ui/IsVisible";
import ListHeader from "@/components/ui/ListHeader";
import ListWrapper from "@/components/ui/ListWrapper";
import Modal from "@/components/ui/modal";
import Pagination from "@/components/ui/pagination";
import {
Table,
@@ -39,8 +38,6 @@ const CompaniesTable = () => {
setCurrentCompany,
onConfirmDelete,
modalConfig,
isFilterModalOpen,
setIsFilterModalOpen,
filterRegister,
handleFilterSubmit,
search,
@@ -48,151 +45,151 @@ const CompaniesTable = () => {
setFilterValue,
isMutating,
metadata,
setParams,
shouldShowPagination,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
} = useCompanyListPresenter();
const companies = allCompanies.filter(
(c): c is NonNullable<typeof c> => c != null,
);
return (
<ListWrapper>
<ListHeader
onFilter={() => setIsFilterModalOpen(true)}
createButtonText="Create Company"
<ListHeader hasFilter={false} createButtonText="Create Company" />
<CompanyListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
watch={watch}
onClearAll={clearAllFilters}
/>
<Table>
<TableHeader>
<TableRow>
{columns.map((columns) => (
<TableHead
colSpan={100}
className="text-start uppercase"
key={columns}
<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={companies}
columns={columns}
emptyMessage="No companies were found"
isLoading={false}
>
{columns}
</TableHead>
))}
</TableRow>
</TableHeader>
{isPending && <TableSkeleton column={columns.length} />}
<TableBody>
<EmptyListWrapper
list={companies}
columns={columns}
emptyMessage="No companies were found"
isLoading={isPending}
>
{companies.map((company, index) => (
<TableRow
key={company.id}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<TableCell className="text-start" colSpan={100}>
{getPaginatedRowNumber({
index,
page: metadata?.page,
limit: metadata?.limit,
})}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.name}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.email}
</TableCell>
<TableCell className="w-full text-start" colSpan={100}>
{formatPhoneNumber(company.phone)}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.address.country}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.address.state}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.language}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.currency}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.employee_count}
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentCompany(company);
setIsModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Company </span>
<TrashIcon />
</button>
<button
data-tooltip-id="edit"
data-tooltip-content="Edit"
data-tooltip-place="top"
className="hover:text-primary"
onClick={() =>
router.push(`${pathName}/edit/${company.id}`)
}
>
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
<span className="sr-only">Edit Company </span>
<PencilSquareIcon />
</button>
<button
data-tooltip-id="details"
data-tooltip-content="Details"
data-tooltip-place="top"
onClick={() => {
router.push(`${pathName}/details/${company.id}`);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "details" })} />
<EyeIcon />
</button>
<button
data-tooltip-id="feedback"
data-tooltip-content="Feedback"
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"
onClick={() => {
router.push(`${pathName}/feedback/${company.id}`);
}}
>
<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>
{companies.map((company, index) => (
<TableRow
key={company.id}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<TableCell className="text-start" colSpan={100}>
{getPaginatedRowNumber({
index,
page: metadata?.page,
limit: metadata?.limit,
})}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.name}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.email}
</TableCell>
<TableCell className="w-full text-start" colSpan={100}>
{formatPhoneNumber(company.phone)}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.address.country}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.address.state}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.language}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.currency}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.employee_count}
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentCompany(company);
setIsModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Company </span>
<TrashIcon />
</button>
<button
data-tooltip-id="edit"
data-tooltip-content="Edit"
data-tooltip-place="top"
className="hover:text-primary"
onClick={() =>
router.push(`${pathName}/edit/${company.id}`)
}
>
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
<span className="sr-only">Edit Company </span>
<PencilSquareIcon />
</button>
<button
data-tooltip-id="details"
data-tooltip-content="Details"
data-tooltip-place="top"
onClick={() => {
router.push(`${pathName}/details/${company.id}`);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "details" })} />
<EyeIcon />
</button>
<button
data-tooltip-id="feedback"
data-tooltip-content="Feedback"
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"
onClick={() => {
router.push(`${pathName}/feedback/${company.id}`);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "feedback" })} />
<ExclamationIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
)}
</Table>
</div>
{isModalOpen && (
<ConfirmationModal
isOpen={isModalOpen}
@@ -210,15 +207,9 @@ const CompaniesTable = () => {
)}
<IsVisible condition={shouldShowPagination}>
<Pagination
currentPage={metadata?.page ? metadata.page - 1 : 0}
totalPages={metadata?.pages ?? 1}
onPageChange={(e) => {
setParams((currentParams) => ({
...currentParams,
offset: e.selected * (metadata?.limit ?? 10),
limit: metadata?.limit ?? 10,
}));
}}
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={handlePaginationChange}
/>
</IsVisible>
</ListWrapper>
@@ -5,16 +5,16 @@ import {
useCompaniesQuery,
useDeleteCompanyQuery,
} from "@/hooks/queries";
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { TCompany } from "@/lib/api";
import { deleteEmptyKeys } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
export const useCompanyListPresenter = () => {
const [currentCompany, setCurrentCompany] = useState<TCompany | null>(null);
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const [modalConfig, setModalConfig] = useState<
Partial<ConfirmationModalProps>
>({});
@@ -33,6 +33,10 @@ export const useCompanyListPresenter = () => {
setValue: setFilterValue,
} = useForm();
const tableSectionRef = useRef<HTMLDivElement>(null);
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
const columns = [
"row",
"name",
@@ -61,6 +65,12 @@ export const useCompanyListPresenter = () => {
const { data, error, isPending, isError } = useCompaniesQuery(params);
useTableSectionRowAnimations(isPending, data, {
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
});
const shouldShowPagination =
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
@@ -68,13 +78,37 @@ export const useCompanyListPresenter = () => {
setIsModalOpen(false),
);
const search = (data: any) => {
const newParams = { ...params, ...data, offset: 0 };
const search = (formData: any) => {
const newParams = { ...params, ...formData, offset: 0 };
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = new URLSearchParams(cleanedParams).toString();
router.push(`${pathName}?${queryString}`);
setIsFilterModalOpen(false);
};
const clearAllFilters = useCallback(() => {
router.push(pathName);
}, [pathName, router]);
const pagination = useMemo(
() => ({
currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
totalPages: data?.meta?.pages ?? 1,
}),
[data?.meta?.page, data?.meta?.pages],
);
const handlePaginationChange = useCallback(
(e: { selected: number }) => {
const limit = data?.meta?.limit ?? 10;
setParams((currentParams) => ({
...currentParams,
offset: e.selected * limit,
limit,
}));
},
[data?.meta?.limit],
);
const onConfirmDelete = () => {
if (currentCompany) {
deleteCompany(currentCompany.id);
@@ -98,8 +132,6 @@ export const useCompanyListPresenter = () => {
router,
isPending,
isError,
isFilterModalOpen,
setIsFilterModalOpen,
filterRegister,
handleFilterSubmit,
search,
@@ -108,5 +140,11 @@ export const useCompanyListPresenter = () => {
isMutating,
setParams,
shouldShowPagination,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
};
};
@@ -0,0 +1,113 @@
"use client";
import InputGroup from "@/components/FormElements/InputGroup";
import { Button } from "@/components/ui-elements/button";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import {
FieldValues,
UseFormHandleSubmit,
UseFormRegister,
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
import { useMemo } from "react";
interface ContactUsListFiltersProps {
filterRegister: UseFormRegister<FieldValues>;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
search: (data: any) => void;
watch: UseFormWatch<FieldValues>;
setValue: UseFormSetValue<any>;
onClearAll: () => void;
}
function countActive(values: Record<string, unknown>): number {
let n = 0;
const q = values.q;
if (typeof q === "string" && q.trim()) n++;
const status = values.status;
if (
status !== undefined &&
status !== null &&
status !== "" &&
String(status).trim()
) {
n++;
}
return n;
}
export default function ContactUsListFilters({
filterRegister,
isMutating,
handleFilterSubmit,
search,
watch,
onClearAll,
}: ContactUsListFiltersProps) {
const watched = watch();
const activeCount = useMemo(
() => countActive(watched as Record<string, unknown>),
[watched],
);
return (
<InlineListFiltersPanel
title="Filter contacts"
description="Search submissions or narrow by status — inline, no popup."
activeFilterCount={activeCount}
expandAriaLabel="Expand contact filters"
collapseAriaLabel="Collapse contact filters"
panelToggleDataCy="contact-us-filter-panel-toggle"
>
<form
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
onSubmit={handleFilterSubmit(search)}
data-cy="contact-us-filter-form"
>
<div className="grid gap-4 sm:grid-cols-2">
<InputGroup
{...filterRegister("q")}
name="q"
label="Keyword search"
placeholder="Name, email…"
type="search"
dataCy="contact-us-filter-q-input"
/>
<InputGroup
{...filterRegister("status")}
name="status"
label="Status"
placeholder="Status"
type="text"
dataCy="contact-us-filter-status-input"
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Button
type="submit"
size="small"
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
data-cy="contact-us-filter-submit-button"
label={
isMutating ? (
<span className="inline-flex h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<span>Apply filters</span>
)
}
/>
<button
type="button"
data-cy="contact-us-filter-reset-button"
onClick={onClearAll}
className="min-h-[52px] rounded-2xl border border-stroke/70 bg-white/80 px-6 text-sm font-semibold text-dark transition hover:border-primary/40 hover:text-primary dark:border-dark-3 dark:bg-dark-3/60 dark:text-white dark:hover:bg-white/[0.06]"
>
Reset all
</button>
</div>
</form>
</InlineListFiltersPanel>
);
}
+103 -81
View File
@@ -3,6 +3,7 @@ import { TrashIcon } from "@/assets/icons";
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
import ConfirmationModal from "@/components/ui/ConfirmationModal";
import IsVisible from "@/components/ui/IsVisible";
import ListHeader from "@/components/ui/ListHeader";
import ListWrapper from "@/components/ui/ListWrapper";
import Pagination from "@/components/ui/pagination";
import {
@@ -17,6 +18,7 @@ import TableSkeleton from "@/components/ui/TableSkeleton";
import { _TooltipDefaultParams } from "@/constants/tooltip";
import { getPaginatedRowNumber, unixToDate } from "@/utils/shared";
import { Tooltip } from "react-tooltip";
import ContactUsListFilters from "./ContactUsListFilters";
import { useContactUsListPresenter } from "./useContactUsListPresenter";
const ContactUsTable = () => {
@@ -30,83 +32,109 @@ const ContactUsTable = () => {
isModalOpen,
currentContact,
metadata,
setParams,
shouldShowPagination,
filterRegister,
handleFilterSubmit,
search,
watch,
setFilterValue,
isMutating,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
} = useContactUsListPresenter();
return (
<ListWrapper>
<Table>
<TableHeader>
<TableRow>
{columns.map((columns) => (
<TableHead
colSpan={100}
className="text-start uppercase"
key={columns}
<ListHeader hasFilter={false} hasCreate={false} />
<ContactUsListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
watch={watch}
onClearAll={clearAllFilters}
/>
<div ref={tableSectionRef}>
<Table>
<TableHeader>
<TableRow>
{columns.map((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}
</TableHead>
))}
</TableRow>
</TableHeader>
{isPending && <TableSkeleton column={columns.length} />}
<TableBody>
<EmptyListWrapper
list={contacts ?? []}
columns={columns}
emptyMessage="No contact us is submitted"
isLoading={isPending}
>
{contacts?.map((item, index) => (
<TableRow
key={item?.id ?? index}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<TableCell className="text-start" colSpan={100}>
{getPaginatedRowNumber({
index,
page: metadata?.page,
limit: metadata?.limit,
})}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item?.full_name}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item?.email}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item?.status}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item?.phone}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{unixToDate({ unix: item?.created_at })}
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentContact(item);
setIsModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Contact Us </span>
<TrashIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
</Table>
{contacts?.map((item, index) => (
<TableRow
key={item?.id ?? index}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<TableCell className="text-start" colSpan={100}>
{getPaginatedRowNumber({
index,
page: metadata?.page,
limit: metadata?.limit,
})}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item?.full_name}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item?.email}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item?.status}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item?.phone}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{unixToDate({ unix: item?.created_at })}
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentContact(item);
setIsModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Contact Us </span>
<TrashIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
)}
</Table>
</div>
{isModalOpen && (
<ConfirmationModal
isOpen={isModalOpen}
@@ -124,15 +152,9 @@ const ContactUsTable = () => {
)}
<IsVisible condition={shouldShowPagination}>
<Pagination
currentPage={metadata?.page ? metadata.page - 1 : 0}
totalPages={metadata?.pages ?? 1}
onPageChange={(e) => {
setParams((currentParams) => ({
...currentParams,
offset: e.selected * (metadata?.limit ?? 10),
limit: metadata?.limit ?? 10,
}));
}}
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={handlePaginationChange}
/>
</IsVisible>
</ListWrapper>
@@ -4,9 +4,13 @@ import {
useDeleteContactUs,
useReadAllContactUs,
} from "@/hooks/queries/useContactUsQuery";
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { TContact } from "@/lib/api/types/TContacts";
import { deleteEmptyKeys } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
export const useContactUsListPresenter = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -24,6 +28,19 @@ export const useContactUsListPresenter = () => {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const isMutating = useIsMutating();
const {
register: filterRegister,
handleSubmit: handleFilterSubmit,
reset: filterFormReset,
watch,
setValue: setFilterValue,
} = useForm();
const tableSectionRef = useRef<HTMLDivElement>(null);
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
useEffect(() => {
const newParams: Record<string, unknown> = {
@@ -35,9 +52,17 @@ export const useContactUsListPresenter = () => {
newParams[key] = value;
}
setParams(newParams);
}, [searchParams]);
filterFormReset(newParams as any);
}, [searchParams, filterFormReset]);
const { data, isPending } = useReadAllContactUs(params);
useTableSectionRowAnimations(isPending, data, {
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
});
const { mutate: deleteContactUS } = useDeleteContactUs(() =>
setIsModalOpen(false),
);
@@ -45,6 +70,39 @@ export const useContactUsListPresenter = () => {
const shouldShowPagination =
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
const pagination = useMemo(
() => ({
currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
totalPages: data?.meta?.pages ?? 1,
}),
[data?.meta?.page, data?.meta?.pages],
);
const handlePaginationChange = useCallback(
(e: { selected: number }) => {
const limit = data?.meta?.limit ?? 10;
setParams((currentParams) => ({
...currentParams,
offset: e.selected * limit,
limit,
}));
},
[data?.meta?.limit],
);
const search = (formData: any) => {
const newParams = { ...params, ...formData, offset: 0 };
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = new URLSearchParams(
cleanedParams as Record<string, string>,
).toString();
router.push(`${pathname}?${queryString}`);
};
const clearAllFilters = useCallback(() => {
router.push(pathname);
}, [pathname, router]);
const onConfirmDelete = () => {
if (!currentContact) return;
deleteContactUS(currentContact?.id);
@@ -63,5 +121,17 @@ export const useContactUsListPresenter = () => {
setCurrentContact,
isModalOpen,
currentContact,
filterRegister,
handleFilterSubmit,
search,
watch,
setFilterValue,
isMutating,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
};
};
@@ -1,4 +1,8 @@
"use client";
import { Select } from "@/components/FormElements/select";
import { Button } from "@/components/ui-elements/button";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import { AdminRoles, CustomerStatus, CustomerType } from "@/constants/enums";
import { getEnumAsArray } from "@/utils/shared";
import {
@@ -8,82 +12,113 @@ import {
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
import { useMemo } from "react";
interface CustomerListFiltersProps {
filterRegister: UseFormRegister<FieldValues>;
setIsFilterModalOpen: (isOpen: boolean) => void;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
search: (data: any) => void;
watch: UseFormWatch<FieldValues>;
setValue: UseFormSetValue<any>;
onClearAll: () => void;
}
function countActive(values: Record<string, unknown>): number {
let n = 0;
for (const key of ["status", "role", "type"] as const) {
const v = values[key];
if (v !== undefined && v !== null && v !== "" && String(v).trim()) n++;
}
return n;
}
const CustomerListFilters = ({
filterRegister,
setIsFilterModalOpen,
isMutating,
handleFilterSubmit,
search,
watch,
setValue,
onClearAll,
}: CustomerListFiltersProps) => {
return (
<form
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
onSubmit={handleFilterSubmit(search)}
>
<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)}
/>
const watched = watch();
const activeCount = useMemo(
() => countActive(watched as Record<string, unknown>),
[watched],
);
<div className="flex gap-4 col-span-2">
<button
type="submit"
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
>
{isMutating ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
) : (
<span>Search</span>
)}
</button>
<button
type="button"
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
onClick={() => setIsFilterModalOpen(false)}
>
Close
</button>
</div>
</form>
return (
<InlineListFiltersPanel
title="Filter customers"
description="Slice by status, role, or account type — inline, no popup."
activeFilterCount={activeCount}
expandAriaLabel="Expand customer filters"
collapseAriaLabel="Collapse customer filters"
panelToggleDataCy="customers-filter-panel-toggle"
>
<form
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
onSubmit={handleFilterSubmit(search)}
data-cy="customers-filter-form"
>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<Select
{...filterRegister("status")}
items={getEnumAsArray(CustomerStatus)}
label="Status"
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";
import { ExclamationIcon, PencilSquareIcon, TrashIcon } from "@/assets/icons";
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
import { Building } from "@/components/Layouts/sidebar/icons";
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
import ConfirmationModal from "@/components/ui/ConfirmationModal";
import IsVisible from "@/components/ui/IsVisible";
import ListHeader from "@/components/ui/ListHeader";
import ListWrapper from "@/components/ui/ListWrapper";
import Modal from "@/components/ui/modal";
import Pagination from "@/components/ui/pagination";
import Status from "@/components/ui/Status";
@@ -24,7 +25,6 @@ import { Tooltip } from "react-tooltip";
import AssignToCompanyModalContent from "./AssignToCompanyModalContent";
import CustomerListFilters from "./CustomerListFilters";
import useCustomerListPresenter from "./useCustomerListPresenter";
import ListWrapper from "@/components/ui/ListWrapper";
const CustomersTable = () => {
const {
@@ -44,179 +44,171 @@ const CustomersTable = () => {
setCurrentCustomer,
isAssignCompanyModalOpen,
setIsAssignCompanyModalOpen,
isFilterModalOpen,
setIsFilterModalOpen,
filterRegister,
handleFilterSubmit,
search,
watch,
setFilterValue,
isMutating,
setParams,
assignCompanyCompaniesQuery,
shouldShowPagination,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
} = useCustomerListPresenter();
return (
<ListWrapper>
<ListHeader
onFilter={() => setIsFilterModalOpen(true)}
createButtonText="Create Customer"
<ListHeader hasFilter={false} createButtonText="Create Customer" />
<CustomerListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
watch={watch}
onClearAll={clearAllFilters}
/>
<Table>
<TableHeader>
<TableRow>
{columns.map((columns) => (
<TableHead
colSpan={100}
className="text-start font-extrabold uppercase"
key={columns}
<div ref={tableSectionRef}>
<Table>
<TableHeader>
<TableRow>
{columns.map((col) => (
<TableHead
colSpan={100}
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}
</TableHead>
))}
</TableRow>
</TableHeader>
{isPending && <TableSkeleton column={columns.length} />}
<TableBody>
<EmptyListWrapper
list={allCustomers ?? []}
columns={columns}
emptyMessage="No Customers were found"
isLoading={isPending}
>
{allCustomers?.map((customer, index) => (
<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>
{allCustomers?.map((customer, index) => (
<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">
{customer?.type}
</TableCell>
<TableCell colSpan={100}>
<Status status={customer?.status}>{customer?.status}</Status>
</TableCell>
<TableCell colSpan={100}>
{customer?.companies?.length ? (
customer?.companies.map((c) => (
<span key={c.id}>{c.name}, </span>
))
) : (
<span>None</span>
)}
</TableCell>
<TableCell colSpan={100} className="uppercase">
{customer?.role ?? "-"}
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentCustomer(customer);
setIsModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Company </span>
<TrashIcon />
</button>
<button
data-tooltip-id="edit"
data-tooltip-content="Edit"
data-tooltip-place="top"
className="hover:text-primary"
onClick={() =>
router.push(`${pathName}/edit/${customer.id}`)
}
>
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
<span className="sr-only">Edit Company </span>
<PencilSquareIcon />
</button>
<button
data-tooltip-id="assign-company"
data-tooltip-content="Assign to company"
data-tooltip-place="top"
className="hover:text-green-dark"
onClick={() => {
setCurrentCustomer(customer);
setIsAssignCompanyModalOpen(true);
setSelectedCompanies({
company_ids: [customer?.companies?.[0]?.id ?? ""],
});
}}
>
<Tooltip
{..._TooltipDefaultParams({ id: "assign-company" })}
/>
<span className="sr-only">Assign To Company </span>
<Building />
</button>
<button
data-tooltip-id="feedback"
data-tooltip-content="Feedback"
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"
onClick={() => {
router.push(`${pathName}/feedback/${customer.id}`);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "feedback" })} />
<ExclamationIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
</Table>
<TableCell colSpan={100} className="uppercase">
{customer?.type}
</TableCell>
<TableCell colSpan={100}>
<Status status={customer?.status}>{customer?.status}</Status>
</TableCell>
<TableCell colSpan={100}>
{customer?.companies?.length ? (
customer?.companies.map((c) => (
<span key={c.id}>{c.name}, </span>
))
) : (
<span>None</span>
)}
</TableCell>
<TableCell colSpan={100} className="uppercase">
{customer?.role ?? "-"}
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentCustomer(customer);
setIsModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Company </span>
<TrashIcon />
</button>
<button
data-tooltip-id="edit"
data-tooltip-content="Edit"
data-tooltip-place="top"
className="hover:text-primary"
onClick={() =>
router.push(`${pathName}/edit/${customer.id}`)
}
>
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
<span className="sr-only">Edit Company </span>
<PencilSquareIcon />
</button>
<button
data-tooltip-id="assign-company"
data-tooltip-content="Assign to company"
data-tooltip-place="top"
className="hover:text-green-dark"
onClick={() => {
setCurrentCustomer(customer);
setIsAssignCompanyModalOpen(true);
setSelectedCompanies({
company_ids: [customer?.companies?.[0]?.id ?? ""],
});
}}
>
<Tooltip
{..._TooltipDefaultParams({ id: "assign-company" })}
/>
<span className="sr-only">Assign To Company </span>
<Building />
</button>
<button
data-tooltip-id="feedback"
data-tooltip-content="Feedback"
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"
onClick={() => {
router.push(`${pathName}/feedback/${customer.id}`);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "feedback" })} />
<ExclamationIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
)}
</Table>
</div>
<IsVisible condition={shouldShowPagination}>
<Pagination
currentPage={metadata?.page ? metadata?.page - 1 : 0}
totalPages={metadata?.pages ?? 1}
onPageChange={(e) => {
setParams((currentParams) => ({
...currentParams,
offset: e.selected * (metadata?.limit ?? 10),
limit: metadata?.limit ?? 10,
}));
}}
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={handlePaginationChange}
/>
</IsVisible>
<Modal
isOpen={isFilterModalOpen}
onClose={() => setIsFilterModalOpen(false)}
classNames="w-full xl:w-1/2"
showButtons={false}
>
<CustomerListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
setIsFilterModalOpen={setIsFilterModalOpen}
watch={watch}
/>
</Modal>
<div className="w-1/12">
<Modal
classNames="w-full xl:w-1/2"
classNames="w-full xl:w-1/2"
onConfirm={() => {
if (!selectedCompanies || !currentCustomer) {
toast.error("Invalid credentials");
@@ -6,18 +6,18 @@ import {
useCustomersQuery,
useDeleteCustomerQuery,
} from "@/hooks/queries";
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { TAssignCustomerToCompanyCredentials, TCustomer } from "@/lib/api";
import { apiDefaultParams } from "@/constants/Api_Params";
import { deleteEmptyKeys } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
const useCustomerListPresenter = () => {
const [isAssignCompanyModalOpen, setIsAssignCompanyModalOpen] =
useState(false);
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const [selectedCompanies, setSelectedCompanies] = useState<
TAssignCustomerToCompanyCredentials | undefined
>();
@@ -27,6 +27,7 @@ const useCustomerListPresenter = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const pathName = usePathname();
const searchParams = useSearchParams();
const [params, setParams] = useState<Record<string, any>>({
...apiDefaultParams,
});
@@ -41,7 +42,31 @@ const useCustomerListPresenter = () => {
setValue: setFilterValue,
} = useForm();
const tableSectionRef = useRef<HTMLDivElement>(null);
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
useEffect(() => {
const newParams: Record<string, any> = {
...apiDefaultParams,
offset: 0,
limit: 10,
};
for (const [key, value] of searchParams.entries()) {
newParams[key] = value;
}
setParams(newParams);
filterFormReset(newParams);
}, [searchParams, filterFormReset]);
const { data, isPending } = useCustomersQuery(params);
useTableSectionRowAnimations(isPending, data, {
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
});
const assignCompanyCompaniesQuery = useCompanyFullList({
enabled: isAssignCompanyModalOpen,
});
@@ -55,15 +80,17 @@ const useCustomerListPresenter = () => {
},
);
const search = (data: any) => {
const newParams = { ...params, ...data, offset: 0 };
const search = (formData: any) => {
const newParams = { ...params, ...formData, offset: 0 };
const cleanedParams = deleteEmptyKeys(newParams);
setParams(cleanedParams);
const queryString = new URLSearchParams(cleanedParams).toString();
router.push(`${pathName}?${queryString}`);
setIsFilterModalOpen(false);
};
const clearAllFilters = useCallback(() => {
router.push(pathName);
}, [pathName, router]);
const columns = [
"row",
"full name",
@@ -85,6 +112,26 @@ const useCustomerListPresenter = () => {
const shouldShowPagination =
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
const pagination = useMemo(
() => ({
currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
totalPages: data?.meta?.pages ?? 1,
}),
[data?.meta?.page, data?.meta?.pages],
);
const handlePaginationChange = useCallback(
(e: { selected: number }) => {
const limit = data?.meta?.limit ?? 10;
setParams((currentParams) => ({
...currentParams,
offset: e.selected * limit,
limit,
}));
},
[data?.meta?.limit],
);
return {
allCustomers: customers,
metadata: data?.meta,
@@ -102,8 +149,6 @@ const useCustomerListPresenter = () => {
router,
isModalOpen,
setIsModalOpen,
isFilterModalOpen,
setIsFilterModalOpen,
filterRegister,
handleFilterSubmit,
search,
@@ -113,6 +158,12 @@ const useCustomerListPresenter = () => {
setParams,
assignCompanyCompaniesQuery,
shouldShowPagination,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
};
};
@@ -0,0 +1,92 @@
"use client";
import InputGroup from "@/components/FormElements/InputGroup";
import { Button } from "@/components/ui-elements/button";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import {
FieldValues,
UseFormHandleSubmit,
UseFormRegister,
UseFormWatch,
} from "react-hook-form";
import { useMemo } from "react";
interface FeedbackListFiltersProps {
filterRegister: UseFormRegister<FieldValues>;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
search: (data: any) => void;
watch: UseFormWatch<FieldValues>;
onClearAll: () => void;
}
function countActive(values: Record<string, unknown>): number {
const q = values.q;
return typeof q === "string" && q.trim() ? 1 : 0;
}
export default function FeedbackListFilters({
filterRegister,
isMutating,
handleFilterSubmit,
search,
watch,
onClearAll,
}: FeedbackListFiltersProps) {
const watched = watch();
const activeCount = useMemo(
() => countActive(watched as Record<string, unknown>),
[watched],
);
return (
<InlineListFiltersPanel
title="Filter feedback"
description="Keyword search across this list — inline, no popup."
activeFilterCount={activeCount}
expandAriaLabel="Expand feedback filters"
collapseAriaLabel="Collapse feedback filters"
panelToggleDataCy="feedback-filter-panel-toggle"
>
<form
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
onSubmit={handleFilterSubmit(search)}
data-cy="feedback-filter-form"
>
<div className="max-w-xl">
<InputGroup
{...filterRegister("q")}
name="q"
label="Keyword search"
placeholder="Search feedback…"
type="search"
dataCy="feedback-filter-q-input"
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Button
type="submit"
size="small"
className="min-h-[52px] min-w-[140px] rounded-2xl bg-gradient-to-r from-primary via-primary to-violet-600 px-8 text-sm font-semibold transition hover:opacity-[0.97]"
data-cy="feedback-filter-submit-button"
label={
isMutating ? (
<span className="inline-flex h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<span>Apply filters</span>
)
}
/>
<button
type="button"
data-cy="feedback-filter-reset-button"
onClick={onClearAll}
className="min-h-[52px] rounded-2xl border border-stroke/70 bg-white/80 px-6 text-sm font-semibold text-dark transition hover:border-primary/40 hover:text-primary dark:border-dark-3 dark:bg-dark-3/60 dark:text-white dark:hover:bg-white/[0.06]"
>
Reset all
</button>
</div>
</form>
</InlineListFiltersPanel>
);
}
+85 -67
View File
@@ -15,6 +15,7 @@ import {
import TableSkeleton from "@/components/ui/TableSkeleton";
import { cn } from "@/lib/utils";
import { capitalize, getPaginatedRowNumber, unixToDate } from "@/utils/shared";
import FeedbackListFilters from "./FeedbackListFilters";
import useFeedbackTablePresenter from "./useFeedbackTablePresenter";
interface IProps {
@@ -30,8 +31,18 @@ const FeedbackTable = ({ id, paramKey }: IProps) => {
router,
getShowcaseSectionTitle,
metadata,
setParams,
shouldShowPagination,
filterRegister,
handleFilterSubmit,
search,
watch,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
isMutating,
} = useFeedbackTablePresenter({ id, paramKey });
return (
@@ -42,76 +53,83 @@ const FeedbackTable = ({ id, paramKey }: IProps) => {
}
className="flex flex-col rounded-[10px] bg-white px-7.5 pb-4 pt-7.5 capitalize shadow-1 dark:bg-gray-dark dark:shadow-card"
>
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead className="capitalize" key={column} colSpan={100}>
{column}
</TableHead>
))}
</TableRow>
</TableHeader>
{isLoading && <TableSkeleton column={columns.length} />}
<TableBody>
<EmptyListWrapper
list={feedback ?? []}
columns={columns}
emptyMessage="No feedback were found"
isLoading={isLoading}
>
{(feedback ?? []).map((item, index) => (
<TableRow
key={item.id}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
<FeedbackListFilters
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
watch={watch}
onClearAll={clearAllFilters}
/>
<div ref={tableSectionRef}>
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead className="capitalize" key={column} colSpan={100}>
{column}
</TableHead>
))}
</TableRow>
</TableHeader>
{isLoading ? (
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
) : (
<TableBody ref={dataTbodyRef}>
<EmptyListWrapper
list={feedback ?? []}
columns={columns}
emptyMessage="No feedback were found"
isLoading={false}
>
<TableCell className="text-start" colSpan={100}>
{getPaginatedRowNumber({
index,
page: metadata?.page,
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}`);
}}
{(feedback ?? []).map((item, index) => (
<TableRow
key={item.id}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<ExclamationIcon />
</button>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
</Table>
<TableCell className="text-start" colSpan={100}>
{getPaginatedRowNumber({
index,
page: metadata?.page,
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
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}>
<Pagination
currentPage={metadata?.page ? metadata.page - 1 : 0}
totalPages={metadata?.pages ?? 1}
onPageChange={(e) => {
setParams((currentParams) => ({
...currentParams,
offset: e.selected * (metadata?.limit ?? 10),
limit: metadata?.limit ?? 10,
}));
}}
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={handlePaginationChange}
/>
</IsVisible>
</ShowcaseSection>
@@ -1,8 +1,12 @@
"use client";
import { apiDefaultParams } from "@/constants/Api_Params";
import { useGetFeedback } from "@/hooks/queries";
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { deleteEmptyKeys } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
interface IProps {
id: string;
@@ -14,6 +18,19 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
const searchParams = useSearchParams();
const router = useRouter();
const pathName = usePathname();
const isMutating = useIsMutating();
const {
register: filterRegister,
handleSubmit: handleFilterSubmit,
reset: filterFormReset,
watch,
setValue: setFilterValue,
} = useForm();
const tableSectionRef = useRef<HTMLDivElement>(null);
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
useEffect(() => {
const newParams: Record<string, unknown> = {
@@ -25,7 +42,8 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
newParams[key] = value;
}
setParams(newParams);
}, [searchParams]);
filterFormReset(newParams as any);
}, [searchParams, filterFormReset]);
const queryParams = useMemo(
() => ({ ...params, [paramKey]: id }),
@@ -33,6 +51,13 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
);
const { data, isLoading } = useGetFeedback(queryParams);
useTableSectionRowAnimations(isLoading, data, {
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
});
const columns = [
"row",
"feedback type",
@@ -45,6 +70,39 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
!isLoading &&
(data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
const pagination = useMemo(
() => ({
currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
totalPages: data?.meta?.pages ?? 1,
}),
[data?.meta?.page, data?.meta?.pages],
);
const handlePaginationChange = useCallback(
(e: { selected: number }) => {
const limit = data?.meta?.limit ?? 10;
setParams((currentParams) => ({
...currentParams,
offset: e.selected * limit,
limit,
}));
},
[data?.meta?.limit],
);
const search = (formData: any) => {
const next = { ...params, ...formData, offset: 0 };
const cleaned = deleteEmptyKeys(next);
const queryString = new URLSearchParams(
cleaned as Record<string, string>,
).toString();
router.push(`${pathName}?${queryString}`);
};
const clearAllFilters = useCallback(() => {
router.push(pathName);
}, [pathName, router]);
const getShowcaseSectionTitle = () => {
if (paramKey === "tender_id") {
const title = data?.data?.[0]?.tender?.title;
@@ -62,6 +120,18 @@ const useFeedbackTablePresenter = ({ id, paramKey }: IProps) => {
metadata: data?.meta,
setParams,
shouldShowPagination,
filterRegister,
handleFilterSubmit,
search,
watch,
setFilterValue,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
isMutating,
};
};
@@ -1,5 +1,9 @@
"use client";
import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select";
import { Button } from "@/components/ui-elements/button";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import { INQUIRY_STATUS } from "@/constants/enums";
import {
FieldValues,
@@ -8,15 +12,16 @@ import {
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
import { useMemo } from "react";
interface InquiriesListFiltersProps {
filterRegister: UseFormRegister<FieldValues>;
setIsFilterModalOpen: (isOpen: boolean) => void;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
search: (data: any) => void;
watch: UseFormWatch<FieldValues>;
setValue: UseFormSetValue<any>;
onClearAll: () => void;
}
const statusOptions = Object.values(INQUIRY_STATUS).map((status) => ({
@@ -24,58 +29,97 @@ const statusOptions = Object.values(INQUIRY_STATUS).map((status) => ({
value: status,
}));
function countActive(values: Record<string, unknown>): number {
let n = 0;
const q = values.q;
if (typeof q === "string" && q.trim()) n++;
const status = values.status;
if (
status !== undefined &&
status !== null &&
status !== "" &&
String(status).trim()
) {
n++;
}
return n;
}
const InquiriesListFilters = ({
filterRegister,
setIsFilterModalOpen,
isMutating,
handleFilterSubmit,
search,
setValue,
watch,
onClearAll,
}: InquiriesListFiltersProps) => {
return (
<form
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
onSubmit={handleFilterSubmit(search)}
>
<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", "")}
/>
const watched = watch();
const activeCount = useMemo(
() => countActive(watched as Record<string, unknown>),
[watched],
);
<div className="flex gap-4">
<button
type="submit"
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
>
{isMutating ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
) : (
<span>Search</span>
)}
</button>
<button
type="button"
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
onClick={() => setIsFilterModalOpen(false)}
>
Close
</button>
</div>
</form>
return (
<InlineListFiltersPanel
title="Filter inquiries"
description="Search or filter by workflow status — inline, no popup."
activeFilterCount={activeCount}
expandAriaLabel="Expand inquiry filters"
collapseAriaLabel="Collapse inquiry filters"
panelToggleDataCy="inquiries-filter-panel-toggle"
>
<form
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
onSubmit={handleFilterSubmit(search)}
data-cy="inquiries-filter-form"
>
<div className="grid gap-4 sm:grid-cols-2">
<InputGroup
{...filterRegister("q")}
name="q"
label="Keyword search"
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,
inquiries,
allInquiries,
isFilterModalOpen,
setIsFilterModalOpen,
isPending,
currentInquiry,
isDeleteModalOpen,
@@ -51,130 +49,117 @@ const InquiriesTable = () => {
watch,
setFilterValue,
isMutating,
setParams,
shouldShowPagination,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
} = useInquiriesListPresenter();
return (
<ListWrapper>
<ListHeader
onFilter={() => setIsFilterModalOpen(true)}
hasCreate={false}
<ListHeader hasFilter={false} hasCreate={false} />
<InquiriesListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
watch={watch}
onClearAll={clearAllFilters}
/>
<Table>
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
{columns.map((column: string) => (
<TableHead key={column} colSpan={100}>
{column}
</TableHead>
))}
</TableRow>
</TableHeader>
{isPending && <TableSkeleton column={columns.length} />}
<TableBody>
<EmptyListWrapper
columns={columns}
list={inquiries ?? []}
emptyMessage="No Inquiries were found"
isLoading={isPending}
>
{inquiries?.map((inquiry: TInquiries, index: number) => (
<TableRow key={inquiry?.id ?? index}>
<TableCell className="text-start" colSpan={100}>
{getPaginatedRowNumber({
index,
page: allInquiries?.meta?.page,
limit: allInquiries?.meta?.limit,
})}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{inquiry?.company_name}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{inquiry?.full_name}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{formatPhoneNumber(inquiry?.phone_number)}
</TableCell>
<TableCell className="text-start" colSpan={100}>
<Status status={inquiry?.status ?? "pending"}>
{inquiry?.status}
</Status>
</TableCell>
<TableCell className="text-start" colSpan={100}>
{unixToDate({ unix: inquiry.created_at, hasTime: true })}
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
data-tooltip-id="change-status"
data-tooltip-content="Change Status"
data-tooltip-place="top"
className="hover:text-primary"
onClick={() => {
setCurrentInquiry(inquiry);
setIsChangeStatusModalOpen(true);
}}
>
<Tooltip
{..._TooltipDefaultParams({ id: "change-status" })}
/>
<span className="sr-only">Change Status</span>
<PencilSquareIcon />
</button>
<button
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentInquiry(inquiry);
setIsDeleteModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Contact Us </span>
<TrashIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
</Table>
<Modal
isOpen={isFilterModalOpen}
onClose={() => setIsFilterModalOpen(false)}
classNames="w-full xl:w-1/2"
showButtons={false}
>
<InquiriesListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
setIsFilterModalOpen={setIsFilterModalOpen}
watch={watch}
/>
</Modal>
<IsVisible condition={shouldShowPagination}>
<Pagination
currentPage={
allInquiries?.meta?.page ? allInquiries?.meta?.page - 1 : 0
}
totalPages={allInquiries?.meta?.pages ?? 1}
onPageChange={(e) => {
setParams((currentParams) => ({
...currentParams,
offset: e.selected * (allInquiries?.meta?.limit ?? 10),
limit: allInquiries?.meta?.limit ?? 10,
}));
}}
/>
</IsVisible>
<div ref={tableSectionRef}>
<Table>
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
{columns.map((column: string) => (
<TableHead key={column} colSpan={100}>
{column}
</TableHead>
))}
</TableRow>
</TableHeader>
{isPending ? (
<TableSkeleton ref={skeletonTbodyRef} column={columns.length} />
) : (
<TableBody ref={dataTbodyRef}>
<EmptyListWrapper
columns={columns}
list={inquiries ?? []}
emptyMessage="No Inquiries were found"
isLoading={false}
>
{inquiries?.map((inquiry: TInquiries, index: number) => (
<TableRow key={inquiry?.id ?? index}>
<TableCell className="text-start" colSpan={100}>
{getPaginatedRowNumber({
index,
page: allInquiries?.meta?.page,
limit: allInquiries?.meta?.limit,
})}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{inquiry?.company_name}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{inquiry?.full_name}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{formatPhoneNumber(inquiry?.phone_number)}
</TableCell>
<TableCell className="text-start" colSpan={100}>
<Status status={inquiry?.status ?? "pending"}>
{inquiry?.status}
</Status>
</TableCell>
<TableCell className="text-start" colSpan={100}>
{unixToDate({ unix: inquiry.created_at, hasTime: true })}
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
type="button"
data-tooltip-id="change-status"
data-tooltip-content="Change Status"
data-tooltip-place="top"
className="hover:text-primary"
onClick={() => {
setCurrentInquiry(inquiry);
setIsChangeStatusModalOpen(true);
}}
>
<Tooltip
{..._TooltipDefaultParams({ id: "change-status" })}
/>
<span className="sr-only">Change Status</span>
<PencilSquareIcon />
</button>
<button
type="button"
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
onClick={() => {
setCurrentInquiry(inquiry);
setIsDeleteModalOpen(true);
}}
>
<Tooltip {..._TooltipDefaultParams({ id: "delete" })} />
<span className="sr-only">Delete Contact Us </span>
<TrashIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
)}
</Table>
</div>
<Modal
isOpen={isChangeStatusModalOpen}
onClose={() => setIsChangeStatusModalOpen(false)}
@@ -192,6 +177,13 @@ const InquiriesTable = () => {
currentStatus={currentInquiry?.status ?? "pending"}
/>
</Modal>
<IsVisible condition={shouldShowPagination}>
<Pagination
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={handlePaginationChange}
/>
</IsVisible>
{isDeleteModalOpen && (
<ConfirmationModal
isOpen={isDeleteModalOpen}
@@ -4,15 +4,15 @@ import {
useDeleteInquiry,
useGetInquiries,
} from "@/hooks/queries/useInquiryQueries";
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { TInquiries } from "@/lib/api";
import { deleteEmptyKeys } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
const useInquiriesListPresenter = () => {
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isChangeStatusModalOpen, setIsChangeStatusModalOpen] = useState(false);
const [currentInquiry, setCurrentInquiry] = useState<TInquiries | null>(null);
@@ -39,6 +39,10 @@ const useInquiriesListPresenter = () => {
setValue: setFilterValue,
} = useForm();
const tableSectionRef = useRef<HTMLDivElement>(null);
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
useEffect(() => {
const newParams: Record<string, any> = {
offset: 0,
@@ -52,30 +56,61 @@ const useInquiriesListPresenter = () => {
}, [searchParams, filterFormReset]);
const { data, isPending } = useGetInquiries(params);
useTableSectionRowAnimations(isPending, data, {
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
});
const shouldShowPagination =
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
const pagination = useMemo(
() => ({
currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
totalPages: data?.meta?.pages ?? 1,
}),
[data?.meta?.page, data?.meta?.pages],
);
const handlePaginationChange = useCallback(
(e: { selected: number }) => {
const limit = data?.meta?.limit ?? 10;
setParams((currentParams) => ({
...currentParams,
offset: e.selected * limit,
limit,
}));
},
[data?.meta?.limit],
);
const { mutate } = useDeleteInquiry(() => setIsDeleteModalOpen(false));
const { mutate: changeStatus } =
useChangeInquiryStatus(() => setIsChangeStatusModalOpen(false));
const search = (data: any) => {
const newParams = { ...params, ...data, offset: 0 };
const search = (formData: any) => {
const newParams = { ...params, ...formData, offset: 0 };
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = new URLSearchParams(cleanedParams).toString();
router.push(`${pathname}?${queryString}`);
setIsFilterModalOpen(false);
};
const clearAllFilters = useCallback(() => {
router.push(pathname);
}, [pathname, router]);
const deleteInquiry = () => {
if (!currentInquiry?.id) return;
mutate(currentInquiry.id);
};
const handleChangeStatus = (data: any) => {
const handleChangeStatus = (payload: any) => {
if (!currentInquiry?.id) return;
changeStatus({
id: currentInquiry.id,
credentials: data,
credentials: payload,
});
};
@@ -83,8 +118,6 @@ const useInquiriesListPresenter = () => {
columns,
inquiries: data?.data.inquiries,
allInquiries: data,
isFilterModalOpen,
setIsFilterModalOpen,
isPending,
isDeleteModalOpen,
setIsDeleteModalOpen,
@@ -103,6 +136,12 @@ const useInquiriesListPresenter = () => {
isMutating,
setParams,
shouldShowPagination,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
};
};
@@ -1,5 +1,9 @@
"use client";
import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select";
import { Button } from "@/components/ui-elements/button";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import {
FieldValues,
UseFormHandleSubmit,
@@ -7,66 +11,111 @@ import {
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
import { useMemo } from "react";
interface NotificationHistoryListFiltersProps {
filterRegister: UseFormRegister<FieldValues>;
setIsFilterModalOpen: (isOpen: boolean) => void;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
search: (data: any) => void;
watch: UseFormWatch<FieldValues>;
setValue: UseFormSetValue<any>;
onClearAll: () => void;
}
function countActive(values: Record<string, unknown>): number {
let n = 0;
const seen = values.seen;
if (
seen !== undefined &&
seen !== null &&
seen !== "" &&
String(seen).trim()
) {
n++;
}
const searchVal = values.search;
if (typeof searchVal === "string" && searchVal.trim()) n++;
return n;
}
const NotificationHistoryListFilters = ({
filterRegister,
setIsFilterModalOpen,
isMutating,
handleFilterSubmit,
search,
watch,
setValue,
onClearAll,
}: NotificationHistoryListFiltersProps) => {
return (
<form
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
onSubmit={handleFilterSubmit(search)}
>
<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"
/>
const watched = watch();
const activeCount = useMemo(
() => countActive(watched as Record<string, unknown>),
[watched],
);
<div className="flex gap-4">
<button
type="submit"
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
>
{isMutating ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
) : (
<span>Search</span>
)}
</button>
<button
type="button"
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
onClick={() => setIsFilterModalOpen(false)}
>
Close
</button>
</div>
</form>
return (
<InlineListFiltersPanel
title="Filter notifications"
description="Seen state and message search — inline, no popup."
activeFilterCount={activeCount}
expandAriaLabel="Expand notification filters"
collapseAriaLabel="Collapse notification filters"
panelToggleDataCy="notification-history-filter-panel-toggle"
>
<form
className="space-y-5 px-4 pb-6 pt-1 sm:px-6"
onSubmit={handleFilterSubmit(search)}
data-cy="notification-history-filter-form"
>
<div className="grid gap-4 sm:grid-cols-2">
<Select
{...filterRegister("seen")}
items={[
{ label: "Seen", value: "true" },
{ 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 ListHeader from "@/components/ui/ListHeader";
import ListWrapper from "@/components/ui/ListWrapper";
import Modal from "@/components/ui/modal";
import NotificationHistoryListFilters from "./NotificationHistoryListFilters";
import useNotificationHistoryTablePresenter from "./useNotificationHistoryTablePresenter";
@@ -36,130 +35,119 @@ const NotificationHistoryTable = () => {
notificationHistory,
pathName,
router,
isFilterModalOpen,
setIsFilterModalOpen,
setParams,
handleSubmit,
register,
handleFilterSubmit,
filterRegister,
setValue,
watch,
search,
isMutating,
shouldShowPagination,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
} = useNotificationHistoryTablePresenter();
return (
<>
<ListWrapper>
<ListHeader
createButtonText="Create notification"
onFilter={() => setIsFilterModalOpen(true)}
<ListHeader hasFilter={false} createButtonText="Create notification" />
<NotificationHistoryListFilters
filterRegister={filterRegister}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
watch={watch}
setValue={setValue}
search={search}
onClearAll={clearAllFilters}
/>
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead key={column}>
{column}
</TableHead>
))}
</TableRow>
</TableHeader>
{isLoading && (
<TableSkeleton column={columns.length} cellColSpan={1} />
)}
<TableBody>
<EmptyListWrapper
list={notificationHistory ?? []}
columns={columns}
emptyMessage="No notifications were found"
>
{(notificationHistory ?? []).map((item, index) => (
<TableRow key={item.id}>
<TableCell>
{getPaginatedRowNumber({
index,
page: metadata?.page,
limit: metadata?.limit,
})}
</TableCell>
<TableCell>{item.title}</TableCell>
<TableCell>{item.event_type}</TableCell>
<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",
<div ref={tableSectionRef}>
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead key={column}>{column}</TableHead>
))}
</TableRow>
</TableHeader>
{isLoading ? (
<TableSkeleton
ref={skeletonTbodyRef}
column={columns.length}
cellColSpan={1}
/>
) : (
<TableBody ref={dataTbodyRef}>
<EmptyListWrapper
list={notificationHistory ?? []}
columns={columns}
emptyMessage="No notifications were found"
isLoading={false}
>
{(notificationHistory ?? []).map((item, index) => (
<TableRow key={item.id}>
<TableCell>
{getPaginatedRowNumber({
index,
page: metadata?.page,
limit: metadata?.limit,
})}
/>
<EyeIcon />
</button>
</TableCell>
</TableRow>
))}
</EmptyListWrapper>
</TableBody>
</Table>
</TableCell>
<TableCell>{item.title}</TableCell>
<TableCell>{item.event_type}</TableCell>
<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
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}>
<Pagination
currentPage={metadata?.page ? metadata?.page - 1 : 0}
totalPages={metadata?.pages ?? 1}
onPageChange={(e) => {
setParams((currentParams: Record<string, any>) => ({
...currentParams,
offset: e.selected * (metadata?.limit ?? 10),
limit: metadata?.limit ?? 10,
}));
}}
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
onPageChange={handlePaginationChange}
/>
</IsVisible>
</ListWrapper>
<Modal
isOpen={isFilterModalOpen}
onClose={() => setIsFilterModalOpen(false)}
showButtons={false}
>
<NotificationHistoryListFilters
filterRegister={register}
handleFilterSubmit={handleSubmit}
isMutating={isMutating}
watch={watch}
setValue={setValue}
setIsFilterModalOpen={setIsFilterModalOpen}
search={search}
/>
</Modal>
</>
);
};
@@ -1,19 +1,37 @@
"use client";
import { apiDefaultParams } from "@/constants/Api_Params";
import { useGetNotificationHistoryQuery } from "@/hooks/queries/useNotificationQueries";
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
import { deleteEmptyKeys } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
const useNotificationHistoryTablePresenter = () => {
const { register, handleSubmit, watch, setValue } = useForm();
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const {
register: filterRegister,
handleSubmit,
watch,
setValue,
reset: filterFormReset,
} = useForm();
const [params, setParams] = useState<Record<string, any>>({
...apiDefaultParams,
});
const searchParams = useSearchParams();
const tableSectionRef = useRef<HTMLDivElement>(null);
const skeletonTbodyRef = useRef<HTMLTableSectionElement>(null);
const dataTbodyRef = useRef<HTMLTableSectionElement>(null);
const { data, isLoading } = useGetNotificationHistoryQuery(params);
useTableSectionRowAnimations(isLoading, data, {
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
});
const shouldShowPagination =
!isLoading && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
const isMutating = useIsMutating();
@@ -32,14 +50,51 @@ const useNotificationHistoryTablePresenter = () => {
];
const pathName = usePathname();
const router = useRouter();
const search = (data: any) => {
const newParams = { ...params, ...data, offset: 0 };
useEffect(() => {
const newParams: Record<string, any> = {
...apiDefaultParams,
offset: 0,
limit: 10,
};
for (const [key, value] of searchParams.entries()) {
newParams[key] = value;
}
setParams(newParams);
filterFormReset(newParams);
}, [searchParams, filterFormReset]);
const pagination = useMemo(
() => ({
currentPage: data?.meta?.page ? data.meta.page - 1 : 0,
totalPages: data?.meta?.pages ?? 1,
}),
[data?.meta?.page, data?.meta?.pages],
);
const handlePaginationChange = useCallback(
(e: { selected: number }) => {
const limit = data?.meta?.limit ?? 10;
setParams((currentParams: Record<string, any>) => ({
...currentParams,
offset: e.selected * limit,
limit,
}));
},
[data?.meta?.limit],
);
const search = (formData: any) => {
const newParams = { ...params, ...formData, offset: 0 };
const cleanedParams = deleteEmptyKeys(newParams);
setParams(cleanedParams);
const queryString = new URLSearchParams(cleanedParams).toString();
router.push(`${pathName}?${queryString}`);
setIsFilterModalOpen(false);
};
const clearAllFilters = useCallback(() => {
router.push(pathName);
}, [pathName, router]);
return {
notificationHistory: data?.data,
metadata: data?.meta,
@@ -48,15 +103,20 @@ const useNotificationHistoryTablePresenter = () => {
router,
pathName,
setParams,
register,
filterRegister,
handleSubmit,
handleFilterSubmit: handleSubmit,
watch,
setValue,
isFilterModalOpen,
setIsFilterModalOpen,
isMutating,
search,
shouldShowPagination,
clearAllFilters,
tableSectionRef,
skeletonTbodyRef,
dataTbodyRef,
pagination,
handlePaginationChange,
};
};
@@ -1,12 +1,14 @@
"use client";
import { ChevronUpIcon, GlobeIcon, SearchIcon } from "@/assets/icons";
import { GlobeIcon, SearchIcon } from "@/assets/icons";
import DatePicker from "@/components/FormElements/DatePicker/DatePicker";
import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select";
import { Button } from "@/components/ui-elements/button";
import CollapsibleFilterSection from "@/components/ui/CollapsibleFilterSection";
import InlineListFiltersPanel from "@/components/ui/InlineListFiltersPanel";
import { Countries } from "@/constants/countries";
import { cn } from "@/lib/utils";
import { useMemo } from "react";
import {
Control,
FieldValues,
@@ -15,8 +17,6 @@ import {
UseFormSetValue,
UseFormWatch,
} from "react-hook-form";
import gsap from "gsap";
import { type ReactNode, useLayoutEffect, useMemo, useRef, useState } from "react";
interface TenderListFiltersProps {
filterRegister: UseFormRegister<FieldValues>;
@@ -71,69 +71,6 @@ function countActiveFilters(values: Record<string, unknown>): number {
return n;
}
function CollapsibleFilterCard({
title,
subtitle,
badge,
defaultOpen,
children,
}: {
title: string;
subtitle?: string;
badge?: string;
defaultOpen?: boolean;
children: ReactNode;
}) {
const [open, setOpen] = useState(Boolean(defaultOpen));
return (
<div
className={cn(
"overflow-hidden rounded-2xl border border-stroke/50 bg-white/50 backdrop-blur-md dark:border-white/[0.08] dark:bg-dark-2/45",
open && "ring-1 ring-primary/15 dark:ring-primary/25",
)}
>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center justify-between gap-3 px-4 py-3.5 text-start transition hover:bg-white/60 dark:hover:bg-white/[0.04]"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-dark dark:text-white">{title}</span>
{badge ? (
<span className="rounded-full bg-gradient-to-r from-primary/90 to-violet-500/85 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white">
{badge}
</span>
) : null}
</div>
{subtitle ? (
<p className="mt-0.5 text-xs text-dark-5 dark:text-dark-6">{subtitle}</p>
) : null}
</div>
<ChevronUpIcon
className={cn(
"h-5 w-5 shrink-0 text-primary transition-transform duration-300 dark:text-primary/90",
!open && "rotate-180",
)}
/>
</button>
<div
className={cn(
"grid transition-[grid-template-rows] duration-300 ease-out",
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="min-h-0 overflow-hidden">
<div className="border-t border-stroke/40 px-4 pb-5 pt-1 dark:border-white/[0.06]">
{children}
</div>
</div>
</div>
</div>
);
}
const TenderListFilters = ({
filterRegister,
control,
@@ -145,363 +82,198 @@ const TenderListFilters = ({
onClearAll,
}: TenderListFiltersProps) => {
const watched = watch();
const activeCount = useMemo(() => countActiveFilters(watched as Record<string, unknown>), [watched]);
const [panelOpen, setPanelOpen] = useState(false);
/** When true, header stays flush (square bottom) with the panel — keep true until collapse GSAP finishes so radius does not pop early. */
const [headerFlushBottom, setHeaderFlushBottom] = useState(false);
const panelOuterRef = useRef<HTMLDivElement>(null);
const panelInnerRef = useRef<HTMLDivElement>(null);
const chevronRef = useRef<HTMLSpanElement>(null);
const prevPanelOpen = useRef(panelOpen);
/** Initial dimensions without animating (avoids flash + Strict Mode double-invoke issues). */
useLayoutEffect(() => {
const outer = panelOuterRef.current;
const inner = panelInnerRef.current;
const chevron = chevronRef.current;
if (!outer || !inner) return;
gsap.set(outer, { height: panelOpen ? "auto" : 0 });
gsap.set(inner, {
opacity: panelOpen ? 1 : 0,
y: panelOpen ? 0 : -14,
scale: panelOpen ? 1 : 0.995,
});
if (chevron) gsap.set(chevron, { rotation: panelOpen ? 0 : 180, transformOrigin: "50% 50%" });
if (!panelOpen) {
setHeaderFlushBottom(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- run once on mount for initial `panelOpen`
}, []);
useLayoutEffect(() => {
const outer = panelOuterRef.current;
const inner = panelInnerRef.current;
const chevron = chevronRef.current;
if (!outer || !inner) return;
if (prevPanelOpen.current === panelOpen) return;
prevPanelOpen.current = panelOpen;
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
gsap.killTweensOf([outer, inner, chevron].filter(Boolean));
if (reduced) {
gsap.set(outer, { height: panelOpen ? "auto" : 0 });
gsap.set(inner, { opacity: panelOpen ? 1 : 0, y: panelOpen ? 0 : -10, scale: 1 });
if (chevron) gsap.set(chevron, { rotation: panelOpen ? 0 : 180 });
setHeaderFlushBottom(panelOpen);
return;
}
const ctx = gsap.context(() => {
if (panelOpen) {
setHeaderFlushBottom(true);
const targetH = inner.scrollHeight;
gsap.set(outer, { height: 0 });
gsap.set(inner, { opacity: 0, y: -36, scale: 0.985 });
const tl = gsap.timeline({ defaults: { ease: "none" } });
tl.to(outer, {
height: targetH,
duration: 0.62,
ease: "power4.out",
onComplete: () => {
gsap.set(outer, { height: "auto" });
},
}).to(
inner,
{
opacity: 1,
y: 0,
scale: 1,
duration: 0.52,
ease: "power3.out",
},
"-=0.5",
);
if (chevron) {
tl.to(
chevron,
{ rotation: 0, duration: 0.48, ease: "back.out(1.35)" },
"-=0.52",
);
}
} else {
const currentH = Math.max(outer.offsetHeight, inner.scrollHeight);
gsap.set(outer, { height: currentH });
const tl = gsap.timeline({
onComplete: () => {
setHeaderFlushBottom(false);
},
});
tl.to(inner, {
opacity: 0,
y: -22,
scale: 0.992,
duration: 0.3,
ease: "power2.in",
})
.to(
outer,
{
height: 0,
duration: 0.48,
ease: "power3.inOut",
},
"-=0.12",
);
if (chevron) {
tl.to(chevron, { rotation: 180, duration: 0.42, ease: "power2.inOut" }, 0);
}
}
}, outer);
return () => ctx.revert();
}, [panelOpen]);
const activeCount = useMemo(
() => countActiveFilters(watched as Record<string, unknown>),
[watched],
);
return (
<div className="relative mb-8">
<div className="relative overflow-hidden rounded-[1.75rem] border border-stroke/60 bg-white dark:border-dark-3 dark:bg-dark-2">
<button
type="button"
onClick={() => setPanelOpen((o) => !o)}
aria-expanded={panelOpen}
aria-label={panelOpen ? "Collapse tender filters" : "Expand tender filters"}
className={cn(
"flex w-full flex-wrap items-start gap-x-3 gap-y-3 px-4 py-4 text-start transition-colors sm:items-center sm:justify-between sm:gap-4 sm:px-6 sm:py-5",
"rounded-[1.65rem] border border-transparent",
"hover:border-stroke/50 hover:bg-white/70 dark:hover:border-white/[0.08] dark:hover:bg-dark-3/50",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-dark-2",
headerFlushBottom &&
"rounded-b-none border-b border-stroke/45 dark:border-white/[0.07]",
)}
>
<div className="flex min-w-0 flex-1 items-start gap-3 sm:items-center">
<span className="mt-0.5 flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-primary to-violet-600 text-white sm:mt-0">
<SearchIcon className="h-5 w-5 opacity-95" />
</span>
<div className="min-w-0">
<span className="block text-lg font-bold tracking-tight text-dark dark:text-white">
Refine tenders
</span>
<p className="mt-0.5 max-w-xl text-sm text-dark-5 dark:text-dark-6">
Search and slice the catalogue inline no popup. Expand sections when you need
precision.
</p>
</div>
</div>
<div className="ml-auto flex shrink-0 items-center gap-2">
{activeCount > 0 ? (
<div className="flex items-center gap-2 rounded-full border border-primary/25 bg-primary/10 px-3 py-1.5 dark:border-primary/35 dark:bg-primary/15">
<span className="text-xs font-semibold uppercase tracking-wide text-primary">
{activeCount} active
</span>
</div>
) : (
<span className="inline-flex items-center rounded-full border border-stroke/50 bg-white/60 px-3 py-1.5 text-xs font-medium text-dark-5 dark:border-white/10 dark:bg-dark-3/80 dark:text-dark-6">
No filters applied
</span>
)}
<span
ref={chevronRef}
className="inline-flex h-5 w-5 shrink-0 text-primary dark:text-primary/90"
aria-hidden
>
<ChevronUpIcon className="h-5 w-5" />
</span>
</div>
</button>
<div
ref={panelOuterRef}
className="overflow-hidden"
aria-hidden={!panelOpen}
>
<div ref={panelInnerRef} className="will-change-transform">
<form
className="space-y-4 px-4 pb-6 pt-1 sm:px-6"
onSubmit={handleFilterSubmit(search)}
>
{/* 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."
<InlineListFiltersPanel
title="Refine tenders"
activeFilterCount={activeCount}
expandAriaLabel="Expand tender filters"
collapseAriaLabel="Collapse tender filters"
panelToggleDataCy="tenders-filter-panel-toggle"
>
<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"
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]"
>
<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>
</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>
Reset all
</button>
</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 mappedValues = { ...values };
@@ -250,7 +281,7 @@ const useTenderListPresenter = () => {
newParams[key] = value;
}
setParams(newParams);
filterFormReset(buildTenderFilterFormValues(newParams));
filterFormReset(mergeTenderFilterFormState(newParams));
}, [searchParams, filterFormReset]);
const { data, error, isPending } = useTendersQuery(params);
@@ -377,8 +408,14 @@ const useTenderListPresenter = () => {
};
const clearAllFilters = useCallback(() => {
const cleared: Record<string, any> = {
...apiDefaultParams,
offset: 0,
limit: 10,
};
filterFormReset(mergeTenderFilterFormState(cleared));
router.push(pathName);
}, [pathName, router]);
}, [pathName, router, filterFormReset]);
const columns = [
"row",
"title",