Initial commit for new panel

This commit is contained in:
AmirReza Jamali
2025-09-09 11:20:26 +03:30
commit 1d4ccb3575
343 changed files with 20031 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export function AdminsSkeleton() {
return (
<div className="rounded-[10px] bg-white px-7.5 pb-4 pt-7.5 shadow-1 dark:bg-gray-dark dark:shadow-card">
<h2 className="mb-5.5 text-body-2xlg font-bold text-dark dark:text-white">
Admins
</h2>
<Table>
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-center">
<TableHead className="!text-left">Full name</TableHead>
<TableHead>Email</TableHead>
<TableHead className="!text-right">Role</TableHead>
<TableHead>Username</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
<TableCell colSpan={100}>
<Skeleton className="h-8" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useAdminsPresenter } from "./useAdminsPresenter";
import { PencilSquareIcon, TrashIcon } from "@/assets/icons";
import ConfirmationModal from "@/components/ui/ConfirmationModal";
import { AdminsSkeleton } from "./Skeleton";
import Link from "next/link";
const AdminsTable = () => {
const {
allUsers,
router,
isPending,
onConfirmDelete,
isModalOpen,
setIsModalOpen,
modalConfig,
setCurrentUser,
pathName,
} = useAdminsPresenter();
if (isPending) return <AdminsSkeleton />;
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 Admin</Link>
</button>
<Table>
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
<TableHead colSpan={100} className="text-start font-extrabold">
Full name
</TableHead>
<TableHead colSpan={100} className="text-start font-extrabold">
Email
</TableHead>
<TableHead colSpan={100} className="text-start font-extrabold">
Role
</TableHead>
<TableHead colSpan={100} className="text-start font-extrabold">
Username
</TableHead>
<TableHead colSpan={100} className="text-start font-extrabold">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{allUsers.map((item) => {
return (
<TableRow key={item.id} className="odd:bg-gray-2 dark:odd:bg-gray-7">
<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.role}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item.username}
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
className="hover:text-red-500"
onClick={() => {
setCurrentUser(item);
setIsModalOpen(true);
}}
>
<span className="sr-only">Delete Admin </span>
<TrashIcon />
</button>
<button
className="hover:text-primary"
onClick={() => router.push(`/admins/edit/${item.id}`)}
>
<span className="sr-only">Edit Admin </span>
<PencilSquareIcon />
</button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
{isModalOpen && (
<ConfirmationModal
isOpen={isModalOpen}
title={modalConfig.title || "Confirm"}
message={modalConfig.message || "Are you sure?"}
variant={modalConfig.variant || "warning"}
size={modalConfig.size || "md"}
confirmText={modalConfig.confirmText ?? "Confirm"}
cancelText={modalConfig.cancelText ?? "Cancel"}
onConfirm={() => {
onConfirmDelete();
}}
onCancel={() => setIsModalOpen(false)}
/>
)}
</div>
);
};
export default AdminsTable;
@@ -0,0 +1,84 @@
"use client";
import { useAdminListInfiniteQuery, useDeleteAdmin } from "@/hooks/queries";
import useDebounce from "@/hooks/useDebounce";
import { useState, useEffect, useRef } from "react";
import { z } from "zod";
import { useInView } from "react-intersection-observer";
import { IUser } from "@/lib/api";
import { ConfirmationModalProps } from "@/components/ui/ConfirmationModal";
import { usePathname, useRouter } from "next/navigation";
export const useAdminsPresenter = () => {
const [currentUser, setCurrentUser] = useState<IUser | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const router = useRouter();
const [modalConfig, setModalConfig] = useState<
Partial<ConfirmationModalProps>
>({});
const [search, setSearch] = useState("");
const [params, setParams] = useState<Record<string, any>>({
sort: "created_at",
});
const debouncedSearch = useDebounce(search, 1000);
const pathName = usePathname();
const {
data,
error,
isPending,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useAdminListInfiniteQuery(params);
const { mutate: deleteAdmin } = useDeleteAdmin(() => {
setIsModalOpen(false);
});
const { inView } = useInView({ threshold: 0.5 });
useEffect(() => {
if (inView && hasNextPage && data && data.pages.length < 5) {
fetchNextPage();
}
}, [inView, hasNextPage, data, fetchNextPage]);
useEffect(() => {
const result = z.string().safeParse(debouncedSearch);
if (result.success) {
setParams((prevParams) => ({
...prevParams,
}));
}
}, [debouncedSearch]);
const handleFilterChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
};
const onConfirmDelete = () => {
if (currentUser) {
deleteAdmin(currentUser.id);
}
};
const allUsers = data?.pages.flatMap((page) => page.data.users) ?? [];
return {
allUsers,
currentAdmin: currentUser,
error,
handleFilterChange,
isFetchingNextPage,
fetchNextPage,
isPending,
onConfirmDelete,
isModalOpen,
setIsModalOpen,
modalConfig,
setModalConfig,
search,
hasNextPage,
data,
router,
setCurrentUser,
pathName,
};
};
@@ -0,0 +1,47 @@
"use client";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useCompanyListPresenter } from "./useCompanyListPresenter";
export function CompaniesSkeleton() {
const { columns } = useCompanyListPresenter();
return (
<div className="rounded-[10px] bg-white px-7.5 pb-4 pt-7.5 shadow-1 dark:bg-gray-dark dark:shadow-card">
<h2 className="mb-5.5 text-body-2xlg font-bold text-dark dark:text-white">
Companies
</h2>
<Table>
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-center">
{columns.map((column) => (
<TableHead
colSpan={100}
className="text-start font-extrabold"
key={column}
>
{column}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
<TableCell colSpan={100}>
<Skeleton className="h-8" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
"use client";
import Link from "next/link";
import { CompaniesSkeleton } from "./Skeleton";
import { useCompanyListPresenter } from "./useCompanyListPresenter";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { PencilSquareIcon, TrashIcon } from "@/assets/icons";
import ConfirmationModal from "@/components/ui/ConfirmationModal";
interface IProps {}
const CompaniesTable = ({}: IProps) => {
const {
isPending,
pathName,
columns,
allCompanies,
setIsModalOpen,
router,
isModalOpen,
setCurrentCompany,
onConfirmDelete,
modalConfig,
} = useCompanyListPresenter();
if (isPending) return <CompaniesSkeleton />;
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>
</button>
<Table>
<TableHeader>
<TableRow>
{columns.map((columns) => (
<TableHead
colSpan={100}
className="text-start font-extrabold"
key={columns}
>
{columns}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{allCompanies.map((company) =>
!company ? (
<h6>No Data was Found</h6>
) : (
<TableRow key={company.id} className="odd:bg-gray-2 dark:odd:bg-gray-7">
<TableHead className="text-start" colSpan={100}>
{company.name}
</TableHead>
<TableHead className="text-start" colSpan={100}>
{company.email}
</TableHead>
<TableHead className="text-start" colSpan={100}>
{company.phone}
</TableHead>
<TableHead className="text-start" colSpan={100}>
{company.address.country}
</TableHead>
<TableHead className="text-start" colSpan={100}>
{company.address.state}
</TableHead>
<TableHead className="text-start" colSpan={100}>
{company.address.city}
</TableHead>
<TableHead className="text-start" colSpan={100}>
{company.address.postal_code}
</TableHead>
<TableHead className="text-start" colSpan={100}>
{company.language}
</TableHead>
<TableHead className="text-start" colSpan={100}>
{company.currency}
</TableHead>
<TableHead className="text-start" colSpan={100}>
{company.employee_count}
</TableHead>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
className="hover:text-red-500"
onClick={() => {
setCurrentCompany(company);
setIsModalOpen(true);
}}
>
<span className="sr-only">Delete Company </span>
<TrashIcon />
</button>
<button
className="hover:text-primary"
onClick={() =>
router.push(`${pathName}/edit/${company.id}`)
}
>
<span className="sr-only">Edit Company </span>
<PencilSquareIcon />
</button>
</div>
</TableCell>
</TableRow>
),
)}
</TableBody>
</Table>
{isModalOpen && (
<ConfirmationModal
isOpen={isModalOpen}
title={modalConfig.title || "Confirm"}
message={modalConfig.message || "Are you sure?"}
variant={modalConfig.variant || "default"}
size={modalConfig.size || "md"}
confirmText={modalConfig.confirmText ?? "Confirm"}
cancelText={modalConfig.cancelText ?? "Cancel"}
onConfirm={() => {
onConfirmDelete();
}}
onCancel={() => setIsModalOpen(false)}
/>
)}
</div>
);
};
export default CompaniesTable;
@@ -0,0 +1,101 @@
"use client";
import { ConfirmationModalProps } from "@/components/ui/ConfirmationModal";
import {
useCompaniesInfiniteQuery,
useDeleteCompanyQuery,
} from "@/hooks/queries";
import useDebounce from "@/hooks/useDebounce";
import { TCompany } from "@/lib/api";
import { usePathname } from "next/navigation";
import { useRouter } from "next/navigation";
import { ChangeEvent, useEffect, useRef, useState } from "react";
import { useInView } from "react-intersection-observer";
import { z } from "zod";
export const useCompanyListPresenter = () => {
const deleteModal = useRef<HTMLDialogElement | null>(null);
const { inView } = useInView({ threshold: 0.5 });
const [currentCompany, setCurrentCompany] = useState<TCompany | null>(null);
const [search, setSearch] = useState("");
const [modalConfig, setModalConfig] = useState<
Partial<ConfirmationModalProps>
>({});
const [params, setParams] = useState<Record<string, any>>({
sort: "created_at",
});
const [isModalOpen, setIsModalOpen] = useState(false);
const router = useRouter();
const columns = [
"name",
"email",
"phone",
"country",
"state",
"city",
"postal_code",
"language",
"currency",
"employee count",
"actions",
];
const pathName = usePathname();
const debouncedSearch = useDebounce(search, 1000);
const {
data,
error,
isPending,
isError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useCompaniesInfiniteQuery(params);
useEffect(() => {
if (inView && hasNextPage && data && data.pages.length < 5) {
fetchNextPage();
}
}, [inView, hasNextPage, data, fetchNextPage]);
const { mutate: deleteCompany } = useDeleteCompanyQuery(() =>
setIsModalOpen(false),
);
useEffect(() => {
const result = z.string().safeParse(debouncedSearch);
if (result.success) {
setParams((prevParams) => ({
...prevParams,
}));
}
}, [debouncedSearch]);
const handleFilterChange = (e: ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
};
const onConfirmDelete = () => {
if (currentCompany) {
deleteCompany(currentCompany.id);
}
};
const allCompanies = data?.pages.flatMap((page) => page.data.companies) ?? [];
return {
allCompanies,
handleFilterChange,
onConfirmDelete,
deleteCompany,
isFetchingNextPage,
data,
columns,
pathName,
currentCompany,
hasNextPage,
fetchNextPage,
modalConfig,
setModalConfig,
setCurrentCompany,
deleteModal,
error,
isModalOpen,
setIsModalOpen,
router,
isPending,
isError,
search,
};
};
@@ -0,0 +1,43 @@
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export function CustomersSkeleton() {
return (
<div className="rounded-[10px] bg-white px-7.5 pb-4 pt-7.5 shadow-1 dark:bg-gray-dark dark:shadow-card">
<h2 className="mb-5.5 text-body-2xlg font-bold text-dark dark:text-white">
Tenders
</h2>
<Table>
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-center">
<TableHead className="uppercase">full name</TableHead>
<TableHead className="uppercase">email</TableHead>
<TableHead className="uppercase">created at</TableHead>
<TableHead className="uppercase">type</TableHead>
<TableHead className="uppercase">status</TableHead>
<TableHead className="uppercase">company</TableHead>
<TableHead className="uppercase">actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
<TableCell colSpan={100}>
<Skeleton className="h-8" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import Link from "next/link";
import { CustomersSkeleton } from "./Skeleton";
import useCustomerListPresenter from "./useCustomerListPresenter";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { PencilSquareIcon, TrashIcon } from "@/assets/icons";
import ConfirmationModal from "@/components/ui/ConfirmationModal";
const CustomersTable = () => {
const {
isPending,
pathName,
router,
columns,
allCustomers,
modalConfig,
isModalOpen,
setIsModalOpen,
onConfirmDelete,
setCurrentCustomer,
} = 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>
</button>
<Table>
<TableHeader>
<TableRow>
{columns.map((columns) => (
<TableHead
colSpan={100}
className="text-start font-extrabold uppercase"
key={columns}
>
{columns}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{allCustomers.map((customer, index) => (
<TableRow key={index}>
<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}>{customer.status}</TableCell>
<TableCell colSpan={100}>
{customer.companies?.length ? (
customer.companies.map((c) => <span>{c.name}, </span>)
) : (
<span>None</span>
)}
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
className="hover:text-red-500"
onClick={() => {
setCurrentCustomer(customer);
setIsModalOpen(true);
}}
>
<span className="sr-only">Delete Company </span>
<TrashIcon />
</button>
<button
className="hover:text-primary"
onClick={() =>
router.push(`${pathName}/edit/${customer.id}`)
}
>
<span className="sr-only">Edit Company </span>
<PencilSquareIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{isModalOpen && (
<ConfirmationModal
isOpen={isModalOpen}
title={modalConfig.title || "Confirm"}
message={modalConfig.message || "Are you sure?"}
variant={modalConfig.variant || "default"}
size={modalConfig.size || "md"}
confirmText={modalConfig.confirmText ?? "Confirm"}
cancelText={modalConfig.cancelText ?? "Cancel"}
onConfirm={() => {
onConfirmDelete();
}}
onCancel={() => setIsModalOpen(false)}
/>
)}
</div>
);
};
export default CustomersTable;
@@ -0,0 +1,115 @@
"use client";
import { ConfirmationModalProps } from "@/components/ui/ConfirmationModal";
import {
useAssignCompany,
useCustomersInfiniteQuery,
useDeleteCustomerQuery,
} from "@/hooks/queries";
import useDebounce from "@/hooks/useDebounce";
import { TAssignCustomerToCompanyCredentials, TCustomer } from "@/lib/api";
import { usePathname, useRouter } from "next/navigation";
import { ChangeEvent, useEffect, useRef, useState } from "react";
import { useInView } from "react-intersection-observer";
import { z } from "zod";
const useCustomerListPresenter = () => {
const deleteModal = useRef<HTMLDialogElement | null>(null);
const assignToCompanyModal = useRef<HTMLDialogElement | null>(null);
const { inView } = useInView({ threshold: 0.5 });
const [selectedCompanies, setSelectedCompanies] =
useState<TAssignCustomerToCompanyCredentials | null>(null);
const [currentCustomer, setCurrentCustomer] = useState<TCustomer | null>(
null,
);
const [modalConfig, setModalConfig] = useState<
Partial<ConfirmationModalProps>
>({});
const [isModalOpen, setIsModalOpen] = useState(false);
const pathName = usePathname();
const [search, setSearch] = useState("");
const [params, setParams] = useState<Record<string, any>>({
sort: "created_at",
});
const router = useRouter();
const debouncedSearch = useDebounce(search, 1000);
const {
data,
error,
isPending,
isError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useCustomersInfiniteQuery(params);
useEffect(() => {
if (inView && hasNextPage && data && data.pages.length < 5) {
fetchNextPage();
}
}, [inView, hasNextPage, data, fetchNextPage]);
const { mutate: deleteCustomer } = useDeleteCustomerQuery(() =>
setIsModalOpen(false),
);
const { mutate: assignSelectedCompanies } = useAssignCompany(
currentCustomer?.id ?? "",
() => {
assignToCompanyModal.current?.close();
},
);
useEffect(() => {
const result = z.string().safeParse(debouncedSearch);
if (result.success) {
setParams((prevParams) => ({
...prevParams,
}));
}
}, [debouncedSearch]);
const handleFilterChange = (e: ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
};
const columns = [
"full name",
"email",
"created at",
"type",
"status",
"company",
"actions",
];
const onConfirmDelete = () => {
if (currentCustomer) {
deleteCustomer(currentCustomer.id);
}
};
const customers = data?.pages.flatMap((page) => page.data.customers) ?? [];
return {
hasNextPage,
fetchNextPage,
allCustomers: customers,
currentCustomer,
deleteCustomer,
deleteModal,
error,
handleFilterChange,
isError,
isFetchingNextPage,
columns,
data,
isPending,
search,
setCurrentCustomer,
selectedCompanies,
setSelectedCompanies,
onConfirmDelete,
assignToCompanyModal,
assignSelectedCompanies,
pathName,
router,
modalConfig,
setModalConfig,
isModalOpen,
setIsModalOpen,
};
};
export default useCustomerListPresenter;
+121
View File
@@ -0,0 +1,121 @@
import * as logos from "@/assets/logos";
export async function getTopProducts() {
// Fake delay
await new Promise((resolve) => setTimeout(resolve, 2000));
return [
{
image: "/images/product/product-01.png",
name: "Apple Watch Series 7",
category: "Electronics",
price: 296,
sold: 22,
profit: 45,
},
{
image: "/images/product/product-02.png",
name: "Macbook Pro M1",
category: "Electronics",
price: 546,
sold: 12,
profit: 125,
},
{
image: "/images/product/product-03.png",
name: "Dell Inspiron 15",
category: "Electronics",
price: 443,
sold: 64,
profit: 247,
},
{
image: "/images/product/product-04.png",
name: "HP Probook 450",
category: "Electronics",
price: 499,
sold: 72,
profit: 103,
},
];
}
export async function getInvoiceTableData() {
// Fake delay
await new Promise((resolve) => setTimeout(resolve, 1400));
return [
{
name: "Free package",
price: 0.0,
date: "2023-01-13T18:00:00.000Z",
status: "Paid",
},
{
name: "Standard Package",
price: 59.0,
date: "2023-01-13T18:00:00.000Z",
status: "Paid",
},
{
name: "Business Package",
price: 99.0,
date: "2023-01-13T18:00:00.000Z",
status: "Unpaid",
},
{
name: "Standard Package",
price: 59.0,
date: "2023-01-13T18:00:00.000Z",
status: "Pending",
},
];
}
export async function getTopChannels() {
// Fake delay
await new Promise((resolve) => setTimeout(resolve, 1500));
return [
{
name: "Google",
visitors: 3456,
revenues: 4220,
sales: 3456,
conversion: 2.59,
logo: logos.google,
},
{
name: "X.com",
visitors: 3456,
revenues: 4220,
sales: 3456,
conversion: 2.59,
logo: logos.x,
},
{
name: "Github",
visitors: 3456,
revenues: 4220,
sales: 3456,
conversion: 2.59,
logo: logos.github,
},
{
name: "Vimeo",
visitors: 3456,
revenues: 4220,
sales: 3456,
conversion: 2.59,
logo: logos.vimeo,
},
{
name: "Facebook",
visitors: 3456,
revenues: 4220,
sales: 3456,
conversion: 2.59,
logo: logos.facebook,
},
];
}
+31
View File
@@ -0,0 +1,31 @@
import { IconProps } from "@/types/icon-props";
export function PreviewIcon(props: IconProps) {
return (
<svg
width={20}
height={20}
viewBox="0 0 20 20"
fill="currentColor"
{...props}
>
<path d="M10 6.875a3.125 3.125 0 100 6.25 3.125 3.125 0 000-6.25zM8.123 10a1.875 1.875 0 113.75 0 1.875 1.875 0 01-3.75 0z" />
<path d="M10 2.708c-3.762 0-6.296 2.254-7.767 4.164l-.026.035c-.333.432-.64.83-.847 1.3C1.137 8.71 1.04 9.26 1.04 10s.096 1.29.319 1.793c.208.47.514.868.847 1.3l.026.034c1.47 1.911 4.005 4.165 7.766 4.165 3.762 0 6.296-2.254 7.766-4.165l.027-.034c.333-.432.639-.83.847-1.3.222-.504.319-1.053.319-1.793s-.097-1.29-.32-1.793c-.207-.47-.513-.868-.846-1.3l-.027-.035c-1.47-1.91-4.004-4.164-7.766-4.164zM3.223 7.635C4.582 5.87 6.79 3.958 9.999 3.958s5.418 1.913 6.776 3.677c.366.475.58.758.72 1.077.132.298.213.662.213 1.288s-.081.99-.213 1.288c-.14.319-.355.602-.72 1.077-1.358 1.764-3.568 3.677-6.776 3.677-3.208 0-5.417-1.913-6.775-3.677-.366-.475-.58-.758-.72-1.077-.132-.298-.213-.662-.213-1.288s.08-.99.212-1.288c.141-.319.355-.602.72-1.077z" />
</svg>
);
}
export function DownloadIcon(props: IconProps) {
return (
<svg
width={20}
height={20}
viewBox="0 0 20 20"
fill="currentColor"
{...props}
>
<path d="M10.461 13.755a.625.625 0 01-.922 0L6.205 10.11a.625.625 0 11.923-.843l2.247 2.457V2.5a.625.625 0 111.25 0v9.223l2.247-2.457a.625.625 0 01.923.843l-3.334 3.646z" />
<path d="M3.125 12.5a.625.625 0 10-1.25 0v.046c0 1.14 0 2.058.097 2.78.101.75.317 1.382.818 1.884.502.501 1.133.717 1.884.818.722.097 1.64.097 2.78.097h5.092c1.14 0 2.058 0 2.78-.097.75-.101 1.382-.317 1.884-.818.501-.502.717-1.134.818-1.884.097-.722.097-1.64.097-2.78V12.5a.625.625 0 10-1.25 0c0 1.196-.001 2.03-.086 2.66-.082.611-.233.935-.463 1.166-.23.23-.555.38-1.166.463-.63.085-1.464.086-2.66.086h-5c-1.196 0-2.03-.001-2.66-.086-.611-.082-.935-.233-1.166-.463-.23-.23-.38-.555-.463-1.166-.085-.63-.086-1.464-.086-2.66z" />
</svg>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { TrashIcon } from "@/assets/icons";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import dayjs from "dayjs";
import { getInvoiceTableData } from "./fetch";
import { DownloadIcon, PreviewIcon } from "./icons";
export async function InvoiceTable() {
const data = await getInvoiceTableData();
return (
<div className="rounded-[10px] border border-stroke bg-white p-4 shadow-1 dark:border-dark-3 dark:bg-gray-dark dark:shadow-card sm:p-7.5">
<Table>
<TableHeader>
<TableRow className="border-none bg-[#F7F9FC] dark:bg-dark-2 [&>th]:py-4 [&>th]:text-base [&>th]:text-dark [&>th]:dark:text-white">
<TableHead className="min-w-[155px] xl:pl-7.5">Package</TableHead>
<TableHead>Invoice Date</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right xl:pr-7.5">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((item, index) => (
<TableRow key={index} className="border-[#eee] dark:border-dark-3">
<TableCell className="min-w-[155px] xl:pl-7.5">
<h5 className="text-dark dark:text-white">{item.name}</h5>
<p className="mt-[3px] text-body-sm font-medium">
${item.price}
</p>
</TableCell>
<TableCell>
<p className="text-dark dark:text-white">
{dayjs(item.date).format("MMM DD, YYYY")}
</p>
</TableCell>
<TableCell>
<div
className={cn(
"max-w-fit rounded-full px-3.5 py-1 text-sm font-medium",
{
"bg-[#219653]/[0.08] text-[#219653]":
item.status === "Paid",
"bg-[#D34053]/[0.08] text-[#D34053]":
item.status === "Unpaid",
"bg-[#FFA70B]/[0.08] text-[#FFA70B]":
item.status === "Pending",
},
)}
>
{item.status}
</div>
</TableCell>
<TableCell className="xl:pr-7.5">
<div className="flex items-center justify-end gap-x-3.5">
<button className="hover:text-primary">
<span className="sr-only">View Invoice</span>
<PreviewIcon />
</button>
<button className="hover:text-primary">
<span className="sr-only">Delete Invoice</span>
<TrashIcon />
</button>
<button className="hover:text-primary">
<span className="sr-only">Download Invoice</span>
<DownloadIcon />
</button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,42 @@
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export function TendersSkeleton() {
return (
<div className="rounded-[10px] bg-white px-7.5 pb-4 pt-7.5 shadow-1 dark:bg-gray-dark dark:shadow-card">
<h2 className="mb-5.5 text-body-2xlg font-bold text-dark dark:text-white">
Tenders
</h2>
<Table>
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-center">
<TableHead>Full name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Company</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
<TableCell colSpan={100}>
<Skeleton className="h-8" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import useTenderListPresenter from "./useTenderListPresenter";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { TendersSkeleton } from "./Skeleton";
const TendersTable = () => {
const { allTenders, isPending } = useTenderListPresenter();
if (isPending) return <TendersSkeleton />;
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">
Title
</TableHead>
<TableHead colSpan={100} className="text-start font-extrabold">
Country Code
</TableHead>
<TableHead colSpan={100} className="text-start font-extrabold">
Status
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{allTenders.map((item) => {
return (
<TableRow
key={item.id}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<TableCell className="text-start" colSpan={100}>
{item.title}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item.country_code}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item.status}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
};
export default TendersTable;
@@ -0,0 +1,80 @@
"use client";
import { ConfirmationModalProps } from "@/components/ui/ConfirmationModal";
import { useTendersInfiniteQuery } from "@/hooks/queries";
import useDebounce from "@/hooks/useDebounce";
import { TCustomer } from "@/lib/api";
import { usePathname } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useInView } from "react-intersection-observer";
import { z } from "zod";
const useTenderListPresenter = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [currentTender, setCurrentTender] = useState<TCustomer | null>(null);
const [search, setSearch] = useState("");
const [modalConfig, setModalConfig] = useState<
Partial<ConfirmationModalProps>
>({});
const [params, setParams] = useState<Record<string, any>>({
sort: "created_at",
});
const debouncedSearch = useDebounce(search, 1000);
const pathName = usePathname();
const {
data,
error,
isPending,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useTendersInfiniteQuery(params);
const { inView } = useInView({ threshold: 0.5 });
useEffect(() => {
if (inView && hasNextPage && data && data.pages.length < 5) {
fetchNextPage();
}
}, [inView, hasNextPage, data, fetchNextPage]);
useEffect(() => {
const result = z.string().safeParse(debouncedSearch);
if (result.success) {
setParams((prevParams) => ({
...prevParams,
q: result.data,
}));
}
}, [debouncedSearch]);
const handleFilterChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
};
const onConfirmDelete = () => {
if (currentTender) {
deleteTender(currentTender.id);
}
};
const allTenders = data?.pages.flatMap((page) => page.data.tenders) ?? [];
return {
currentTender,
setCurrentTender,
search,
handleFilterChange,
onConfirmDelete,
allTenders,
error,
isPending,
hasNextPage,
data,
fetchNextPage,
isFetchingNextPage,
pathName,
isModalOpen,
setIsModalOpen,
modalConfig,
setModalConfig,
};
};
export default useTenderListPresenter;
@@ -0,0 +1,72 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { compactFormat, standardFormat } from "@/lib/format-number";
import { cn } from "@/lib/utils";
import Image from "next/image";
import { getTopChannels } from "../fetch";
export async function TopChannels({ className }: { className?: string }) {
const data = await getTopChannels();
return (
<div
className={cn(
"grid rounded-[10px] bg-white px-7.5 pb-4 pt-7.5 shadow-1 dark:bg-gray-dark dark:shadow-card",
className,
)}
>
<h2 className="mb-4 text-body-2xlg font-bold text-dark dark:text-white">
Top Channels
</h2>
<Table>
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-center">
<TableHead className="min-w-[120px] !text-left">Source</TableHead>
<TableHead>Visitors</TableHead>
<TableHead className="!text-right">Revenues</TableHead>
<TableHead>Sales</TableHead>
<TableHead>Conversion</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((channel, i) => (
<TableRow
className="text-center text-base font-medium text-dark dark:text-white"
key={channel.name + i}
>
<TableCell className="flex min-w-fit items-center gap-3">
<Image
src={channel.logo}
className="size-8 rounded-full object-cover"
width={40}
height={40}
alt={channel.name + " Logo"}
role="presentation"
/>
<div className="">{channel.name}</div>
</TableCell>
<TableCell>{compactFormat(channel.visitors)}</TableCell>
<TableCell className="!text-right text-green-light-1">
${standardFormat(channel.revenues)}
</TableCell>
<TableCell>{channel.sales}</TableCell>
<TableCell>{channel.conversion}%</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,41 @@
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export function TopChannelsSkeleton() {
return (
<div className="rounded-[10px] bg-white px-7.5 pb-4 pt-7.5 shadow-1 dark:bg-gray-dark dark:shadow-card">
<h2 className="mb-5.5 text-body-2xlg font-bold text-dark dark:text-white">
Top Channels
</h2>
<Table>
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-center">
<TableHead className="!text-left">Source</TableHead>
<TableHead>Visitors</TableHead>
<TableHead className="!text-right">Revenues</TableHead>
<TableHead>Sales</TableHead>
<TableHead>Conversion</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
<TableCell colSpan={100}>
<Skeleton className="h-8" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,71 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import Image from "next/image";
import { getTopProducts } from "../fetch";
export async function TopProducts() {
const data = await getTopProducts();
return (
<div className="rounded-[10px] bg-white shadow-1 dark:bg-gray-dark dark:shadow-card">
<div className="px-6 py-4 sm:px-7 sm:py-5 xl:px-8.5">
<h2 className="text-2xl font-bold text-dark dark:text-white">
Top Products
</h2>
</div>
<Table>
<TableHeader>
<TableRow className="border-t text-base [&>th]:h-auto [&>th]:py-3 sm:[&>th]:py-4.5">
<TableHead className="min-w-[120px] pl-5 sm:pl-6 xl:pl-7.5">
Product Name
</TableHead>
<TableHead>Category</TableHead>
<TableHead>Price</TableHead>
<TableHead>Sold</TableHead>
<TableHead className="pr-5 text-right sm:pr-6 xl:pr-7.5">
Profit
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((product) => (
<TableRow
className="text-base font-medium text-dark dark:text-white"
key={product.name + product.profit}
>
<TableCell className="flex min-w-fit items-center gap-3 pl-5 sm:pl-6 xl:pl-7.5">
<Image
src={product.image}
className="aspect-[6/5] w-15 rounded-[5px] object-cover"
width={60}
height={50}
alt={"Image for product " + product.name}
role="presentation"
/>
<div>{product.name}</div>
</TableCell>
<TableCell>{product.category}</TableCell>
<TableCell>${product.price}</TableCell>
<TableCell>{product.sold}</TableCell>
<TableCell className="pr-5 text-right text-green-light-1 sm:pr-6 xl:pr-7.5">
${product.profit}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,41 @@
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export function TopProductsSkeleton() {
return (
<div className="rounded-[10px] bg-white shadow-1 dark:bg-gray-dark dark:shadow-card">
<h2 className="px-4 py-6 text-2xl font-bold text-dark dark:text-white md:px-6 xl:px-9">
Top Products
</h2>
<Table>
<TableHeader>
<TableRow className="border-t text-base [&>th]:h-auto [&>th]:py-3 sm:[&>th]:py-4.5">
<TableHead className="min-w-[120px]">Product Name</TableHead>
<TableHead>Category</TableHead>
<TableHead>Price</TableHead>
<TableHead>Sold</TableHead>
<TableHead>Profit</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
<TableCell colSpan={100}>
<Skeleton className="h-8" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}