From e1898bf259b3901ac1429513e9834d45cc49e1f0 Mon Sep 17 00:00:00 2001 From: AmirReza Jamali Date: Thu, 18 Sep 2025 16:52:46 +0330 Subject: [PATCH 1/2] 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. --- package-lock.json | 32 ++++- package.json | 3 + src/app/customers/edit/[id]/page.tsx | 2 +- src/app/tenders/[details]/page.tsx | 118 ++++++++++++++++++ src/assets/icons.tsx | 26 ++++ src/components/Layouts/showcase-section.tsx | 10 +- src/components/Tables/companies/index.tsx | 3 +- src/components/Tables/customers/index.tsx | 15 ++- .../customers/useCustomerListPresenter.ts | 1 + src/components/Tables/tenders/Skeleton.tsx | 8 +- src/components/Tables/tenders/index.tsx | 74 +++++++---- .../Tables/tenders/useTenderListPresenter.ts | 29 ++--- .../forms/companies/CreateCompany.tsx | 2 +- .../forms/customers/CreateCustomer.tsx | 31 ++++- src/components/ui/pagination.tsx | 32 +++++ src/hooks/queries/useFlagsQueries.ts | 16 +++ src/hooks/queries/useTenders.ts | 30 ++--- src/lib/api/endpoints.ts | 1 + src/lib/api/services/customers-service.ts | 6 +- src/lib/api/services/flags-service.ts | 15 +++ src/lib/api/services/tenders-service.ts | 13 +- src/lib/api/types/Customers.ts | 2 + src/lib/api/types/Tenders.ts | 1 + src/utils/shared.ts | 27 +++- 24 files changed, 411 insertions(+), 86 deletions(-) create mode 100644 src/app/tenders/[details]/page.tsx create mode 100644 src/components/ui/pagination.tsx create mode 100644 src/hooks/queries/useFlagsQueries.ts create mode 100644 src/lib/api/services/flags-service.ts diff --git a/package-lock.json b/package-lock.json index c120e65..550951e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,12 @@ "dependencies": { "@tanstack/react-query": "^5.85.6", "@types/js-cookie": "^3.0.6", + "@types/react-paginate": "^7.1.4", "apexcharts": "^4.5.0", "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "currency-symbol-map": "^5.1.0", "dayjs": "^1.11.13", "flatpickr": "^4.6.13", "js-cookie": "^3.0.5", @@ -27,6 +29,7 @@ "react-dom": "19.0.0", "react-hook-form": "^7.62.0", "react-intersection-observer": "^9.16.0", + "react-paginate": "^8.3.0", "react-toastify": "^11.0.5", "tailwind-merge": "^2.6.0", "zod": "^4.1.5" @@ -1106,7 +1109,6 @@ "version": "19.0.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.8.tgz", "integrity": "sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -1122,6 +1124,15 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/react-paginate": { + "version": "7.1.4", + "resolved": "https://registry.npmmirror.com/@types/react-paginate/-/react-paginate-7.1.4.tgz", + "integrity": "sha512-6fqZvDzRJHubOGl6c7cGFC9ysgQSWYy0Gpus9HjORpydlcXgPnT8x+aKgqwCdtpZrRTwcBz2Q7JWAOYaRrsXGg==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.27.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.27.0.tgz", @@ -2280,9 +2291,14 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, "license": "MIT" }, + "node_modules/currency-symbol-map": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/currency-symbol-map/-/currency-symbol-map-5.1.0.tgz", + "integrity": "sha512-LO/lzYRw134LMDVnLyAf1dHE5tyO6axEFkR3TXjQIOmMkAM9YL6QsiUwuXzZAmFnuDJcs4hayOgyIYtViXFrLw==", + "license": "BSD-2-Clause" + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -5336,6 +5352,18 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-paginate": { + "version": "8.3.0", + "resolved": "https://registry.npmmirror.com/react-paginate/-/react-paginate-8.3.0.tgz", + "integrity": "sha512-TptZE37HPkT3R+7AszWA++LOTIsIHXcCSWMP9WW/abeF8sLpJzExFB/dVs7xbtqteJ5njF6kk+udTDC0AR3y5w==", + "license": "MIT", + "dependencies": { + "prop-types": "^15" + }, + "peerDependencies": { + "react": "^16 || ^17 || ^18 || ^19" + } + }, "node_modules/react-toastify": { "version": "11.0.5", "resolved": "https://registry.npmmirror.com/react-toastify/-/react-toastify-11.0.5.tgz", diff --git a/package.json b/package.json index 0a8b43a..091de90 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,12 @@ "dependencies": { "@tanstack/react-query": "^5.85.6", "@types/js-cookie": "^3.0.6", + "@types/react-paginate": "^7.1.4", "apexcharts": "^4.5.0", "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "currency-symbol-map": "^5.1.0", "dayjs": "^1.11.13", "flatpickr": "^4.6.13", "js-cookie": "^3.0.5", @@ -28,6 +30,7 @@ "react-dom": "19.0.0", "react-hook-form": "^7.62.0", "react-intersection-observer": "^9.16.0", + "react-paginate": "^8.3.0", "react-toastify": "^11.0.5", "tailwind-merge": "^2.6.0", "zod": "^4.1.5" diff --git a/src/app/customers/edit/[id]/page.tsx b/src/app/customers/edit/[id]/page.tsx index ac7396d..7fdd6bf 100644 --- a/src/app/customers/edit/[id]/page.tsx +++ b/src/app/customers/edit/[id]/page.tsx @@ -4,7 +4,7 @@ import { use } from "react"; import { useCustomersInfiniteQuery, useGetCustomersDetails, -} from "../../../../hooks/queries/useCustomerQueries"; +} from "@/hooks/queries/useCustomerQueries"; import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb"; import CreateCustomer from "@/components/forms/customers/CreateCustomer"; import Loading from "@/app/loading"; diff --git a/src/app/tenders/[details]/page.tsx b/src/app/tenders/[details]/page.tsx new file mode 100644 index 0000000..160355c --- /dev/null +++ b/src/app/tenders/[details]/page.tsx @@ -0,0 +1,118 @@ +"use client"; +import Loading from "@/app/loading"; +import { LocationIcon } from "@/assets/icons"; +import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb"; +import { ShowcaseSection } from "@/components/Layouts/showcase-section"; +import Status from "@/components/ui/Status"; +import { + Table, + TableBody, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useTenderDetailQuery } from "@/hooks/queries"; +import { useGetFlagQuery } from "@/hooks/queries/useFlagsQueries"; +import { msToDate, unixToDate } from "@/utils/shared"; +import getSymbolFromCurrency from "currency-symbol-map"; +import { use } from "react"; +import { TableCell } from "../../../components/ui/table"; +import Link from "next/link"; + +interface IProps { + params: Promise<{ + details: string; + }>; +} + +const TenderDetails = ({ params }: IProps) => { + const { details } = use(params); + const { data, isLoading, isError } = useTenderDetailQuery(details); + const breadcrumbItems = [ + { name: "Dashboard", href: "/" }, + { name: "Tenders", href: "/tenders" }, + { name: "Tender details", href: `/tenders/${details}` }, + ]; + const { data: flagData } = useGetFlagQuery(data?.data.country_code ?? "EU"); + if (isLoading) return ; + if (isError) return
Error
; + return ( + <> + + +
+

{data?.data.title}

+ + {data?.data.status} + +
+
+ + {data?.data.tender_id} + + + {data?.data.buyer_organization.name} + + + {data?.data.notice_publication_id} + + + {data?.data.procedure_code} + + + {data?.data.procurement_type_code} + +
+
+ + {msToDate(data?.data.application_deadline ?? 0)} + + + + {data?.data.submission_url} + + + + {msToDate(data?.data.publication_date ?? 0)} + + + {msToDate(data?.data.submission_deadline ?? 0)} + + + {msToDate(data?.data.tender_deadline ?? 0)} + +
+
+
+

Country

+
+ +

{data?.data.country_code}

+
+
+
+ {data?.data.currency && ( +
+

Currency

+
+

{data?.data.currency}

+

{getSymbolFromCurrency(data?.data.currency ?? "USD")}

+
+
+ )} + +
+
+
+

Description

+

{data?.data.description}

+
+ + + ); +}; + +export default TenderDetails; diff --git a/src/assets/icons.tsx b/src/assets/icons.tsx index 6ec256b..eed4ddc 100644 --- a/src/assets/icons.tsx +++ b/src/assets/icons.tsx @@ -344,6 +344,32 @@ export function GoogleIcon(props: IconProps) { ); } +export function LocationIcon(props: IconProps) { + return ( + + + + ); +} +export function ExclamationIcon(props: IconProps) { + return ( + + + + ); +} export function CrossIcon(props: IconProps) { return ( -

- {title} -

+ {title && ( +

+ {title} +

+ )}
{children}
diff --git a/src/components/Tables/companies/index.tsx b/src/components/Tables/companies/index.tsx index 7eb2629..83ef796 100644 --- a/src/components/Tables/companies/index.tsx +++ b/src/components/Tables/companies/index.tsx @@ -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} - {company.phone} + {formatPhoneNumber(company.phone)} {company.address.country} diff --git a/src/components/Tables/customers/index.tsx b/src/components/Tables/customers/index.tsx index ae23bb6..556a84a 100644 --- a/src/components/Tables/customers/index.tsx +++ b/src/components/Tables/customers/index.tsx @@ -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 = () => { {customer.full_name} {customer.email} - {customer.created_at} - {customer.type} - {customer.status} + {unixToDate(customer.created_at)} + + + {customer.type} + + + {customer.status} + {customer.companies?.length ? ( @@ -76,6 +84,7 @@ const CustomersTable = () => { None )} + {customer.role ?? "-"}
- )} + { + setParams((currentParams) => ({ + ...currentParams, + offset: e.selected * (allTenders?.meta?.limit ?? 10), + limit: allTenders?.meta?.limit ?? 10, + })); + }} + />
); }; diff --git a/src/components/Tables/tenders/useTenderListPresenter.ts b/src/components/Tables/tenders/useTenderListPresenter.ts index cd484b5..b5b071e 100644 --- a/src/components/Tables/tenders/useTenderListPresenter.ts +++ b/src/components/Tables/tenders/useTenderListPresenter.ts @@ -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>({ 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, diff --git a/src/components/forms/companies/CreateCompany.tsx b/src/components/forms/companies/CreateCompany.tsx index b52f475..834a7e5 100644 --- a/src/components/forms/companies/CreateCompany.tsx +++ b/src/components/forms/companies/CreateCompany.tsx @@ -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"; diff --git a/src/components/forms/customers/CreateCustomer.tsx b/src/components/forms/customers/CreateCustomer.tsx index fd67d0c..e962cf0 100644 --- a/src/components/forms/customers/CreateCustomer.tsx +++ b/src/components/forms/customers/CreateCustomer.tsx @@ -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) => {

{errors.email.message}

)} +
+