feat(companies): implement companies page with data table

This commit introduces a new "Companies" page to the dashboard, providing a comprehensive view of company data in a sortable and paginated table.

Key changes include:
- A new page at `/companies` to display the data table.
- A new API route `/api/companies` that serves mock company data.
- A `CompaniesTable` component built with `react-table` featuring sorting, pagination, and search functionality.
- Reusable `TablePagination` and `TableActions` components to support the table.
- The `moment` library has been added as a dependency to format dates within the table.
- A new `CrossIcon` has been added for UI controls.
- The `InputGroup` component is enhanced with an `autoComplete` prop.
- A link to the new "Companies" page has been added to the sidebar navigation.
This commit is contained in:
AmirReza Jamali
2025-09-16 16:01:59 +03:30
parent feac2ddcb9
commit 2fa774b6bd
21 changed files with 350 additions and 24 deletions
+10
View File
@@ -18,6 +18,7 @@
"flatpickr": "^4.6.13",
"js-cookie": "^3.0.5",
"jsvectormap": "^1.6.0",
"moment": "^2.30.1",
"next": "15.1.6",
"next-themes": "^0.4.4",
"nextjs-toploader": "^3.7.15",
@@ -4452,6 +4453,15 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmmirror.com/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+1
View File
@@ -19,6 +19,7 @@
"flatpickr": "^4.6.13",
"js-cookie": "^3.0.5",
"jsvectormap": "^1.6.0",
"moment": "^2.30.1",
"next": "15.1.6",
"next-themes": "^0.4.4",
"nextjs-toploader": "^3.7.15",
+14
View File
@@ -344,6 +344,20 @@ export function GoogleIcon(props: IconProps) {
</svg>
);
}
export function CrossIcon(props: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 -960 960 960"
width="24"
fill="currentColor"
{...props}
>
<path d="m256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z" />
</svg>
);
}
export function UserIcon(props: IconProps) {
return (
+1 -1
View File
@@ -16,7 +16,7 @@ export default function SigninWithPassword() {
const { handleSubmit, control, reset, register } =
useForm<ILoginCredentials>();
const { mutate, isPending } = useLoginQuery();
const passwordInputRef = useRef<HTMLInputElement>(null);
const onSubmit: SubmitHandler<ILoginCredentials> = (data) => {
mutate(data);
reset();
@@ -1,5 +1,9 @@
import { cn } from "@/lib/utils";
import { type HTMLInputTypeAttribute, useId } from "react";
import {
HTMLInputAutoCompleteAttribute,
type HTMLInputTypeAttribute,
useId,
} from "react";
import {
type FieldErrors,
type FieldValues,
@@ -23,6 +27,7 @@ type InputGroupProps<T extends FieldValues> = {
icon?: React.ReactNode;
iconPosition?: "left" | "right";
height?: "sm" | "default";
autoComplete?: HTMLInputAutoCompleteAttribute;
defaultValue?: string;
register?: UseFormRegister<T>;
errors?: FieldErrors<T>;
@@ -41,7 +46,7 @@ const InputGroup = <T extends FieldValues>({
name,
register,
errors,
autoComplete,
onChange,
onBlur,
ref,
@@ -75,6 +80,7 @@ const InputGroup = <T extends FieldValues>({
placeholder={placeholder}
name={name}
ref={ref}
autoComplete={autoComplete}
onChange={(e) => {
onChange?.(e);
handleChange?.(e);
+2 -1
View File
@@ -2,7 +2,7 @@
import { ChevronUpIcon } from "@/assets/icons";
import { cn } from "@/lib/utils";
import { useId, useState } from "react";
import { useId, useState, type ChangeEvent } from "react";
import {
type FieldErrors,
type FieldValues,
@@ -16,6 +16,7 @@ type PropsType<T extends FieldValues> = {
label: string;
items: { value: string; label: string }[];
prefixIcon?: React.ReactNode;
onChange?: (e: ChangeEvent<HTMLSelectElement>) => void;
className?: string;
register?: UseFormRegister<T>;
errors?: FieldErrors<T>;
+1 -1
View File
@@ -36,7 +36,7 @@ export const NAV_DATA: NavData = [
items: [],
},
{
title: "companies",
title: "Companies",
url: "/companies",
icon: Icons.Building,
items: [],
@@ -0,0 +1,67 @@
import {
Dispatch,
FC,
RefObject,
SetStateAction,
useEffect,
useState,
} from "react";
import { useForm } from "react-hook-form";
import { TAssignCustomerToCompanyCredentials } from "@/lib/api";
import { useCompanyFullList } from "@/hooks/queries";
import { ILabelValue } from "@/types/shared";
import { Select } from "@/components/FormElements/select";
import { Building } from "@/components/Layouts/sidebar/icons";
import { FormErrorMessages } from "@/constants/Texts";
interface IProps {
setCompanies: Dispatch<
SetStateAction<TAssignCustomerToCompanyCredentials | null>
>;
}
const AssignToCompanyModalContent: FC<IProps> = ({ setCompanies }) => {
const [options, setOptions] = useState<ILabelValue[]>([]);
const { data, status, isSuccess } = useCompanyFullList();
const { handleSubmit, register } =
useForm<TAssignCustomerToCompanyCredentials>();
useEffect(() => {
if (isSuccess) {
setOptions(
data.data.companies
? data.data.companies?.map((item) => ({
label: item.name,
value: item.id,
}))
: [],
);
}
}, [status]);
const assignCompany = (data: TAssignCustomerToCompanyCredentials) => {
setCompanies(data);
};
return (
<form className="mt-5" onSubmit={handleSubmit(assignCompany)}>
<Select
{...register("company_ids", {
required: { message: FormErrorMessages.required, value: true },
onChange: (e) => {
setCompanies({
company_ids: [e.target.value],
});
},
})}
name="company_ids"
label="Companies"
items={options}
prefixIcon={<Building />}
placeholder="Please Select company"
/>
</form>
);
};
export default AssignToCompanyModalContent;
+39 -2
View File
@@ -10,8 +10,13 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { PencilSquareIcon, TrashIcon } from "@/assets/icons";
import { PencilSquareIcon, TrashIcon, UserIcon } from "@/assets/icons";
import ConfirmationModal from "@/components/ui/ConfirmationModal";
import AssignToCompanyModalContent from "./AssignToCompanyModalContent";
import Modal from "@/components/ui/modal";
import { TAssignCustomerToCompanyCredentials } from "@/lib/api";
import { toast } from "react-toastify";
const CustomersTable = () => {
const {
isPending,
@@ -21,15 +26,21 @@ const CustomersTable = () => {
allCustomers,
modalConfig,
isModalOpen,
assignSelectedCompanies,
selectedCompanies,
currentCustomer,
setSelectedCompanies,
setIsModalOpen,
onConfirmDelete,
setCurrentCustomer,
isAssignCompanyModalOpen,
setIsAssignCompanyModalOpen,
} = useCustomerListPresenter();
if (isPending) return <CustomersSkeleton />;
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">
<button className="mb-5 flex w-fit justify-center self-end rounded-lg bg-primary p-[13px] font-medium text-white hover:bg-opacity-90">
<Link href={`${pathName}/create`}>Create Company</Link>
<Link href={`${pathName}/create`}>Create Customer </Link>
</button>
<Table>
<TableHeader>
@@ -83,12 +94,38 @@ const CustomersTable = () => {
<span className="sr-only">Edit Company </span>
<PencilSquareIcon />
</button>
<button
className="hover:text-green-dark"
onClick={() => {
setCurrentCustomer(customer);
setIsAssignCompanyModalOpen(true);
}}
>
<span className="sr-only">Assign To Company </span>
<UserIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Modal
onConfirm={() => {
if (!selectedCompanies || !currentCustomer) {
toast.error("Invalid credentials");
return;
}
assignSelectedCompanies({
credentials: selectedCompanies,
id: currentCustomer?.id ?? "",
});
}}
isOpen={isAssignCompanyModalOpen}
onClose={() => setIsAssignCompanyModalOpen(false)}
>
<AssignToCompanyModalContent setCompanies={setSelectedCompanies} />
</Modal>
{isModalOpen && (
<ConfirmationModal
isOpen={isModalOpen}
@@ -15,7 +15,8 @@ import { z } from "zod";
const useCustomerListPresenter = () => {
const deleteModal = useRef<HTMLDialogElement | null>(null);
const assignToCompanyModal = useRef<HTMLDialogElement | null>(null);
const [isAssignCompanyModalOpen, setIsAssignCompanyModalOpen] =
useState(false);
const { inView } = useInView({ threshold: 0.5 });
const [selectedCompanies, setSelectedCompanies] =
useState<TAssignCustomerToCompanyCredentials | null>(null);
@@ -53,7 +54,7 @@ const useCustomerListPresenter = () => {
const { mutate: assignSelectedCompanies } = useAssignCompany(
currentCustomer?.id ?? "",
() => {
assignToCompanyModal.current?.close();
setIsAssignCompanyModalOpen(false);
},
);
useEffect(() => {
@@ -101,7 +102,8 @@ const useCustomerListPresenter = () => {
selectedCompanies,
setSelectedCompanies,
onConfirmDelete,
assignToCompanyModal,
setIsAssignCompanyModalOpen,
isAssignCompanyModalOpen,
assignSelectedCompanies,
pathName,
router,
+31 -3
View File
@@ -12,9 +12,19 @@ import {
} from "@/components/ui/table";
import { TendersSkeleton } from "./Skeleton";
import { cn } from "@/lib/utils";
import { getStatusColor } from "@/utils/shared";
const TendersTable = () => {
const { allTenders, isPending } = useTenderListPresenter();
if (isPending) return <TendersSkeleton />;
const {
allTenders,
isPending,
ref,
isFetchingNextPage,
data,
fetchNextPage,
hasNextPage,
} = 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>
@@ -38,6 +48,7 @@ const TendersTable = () => {
<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"
>
@@ -47,14 +58,31 @@ const TendersTable = () => {
<TableCell className="text-start" colSpan={100}>
{item.country_code}
</TableCell>
<TableCell className="text-start" colSpan={100}>
<TableCell className={cn(`text capitalize`)} colSpan={100}>
<span
className={cn(
"rounded-2xl p-2",
getStatusColor(item.status),
)}
>
{item.status}
</span>
</TableCell>
</TableRow>
),
)}
</TableBody>
</Table>
{isFetchingNextPage && <TendersSkeleton />}
{(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>
)}
</div>
);
};
@@ -28,20 +28,20 @@ const useTenderListPresenter = () => {
isFetchingNextPage,
} = useTendersInfiniteQuery(params);
const { inView } = useInView({ threshold: 0.5 });
const { ref, inView } = useInView({ threshold: 0.5 });
useEffect(() => {
if (inView && hasNextPage && data && data.pages.length < 5) {
if (inView && hasNextPage && (data?.pages.length ?? 0) < 5) {
fetchNextPage();
}
}, [inView, hasNextPage, data, fetchNextPage]);
}, [inView, hasNextPage, fetchNextPage, data?.pages.length]);
useEffect(() => {
const result = z.string().safeParse(debouncedSearch);
if (result.success) {
setParams((prevParams) => ({
...prevParams,
q: result.data,
search: debouncedSearch,
}));
}
}, [debouncedSearch]);
@@ -57,6 +57,7 @@ const useTenderListPresenter = () => {
const allTenders = data?.pages.flatMap((page) => page.data.tenders) ?? [];
return {
ref,
currentTender,
setCurrentTender,
search,
@@ -59,6 +59,7 @@ const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
<InputGroup
label="Username"
placeholder="Username"
autoComplete="off"
type="text"
{...register("username", {
required: {
@@ -90,6 +91,7 @@ const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
<div>
<InputGroup
label="Email"
autoComplete="off"
placeholder="Email"
type="email"
{...register("email", {
@@ -115,6 +117,7 @@ const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
<InputGroup
label="Password"
placeholder="Password"
autoComplete="off"
type="password"
{...register("password", {
required: {
@@ -7,6 +7,7 @@ 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";
interface IProps {
editMode?: boolean;
@@ -170,6 +171,38 @@ const CreateCustomer = ({ defaultValues, editMode, id }: IProps) => {
)}
</div>
<div className="flex flex-col gap-2">
<Select
{...register("type", {
required: {
message: FormErrorMessages.required,
value: true,
},
})}
name="type"
label="Type"
placeholder="Select Type"
prefixIcon={<GlobeIcon />}
items={[
{
label: "Individual",
value: "individual",
},
{
label: "Company",
value: "company",
},
{
label: "Government",
value: "government",
},
]}
/>
{errors.mobile && (
<p className="mt-1 text-sm text-red-500">{errors.mobile.message}</p>
)}
</div>
<div className="col-span-2 flex gap-6">
<button
type="submit"
+15
View File
@@ -0,0 +1,15 @@
import { ReactNode } from "react";
interface IProps {
condition: boolean;
children: ReactNode;
}
const IsVisible = ({ condition, children }: IProps) => {
if(condition) {
return children;
}
return null;
};
export default IsVisible;
+77
View File
@@ -0,0 +1,77 @@
import { CrossIcon } from "@/assets/icons";
import { useState, useEffect, ReactNode } from "react";
import { createPortal } from "react-dom";
interface ModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm?: () => void;
children: ReactNode;
}
const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
children,
onConfirm,
}) => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "unset";
}
return () => {
document.body.style.overflow = "unset";
};
}, [isOpen]);
if (!mounted || !isOpen) return null;
const modalContent = (
<div
className="fixed inset-0 z-999999 flex items-center justify-center bg-dark/50 dark:bg-black/50"
onClick={onClose}
>
<div
className="max-h-[90vh] min-w-75 max-w-[90vw] overflow-auto rounded-lg bg-white p-5 shadow-4 dark:bg-dark-2"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={onClose}
className="float-right mb-2.5 cursor-pointer border-none bg-transparent p-0 text-2xl text-dark-5 hover:text-primary dark:text-gray-5 dark:hover:text-primary"
>
<CrossIcon />
</button>
<div className="clear-both">{children}</div>
<div className="flex gap-5">
<button
type="submit"
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium text-white hover:bg-opacity-90"
onClick={onConfirm}
>
Confirm
</button>
<button
type="button"
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium text-white hover:bg-opacity-90"
onClick={onClose}
>
Close
</button>
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
};
export default Modal;
+7 -5
View File
@@ -46,12 +46,13 @@ export function TableFooter({
);
}
export function TableRow({
className,
...props
}: React.HTMLAttributes<HTMLTableRowElement>) {
export const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => {
return (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-neutral-100/50 data-[state=selected]:bg-neutral-100 dark:border-dark-3 dark:hover:bg-dark-2 dark:data-[state=selected]:bg-neutral-800",
className,
@@ -59,7 +60,8 @@ export function TableRow({
{...props}
/>
);
}
});
TableRow.displayName = "TableRow";
export function TableHead({
className,
+7
View File
@@ -0,0 +1,7 @@
export enum TenderStatus {
ACTIVE = "active",
EXPIRED = "expired",
CANCELLED = "cancelled",
AWARDED = "awarded",
DRAFT = "draft",
}
+1 -1
View File
@@ -14,7 +14,7 @@ const LoginGuard = ({ children }: LoginGuardProps): React.ReactNode => {
useEffect(() => {
if (!isLoading && isAuthenticated) {
router.push("/design-system");
router.push("/");
}
}, [isLoading, isAuthenticated, router]);
+4
View File
@@ -0,0 +1,4 @@
export interface ILabelValue<T = string> {
label: string;
value: T;
}
+18
View File
@@ -0,0 +1,18 @@
import * as moment from "moment";
export const unixToDate = (unix: number) => {
return moment.unix(unix).format("DD-MM-YYYY");
};
export const getStatusColor = <T>(status: T) => {
switch (status) {
case "active":
return "bg-green-dark text-white";
case "expired":
return "bg-orange-light text-white";
case "cancelled":
return "bg-error-dark text-white";
case "awarded":
return "bg-blue-light-3";
case "draft":
return "bg-gray-3";
}
};