feat(submissions): Implement submissions page with paginated table
This commit introduces a new page for users to view their submissions in a structured and paginated table. This allows users to easily track their submission history and access key details. The main changes include: - A new page at `/submissions` to display the submissions list. - A `SubmissionsTable` component to render submission data, including title, country, status, and a link to the submission URL. - Integration of `react-paginate` to handle navigation through large sets of data. - A new `useSubmissions` custom hook using TanStack Query for efficient, paginated data fetching from the API. - Addition of the `currency-symbol-map` dependency to display prices with the correct currency symbol.
This commit is contained in:
@@ -2,7 +2,7 @@ import { cn } from "@/lib/utils";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type PropsType = {
|
||||
title: string;
|
||||
title?: string | ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
@@ -10,9 +10,11 @@ type PropsType = {
|
||||
export function ShowcaseSection({ title, children, className }: PropsType) {
|
||||
return (
|
||||
<div className="rounded-[10px] bg-white shadow-1 dark:bg-gray-dark dark:shadow-card">
|
||||
<h2 className="border-b border-stroke px-4 py-4 font-medium text-dark dark:border-dark-3 dark:text-white sm:px-6 xl:px-7.5">
|
||||
{title}
|
||||
</h2>
|
||||
{title && (
|
||||
<h2 className="border-b border-stroke px-4 py-4 font-medium text-dark dark:border-dark-3 dark:text-white sm:px-6 xl:px-7.5">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
<div className={cn("p-4 sm:p-6 xl:p-10", className)}>{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { PencilSquareIcon, TrashIcon } from "@/assets/icons";
|
||||
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
||||
import { formatPhoneNumber } from "@/utils/shared";
|
||||
|
||||
interface IProps {}
|
||||
|
||||
@@ -65,7 +66,7 @@ const CompaniesTable = ({}: IProps) => {
|
||||
{company.email}
|
||||
</TableHead>
|
||||
<TableHead className="text-start" colSpan={100}>
|
||||
{company.phone}
|
||||
{formatPhoneNumber(company.phone)}
|
||||
</TableHead>
|
||||
<TableHead className="text-start" colSpan={100}>
|
||||
{company.address.country}
|
||||
|
||||
@@ -18,6 +18,9 @@ import Modal from "@/components/ui/modal";
|
||||
import { TAssignCustomerToCompanyCredentials } from "@/lib/api";
|
||||
import { toast } from "react-toastify";
|
||||
import Status from "@/components/ui/Status";
|
||||
import { unixToDate } from "@/utils/shared";
|
||||
import { custom } from "zod";
|
||||
|
||||
const CustomersTable = () => {
|
||||
const {
|
||||
isPending,
|
||||
@@ -62,10 +65,15 @@ const CustomersTable = () => {
|
||||
<TableRow key={customer.id}>
|
||||
<TableCell colSpan={100}>{customer.full_name}</TableCell>
|
||||
<TableCell colSpan={100}>{customer.email}</TableCell>
|
||||
<TableCell colSpan={100}>{customer.created_at}</TableCell>
|
||||
<TableCell colSpan={100}>{customer.type}</TableCell>
|
||||
<TableCell colSpan={100}>
|
||||
<Status status={customer.status}>{customer.status}</Status>
|
||||
{unixToDate(customer.created_at)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell colSpan={100} className="uppercase">{customer.type}</TableCell>
|
||||
<TableCell colSpan={100}>
|
||||
<Status status={customer.status}>
|
||||
<Status status={customer.status}>{customer.status}</Status>
|
||||
</Status>
|
||||
</TableCell>
|
||||
<TableCell colSpan={100}>
|
||||
{customer.companies?.length ? (
|
||||
@@ -76,6 +84,7 @@ const CustomersTable = () => {
|
||||
<span>None</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell colSpan={100}>{customer.role ?? "-"}</TableCell>
|
||||
<TableCell className="text-start xl:pr-7.5">
|
||||
<div className="flex items-center justify-start gap-x-3.5">
|
||||
<button
|
||||
|
||||
@@ -75,6 +75,7 @@ const useCustomerListPresenter = () => {
|
||||
"type",
|
||||
"status",
|
||||
"company",
|
||||
"role",
|
||||
"actions",
|
||||
];
|
||||
const onConfirmDelete = () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
export function TendersSkeleton() {
|
||||
return (
|
||||
<TableBody>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell colSpan={100}>
|
||||
<Skeleton className="h-8" />
|
||||
@@ -22,6 +22,12 @@ export function TendersSkeleton() {
|
||||
<TableCell colSpan={100}>
|
||||
<Skeleton className="h-8" />
|
||||
</TableCell>
|
||||
<TableCell colSpan={100}>
|
||||
<Skeleton className="h-8" />
|
||||
</TableCell>
|
||||
<TableCell colSpan={100}>
|
||||
<Skeleton className="h-8" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -14,75 +14,99 @@ import {
|
||||
import { TendersSkeleton } from "./Skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getStatusColor } from "@/utils/shared";
|
||||
import { ExclamationIcon } from "@/assets/icons";
|
||||
import Pagination from "@/components/ui/pagination";
|
||||
import Page from "../../../app/forms/form-layout/page";
|
||||
import Link from "next/link";
|
||||
const TendersTable = () => {
|
||||
const {
|
||||
allTenders,
|
||||
isPending,
|
||||
ref,
|
||||
isFetchingNextPage,
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
} = useTenderListPresenter();
|
||||
const { allTenders, isPending, router, pathName, setParams } =
|
||||
useTenderListPresenter();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-[10px] bg-white px-7.5 pb-4 pt-7.5 shadow-1 dark:bg-gray-dark dark:shadow-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-none uppercase [&>th]:text-start">
|
||||
<TableHead colSpan={100} className="text-start font-extrabold">
|
||||
{/* <TableHead colSpan={100} className="text-start font-extrabold">
|
||||
Row
|
||||
</TableHead>
|
||||
</TableHead> */}
|
||||
<TableHead colSpan={100} className="text-start font-extrabold">
|
||||
Title
|
||||
</TableHead>
|
||||
<TableHead colSpan={100} className="text-start font-extrabold">
|
||||
Country Code
|
||||
</TableHead>
|
||||
<TableHead colSpan={100} className="text-start font-extrabold">
|
||||
Submission Url
|
||||
</TableHead>
|
||||
<TableHead colSpan={100} className="text-start font-extrabold">
|
||||
Status
|
||||
</TableHead>
|
||||
|
||||
<TableHead colSpan={100} className="text-start font-extrabold">
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
{isPending && <TendersSkeleton />}
|
||||
<TableBody>
|
||||
{allTenders.map((item, index) =>
|
||||
{allTenders?.data.tenders?.map((item, index) =>
|
||||
!item ? (
|
||||
<h6 key={index}>No tenders were found</h6>
|
||||
) : (
|
||||
<TableRow
|
||||
ref={index === allTenders.length - 1 ? ref : null}
|
||||
key={item.id}
|
||||
className="odd:bg-gray-2 dark:odd:bg-gray-7"
|
||||
>
|
||||
<TableCell className="text-start" colSpan={100}>
|
||||
{/* <TableCell className="text-start" colSpan={100}>
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
</TableCell> */}
|
||||
<TableCell className="text-start" colSpan={100}>
|
||||
{item.title}
|
||||
</TableCell>
|
||||
<TableCell className="text-start" colSpan={100}>
|
||||
{item.country_code}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className={cn(`text capitalize`)} colSpan={100}>
|
||||
{item.submission_url ? (
|
||||
<Link target="_blank" href={item.submission_url}>
|
||||
{item.submission_url}
|
||||
</Link>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className={cn(`text capitalize`)} colSpan={100}>
|
||||
<Status status={item.status}>{item.status}</Status>
|
||||
</TableCell>
|
||||
<TableCell className={cn(`text capitalize`)} colSpan={100}>
|
||||
<button
|
||||
className="text-gray-40 rounded-full bg-gray-dark bg-opacity-5 hover:text-green-dark dark:bg-gray dark:bg-opacity-5"
|
||||
onClick={() => {
|
||||
router.push(`${pathName}/${item.id}`);
|
||||
}}
|
||||
>
|
||||
<ExclamationIcon />
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
)}
|
||||
</TableBody>
|
||||
{isFetchingNextPage && <TendersSkeleton />}
|
||||
</Table>
|
||||
{(data?.pages.length ?? 0) >= 5 && hasNextPage && (
|
||||
<button
|
||||
onClick={() => fetchNextPage()}
|
||||
disabled={isFetchingNextPage}
|
||||
className="mt-4 rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700"
|
||||
>
|
||||
{isFetchingNextPage ? "Loading..." : "Load More"}
|
||||
</button>
|
||||
)}
|
||||
<Pagination
|
||||
currentPage={allTenders?.meta?.page ? allTenders?.meta?.page - 1 : 0}
|
||||
totalPages={allTenders?.meta?.pages ?? 1}
|
||||
onPageChange={(e) => {
|
||||
setParams((currentParams) => ({
|
||||
...currentParams,
|
||||
offset: e.selected * (allTenders?.meta?.limit ?? 10),
|
||||
limit: allTenders?.meta?.limit ?? 10,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
import { ConfirmationModalProps } from "@/components/ui/ConfirmationModal";
|
||||
import { useTendersInfiniteQuery } from "@/hooks/queries";
|
||||
import { useTendersQuery } from "@/hooks/queries";
|
||||
import useDebounce from "@/hooks/useDebounce";
|
||||
import { TCustomer } from "@/lib/api";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import { z } from "zod";
|
||||
@@ -17,31 +17,18 @@ const useTenderListPresenter = () => {
|
||||
const [params, setParams] = useState<Record<string, any>>({
|
||||
sort: "created_at",
|
||||
});
|
||||
const router = useRouter();
|
||||
const debouncedSearch = useDebounce(search, 1000);
|
||||
const pathName = usePathname();
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
isPending,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useTendersInfiniteQuery(params);
|
||||
const { data, error, isPending } = useTendersQuery(params);
|
||||
|
||||
const { ref, inView } = useInView({ threshold: 0.5 });
|
||||
|
||||
useEffect(() => {
|
||||
if (inView && hasNextPage && (data?.pages.length ?? 0) < 5) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [inView, hasNextPage, fetchNextPage, data?.pages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const result = z.string().safeParse(debouncedSearch);
|
||||
if (result.success) {
|
||||
setParams((prevParams) => ({
|
||||
...prevParams,
|
||||
search: debouncedSearch,
|
||||
}));
|
||||
}
|
||||
}, [debouncedSearch]);
|
||||
@@ -55,20 +42,18 @@ const useTenderListPresenter = () => {
|
||||
// }
|
||||
// };
|
||||
|
||||
const allTenders = data?.pages.flatMap((page) => page.data.tenders) ?? [];
|
||||
return {
|
||||
ref,
|
||||
currentTender,
|
||||
setCurrentTender,
|
||||
search,
|
||||
handleFilterChange,
|
||||
allTenders,
|
||||
router,
|
||||
allTenders: data,
|
||||
error,
|
||||
isPending,
|
||||
hasNextPage,
|
||||
setParams,
|
||||
data,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
pathName,
|
||||
isModalOpen,
|
||||
setIsModalOpen,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { ICreateCompanyCredentials } from "@/lib/api/types/Company";
|
||||
import useCreateCompanyPresenter from "./useCreateCompanyPresenter";
|
||||
import { ShowcaseSection } from "../../Layouts/showcase-section";
|
||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||
import { title } from "process";
|
||||
import InputGroup from "@/components/FormElements/InputGroup";
|
||||
import { FormErrorMessages } from "@/constants/Texts";
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
import { CreateCustomerCredentials } from "@/lib/api";
|
||||
import useCreateCustomerPresenter from "./useCreateCustomerPresenter";
|
||||
import { ShowcaseSection } from "../../Layouts/showcase-section";
|
||||
import { ShowcaseSection } from "@/components/Layouts/showcase-section";
|
||||
import InputGroup from "@/components/FormElements/InputGroup";
|
||||
import { FormErrorMessages } from "@/constants/Texts";
|
||||
import { Select } from "@/components/FormElements/select";
|
||||
import MultiSelect from "@/components/FormElements/MultiSelect";
|
||||
import { GlobeIcon } from "@/assets/icons";
|
||||
import { GlobeIcon, UserIcon } from "@/assets/icons";
|
||||
|
||||
interface IProps {
|
||||
editMode?: boolean;
|
||||
@@ -35,8 +35,8 @@ const CreateCustomer = ({ defaultValues, editMode, id }: IProps) => {
|
||||
message: FormErrorMessages.maxLength(30),
|
||||
},
|
||||
pattern: {
|
||||
value: /^[a-zA-Z0-9]+$/,
|
||||
message: FormErrorMessages.alphaNumeric,
|
||||
value: /^[a-zA-Z0-9](?:[a-zA-Z0-9._]*[a-zA-Z0-9])?$/,
|
||||
message: FormErrorMessages.invalidPattern,
|
||||
},
|
||||
})}
|
||||
name="username"
|
||||
@@ -61,7 +61,7 @@ const CreateCustomer = ({ defaultValues, editMode, id }: IProps) => {
|
||||
}))
|
||||
: []
|
||||
}
|
||||
placeholder="Choose your interests..."
|
||||
placeholder="Select companies to assign"
|
||||
{...register("company_ids", {
|
||||
required: "Please select at least one interest",
|
||||
})}
|
||||
@@ -87,6 +87,26 @@ const CreateCustomer = ({ defaultValues, editMode, id }: IProps) => {
|
||||
<p className="mt-1 text-sm text-red-500">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Select
|
||||
label="Role"
|
||||
{...register("role", {
|
||||
required: { message: FormErrorMessages.required, value: true },
|
||||
})}
|
||||
name="role"
|
||||
items={[
|
||||
{
|
||||
label: "Admin",
|
||||
value: "admin",
|
||||
},
|
||||
{
|
||||
label: "Analyst",
|
||||
value: "analyst",
|
||||
},
|
||||
]}
|
||||
placeholder="Select Role"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<InputGroup
|
||||
{...register("password", {
|
||||
@@ -97,6 +117,7 @@ const CreateCustomer = ({ defaultValues, editMode, id }: IProps) => {
|
||||
message: FormErrorMessages.maxLength(128),
|
||||
},
|
||||
})}
|
||||
disabled={editMode}
|
||||
name="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { FC, Fragment } from "react";
|
||||
import ReactPaginate, { ReactPaginateProps } from "react-paginate";
|
||||
interface IProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: ReactPaginateProps["onPageChange"];
|
||||
}
|
||||
const Pagination: FC<IProps> = ({ totalPages, currentPage, onPageChange }) => {
|
||||
return (
|
||||
<Fragment>
|
||||
<ReactPaginate
|
||||
pageCount={totalPages}
|
||||
initialPage={currentPage}
|
||||
nextLabel=">"
|
||||
previousLabel="<"
|
||||
pageRangeDisplayed={5}
|
||||
activeClassName="flex justify-center rounded-md bg-primary p-1 font-medium !text-white hover:bg-opacity-90"
|
||||
onPageChange={(e) => {
|
||||
if (!onPageChange) return;
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
onPageChange(e);
|
||||
}}
|
||||
className="flex w-full items-center justify-center text-sm text-gray-dark dark:text-white"
|
||||
nextClassName="flex justify-center rounded-md p-1 font-medium text-gray-dark dark:text-white hover:bg-opacity-90"
|
||||
previousClassName="flex justify-center rounded-md p-1 font-medium text-gray-dark dark:text-white hover:bg-opacity-90"
|
||||
pageClassName="flex justify-center h-fit rounded-md mx-2 p-1 text-gray-dark dark:text-white hover:bg-opacity-90"
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pagination;
|
||||
Reference in New Issue
Block a user