Merge pull request 'feat(submissions): Implement submissions page with paginated table' (#30) from tender-details-page into develop

Reviewed-on: https://repo.ravanertebat.com/TM/tm_panel/pulls/30
Reviewed-by: Sina Nakhostin <s.nakhostin@ravanertebat.com>
This commit is contained in:
Sina Nakhostin
2025-09-20 15:55:53 +03:30
24 changed files with 414 additions and 86 deletions
+30 -2
View File
@@ -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",
+3
View File
@@ -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"
+1 -1
View File
@@ -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";
+121
View File
@@ -0,0 +1,121 @@
"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 <Loading />;
if (isError) return <div>Error</div>;
return (
<>
<Breadcrumb items={breadcrumbItems} />
<ShowcaseSection className="!p-6.5">
<div className="flex justify-between border-b pb-4">
<p>{data?.data.title}</p>
<Status status={data?.data.status ?? "Unknown"}>
{data?.data.status}
</Status>
</div>
<div className="mt-4 flex justify-between gap-2 border-b pb-4">
<ShowcaseSection title="Tender ID">
{data?.data.tender_id}
</ShowcaseSection>
<ShowcaseSection title="Buyer Organization">
{data?.data.buyer_organization.name}
</ShowcaseSection>
<ShowcaseSection title="Notice Publication Id">
{data?.data.notice_publication_id}
</ShowcaseSection>
<ShowcaseSection title="Procedure Code">
{data?.data.procedure_code}
</ShowcaseSection>
<ShowcaseSection title="Procurement Type Code">
{data?.data.procurement_type_code}
</ShowcaseSection>
</div>
<div className="mt-4 flex justify-between gap-2 border-b pb-4">
<ShowcaseSection title="Application Deadline">
<span>{msToDate(data?.data.application_deadline ?? 0)}</span>
</ShowcaseSection>
{data?.data.submission_url && (
<ShowcaseSection title="Submission Url">
<Link href={data?.data.submission_url ?? ""} target="_blank">
{data?.data.submission_url}
</Link>
</ShowcaseSection>
)}
<ShowcaseSection title="Publication Deadline">
<span>{msToDate(data?.data.publication_date ?? 0)}</span>
</ShowcaseSection>
<ShowcaseSection title="Submission Deadline">
<span>{msToDate(data?.data.submission_deadline ?? 0)}</span>
</ShowcaseSection>
<ShowcaseSection title="Tender Deadline">
<span>{msToDate(data?.data.tender_deadline ?? 0)}</span>
</ShowcaseSection>
</div>
<div className="mt-4 flex justify-between border-b pb-4">
<div>
<h2 className="mb-6 text-lg font-semibold">Country</h2>
<div className="flex items-center gap-1">
<LocationIcon />
<p className="text-sm uppercase">{data?.data.country_code}</p>
<div
dangerouslySetInnerHTML={{ __html: flagData }}
className="flex w-7 items-center justify-center overflow-hidden rounded-md border p-0"
/>
</div>
</div>
{data?.data.currency && (
<div>
<h2 className="mb-6 text-lg font-semibold">Currency</h2>
<div className="flex items-center gap-1">
<p className="text-sm uppercase">{data?.data.currency}</p>
<p>{getSymbolFromCurrency(data?.data.currency ?? "USD")}</p>
</div>
</div>
)}
<div></div>
</div>
<div className="mt-4">
<h2 className="mb-6 text-lg font-semibold">Description</h2>
<p>{data?.data.description}</p>
</div>
</ShowcaseSection>
</>
);
};
export default TenderDetails;
+26
View File
@@ -344,6 +344,32 @@ export function GoogleIcon(props: IconProps) {
</svg>
);
}
export function LocationIcon(props: IconProps) {
return (
<svg
width="24"
height="24"
viewBox="0 -960 960 960"
fill="currentColor"
{...props}
>
<path d="M480-480q33 0 56.5-23.5T560-560q0-33-23.5-56.5T480-640q-33 0-56.5 23.5T400-560q0 33 23.5 56.5T480-480Zm0 294q122-112 181-203.5T720-552q0-109-69.5-178.5T480-800q-101 0-170.5 69.5T240-552q0 71 59 162.5T480-186Zm0 106Q319-217 239.5-334.5T160-552q0-150 96.5-239T480-880q127 0 223.5 89T800-552q0 100-79.5 217.5T480-80Zm0-480Z" />
</svg>
);
}
export function ExclamationIcon(props: IconProps) {
return (
<svg
width="24"
height="24"
viewBox="0 -960 960 960"
fill="currentColor"
{...props}
>
<path d="M440-400v-360h80v360h-80Zm0 200v-80h80v80h-80Z" />
</svg>
);
}
export function CrossIcon(props: IconProps) {
return (
<svg
+6 -4
View File
@@ -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>
+2 -1
View File
@@ -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}
+12 -3
View File
@@ -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 = () => {
+7 -1
View File
@@ -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>
+49 -25
View File
@@ -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"
+32
View File
@@ -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;
+16
View File
@@ -0,0 +1,16 @@
import { API_ENDPOINTS } from "@/lib/api";
import { flagsService } from "@/lib/api/services/flags-service";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
export const useGetFlagQuery = (countryCode: string) => {
const queryKey = useMemo(
() => [API_ENDPOINTS.FLAGS(countryCode)],
[countryCode],
);
return useQuery({
queryKey,
queryFn: () => flagsService.getCountryFlag(countryCode),
});
};
+12 -18
View File
@@ -1,29 +1,23 @@
import { API_ENDPOINTS, tendersService } from "@/lib/api";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
export const useTendersInfiniteQuery = (params?: Record<string, any>) => {
export const useTendersQuery = (params?: Record<string, any>) => {
const queryKey = useMemo(
() => [API_ENDPOINTS.TENDERS.READ_ALL, "read-tenders", params],
[params],
);
return useInfiniteQuery({
return useQuery({
queryKey,
queryFn: ({ pageParam = 0 }) =>
tendersService.tendersList({ ...params, offset: pageParam }),
initialPageParam: 0,
getNextPageParam: (lastPage) => {
if (lastPage.meta) {
const { offset, limit, total } = lastPage.meta;
const nextOffset = offset + limit;
return nextOffset < total ? nextOffset : undefined;
}
},
queryFn: () => tendersService.tendersList({ ...params }),
});
};
export const useTenderDetailQuery = (id: string) => {
const queryKey = useMemo(() => [API_ENDPOINTS.TENDERS.DETAILS(id)], [id]);
return useQuery({
queryKey,
queryFn: () => tendersService.tenderDetails(id),
});
};
+1
View File
@@ -1,4 +1,5 @@
export const API_ENDPOINTS = {
FLAGS: (countryCode: string) => `flags/${countryCode}`,
POSTS: {
READ_ALL: "posts ",
},
+5 -1
View File
@@ -1,13 +1,17 @@
import api from "../axios";
import { API_ENDPOINTS } from "../endpoints";
import { ApiResponse } from "../types";
import {
CreateCustomerCredentials,
CustomerListResponseSchema,
TAssignCustomerToCompanyCredentials,
TCustomersListResponse,
} from "../types/Customers";
export const customersService = {
getCustomers: async (params?: Record<string, any>) => {
getCustomers: async (
params?: Record<string, any>,
): Promise<ApiResponse<TCustomersListResponse>> => {
try {
const response = await api.get(API_ENDPOINTS.CUSTOMERS.READ_ALL, {
params,
+15
View File
@@ -0,0 +1,15 @@
import api from "../axios";
import { API_ENDPOINTS } from "../endpoints";
export const flagsService = {
getCountryFlag: async (countryCode: string) => {
try {
const response = await api.get(API_ENDPOINTS.FLAGS(countryCode));
return response.data;
} catch (error) {
console.error("ERROR caught in Flags Service", error);
throw error;
}
},
};
+12 -1
View File
@@ -1,7 +1,7 @@
import api from "../axios";
import { API_ENDPOINTS } from "../endpoints";
import { ApiResponse } from "../types";
import { TTenderResponse } from "../types/Tenders";
import { TTenderDetails, TTenderResponse } from "../types/Tenders";
export const tendersService = {
tendersList: async (
@@ -14,4 +14,15 @@ export const tendersService = {
throw error;
}
},
tenderDetails: async (id: string): Promise<ApiResponse<TTenderDetails>> => {
try {
return (await api.get(API_ENDPOINTS.TENDERS.DETAILS(id))).data;
} catch (error) {
console.error(
"ERROR caught in Tender Service => tender details: ",
error,
);
throw error;
}
},
};
+2
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import { createApiResponseSchema } from "./Factory";
const customersSchema = z.object({
role: z.enum(["admin", "analyst"]).optional(),
companies: z
.array(
z.object({
@@ -75,6 +76,7 @@ const createCustomerCredentials = z.object({
])
.optional(),
email: z.string().email(),
role: z.enum(["admin", "analyst"]),
employee_count: z.number().int().min(1).max(100000).optional(),
first_name: z.string().min(2).max(50).optional(),
founded_year: z.number().int().min(1800).max(2100).optional(),
+1
View File
@@ -27,3 +27,4 @@ export const TendersResponse = z.object({
tenders: z.array(TenderSchema).or(z.null()),
});
export type TTenderResponse = z.infer<typeof TendersResponse>;
export type TTenderDetails = z.infer<typeof TenderSchema>;
+26 -1
View File
@@ -1,7 +1,12 @@
import * as moment from "moment";
export const unixToDate = (unix: number) => {
return moment.unix(unix).format("DD-MM-YYYY");
return moment.unix(unix).format("YYYY/MM/DD");
};
export const msToDate = (ms: number) => {
return moment.default(ms).format("YYYY/MM/DD");
};
export const getStatusColor = <T>(status: T) => {
switch (status) {
case "active":
@@ -16,3 +21,23 @@ export const getStatusColor = <T>(status: T) => {
return "bg-gray-3";
}
};
export const formatPhoneNumber = (input?: string) => {
if (!input) return "";
let cleaned = input.replace(/[^\d+]/g, "");
const hasPlus = cleaned.startsWith("+");
if (hasPlus) {
cleaned = cleaned.slice(1);
}
const groups = [];
let i = 0;
while (i < cleaned.length) {
const size = i === 0 ? 3 : 4;
groups.push(cleaned.slice(i, i + size));
i += size;
}
return (hasPlus ? "+" : "") + groups.join(" ");
};