Compare commits

...

6 Commits

Author SHA1 Message Date
hamid zarghami 95814d6b00 campains
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-04 11:56:32 +03:30
hamid zarghami a4eaa50fdf group 2026-07-04 10:43:16 +03:30
hamid zarghami 457361e759 Crud group 2026-07-04 10:34:56 +03:30
hamid zarghami eb9973678c slider 2026-07-04 09:45:24 +03:30
hamid zarghami bcf03887f1 pagination orders list 2026-07-02 10:43:55 +03:30
hamid zarghami 6187e7c26d update customer 2026-07-02 10:35:38 +03:30
48 changed files with 2536 additions and 257 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
VITE_TOKEN_NAME = 'dmnu_a_t' VITE_TOKEN_NAME = 'dmnu_a_t'
VITE_REFRESH_TOKEN_NAME = 'dmnu-a-rt' VITE_REFRESH_TOKEN_NAME = 'dmnu-a-rt'
VITE_BASE_URL = 'https://dmenu-api.danakcorp.com' # VITE_BASE_URL = 'https://dmenu-api.danakcorp.com'
# VITE_BASE_URL = 'http://192.168.99.131:2000' VITE_BASE_URL = 'http://192.168.99.131:2000'
VITE_SOCKET_URL = 'https://dmenu-api.danakcorp.com' VITE_SOCKET_URL = 'https://dmenu-api.danakcorp.com'
+10
View File
@@ -33,6 +33,11 @@ export const Pages = {
add: "/customers/add", add: "/customers/add",
detail: "/customers/detail/", detail: "/customers/detail/",
update: "/customers/update/", update: "/customers/update/",
groups: "/customers/groups",
groupsAdd: "/customers/groups/add",
groupsUpdate: "/customers/groups/update/",
campaigns: "/customers/campaigns",
campaignsAdd: "/customers/campaigns/add",
}, },
discount: { discount: {
list: "/discounts/list", list: "/discounts/list",
@@ -83,6 +88,11 @@ export const Pages = {
notifications: { notifications: {
list: "/notifications/list", list: "/notifications/list",
}, },
sliders: {
list: "/sliders/list",
add: "/sliders/create",
update: "/sliders/update/",
},
statistics: { statistics: {
list: "/statistics", list: "/statistics",
}, },
+1
View File
@@ -22,6 +22,7 @@ export const MENU_PREVIEW_RESERVED_SEGMENTS = new Set([
"reviews", "reviews",
"pagers", "pagers",
"notifications", "notifications",
"sliders",
"assets", "assets",
"menu-preview", "menu-preview",
]); ]);
+5 -2
View File
@@ -68,7 +68,8 @@
"reviews": "نظرات", "reviews": "نظرات",
"pagers": "پیجرها", "pagers": "پیجرها",
"notifications": "تنظیمات اعلانات", "notifications": "تنظیمات اعلانات",
"statistics": "گزارشات" "statistics": "گزارشات",
"sliders": "اسلایدرها"
}, },
"shipment": { "shipment": {
"dineIn": "سرو در رستوران", "dineIn": "سرو در رستوران",
@@ -103,7 +104,9 @@
"discount_list": "لیست تخفیف", "discount_list": "لیست تخفیف",
"add_discount": "افزودن تخفیف", "add_discount": "افزودن تخفیف",
"customers_list": "لیست مشتریان", "customers_list": "لیست مشتریان",
"add_customer": "افزودن مشتری" "add_customer": "افزودن مشتری",
"groups": "گروه مشتری",
"campaigns": "کمپین‌ها"
}, },
"guide": { "guide": {
"list_guide": "لیست راهنمای سرویس", "list_guide": "لیست راهنمای سرویس",
+1
View File
@@ -0,0 +1 @@
export { default } from "./groups/List";
+97 -97
View File
@@ -1,138 +1,141 @@
import Table from '@/components/Table' import Button from "@/components/Button";
import Filters, { type FieldType } from '@/components/Filters' import Filters, { type FieldType } from "@/components/Filters";
import Button from '@/components/Button' import Table from "@/components/Table";
import { type FC, useMemo, useState } from 'react' import { Pages } from "@/config/Pages";
import { useTranslation } from 'react-i18next' import { formatFaNumber, formatOptionalDate } from "@/helpers/func";
import { DocumentDownload, DocumentUpload } from 'iconsax-react' import type { CustomerListRow, UserRestaurant } from "@/pages/customers/types/Types";
import { useGetUsers } from './hooks/useUsersData' import { DocumentDownload, DocumentUpload, Edit } from "iconsax-react";
import { useCustomerFilters } from './hooks/useCustomerFilters' import { type FC, useMemo, useState } from "react";
import ImportCustomersModal from './components/ImportCustomersModal' import { useTranslation } from "react-i18next";
import { downloadCustomerImportSampleExcel } from './utils/downloadCustomerImportSampleExcel' import { useNavigate } from "react-router-dom";
import type { CustomerListRow, UserRestaurant } from '@/pages/customers/types/Types' import ImportCustomersModal from "./components/ImportCustomersModal";
import { formatOptionalDate, formatFaNumber } from '@/helpers/func' import { useCustomerFilters } from "./hooks/useCustomerFilters";
import { useGetUsers } from "./hooks/useUsersData";
import { downloadCustomerImportSampleExcel } from "./utils/downloadCustomerImportSampleExcel";
const mapUserRestaurantToRow = (userRestaurant: UserRestaurant): CustomerListRow => { const mapUserRestaurantToRow = (userRestaurant: UserRestaurant): CustomerListRow => {
const { user, orderCount, totalOrderAmount } = userRestaurant const { user, orderCount, totalOrderAmount } = userRestaurant;
const fullName = [user.firstName, user.lastName].filter(Boolean).join(' ').trim() const fullName = [user.firstName, user.lastName].filter(Boolean).join(" ").trim();
return { return {
id: user.id, id: user.id,
fullName: fullName || 'بدون نام', fullName: fullName || "بدون نام",
phone: user.phone || '-', phone: user.phone || "-",
birthDate: formatOptionalDate(user.birthDate), birthDate: formatOptionalDate(user.birthDate),
marriageDate: formatOptionalDate(user.marriageDate), marriageDate: formatOptionalDate(user.marriageDate),
orderCount: formatFaNumber(orderCount), orderCount: formatFaNumber(orderCount),
totalOrderAmount: formatFaNumber(totalOrderAmount), totalOrderAmount: formatFaNumber(totalOrderAmount),
status: user.isActive !== false ? 'فعال' : 'غیرفعال', status: user.isActive !== false ? "فعال" : "غیرفعال",
} firstName: user.firstName,
} lastName: user.lastName,
birthDateRaw: user.birthDate,
marriageDateRaw: user.marriageDate,
gender: user.gender,
avatarUrl: user.avatarUrl,
};
};
const CustomersList: FC = () => { const CustomersList: FC = () => {
const { t } = useTranslation('global') const { t } = useTranslation("global");
const [isImportModalOpen, setIsImportModalOpen] = useState(false) const navigate = useNavigate();
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const { const { filters, currentPage, apiParams, handleFiltersChange, handlePageChange } = useCustomerFilters();
filters,
currentPage,
apiParams,
handleFiltersChange,
handlePageChange,
} = useCustomerFilters()
const { data: usersData, isLoading } = useGetUsers(apiParams) const { data: usersData, isLoading } = useGetUsers(apiParams);
const filterFields = useMemo<FieldType[]>(() => [ const filterFields = useMemo<FieldType[]>(
() => [
{ {
type: 'input', type: "input",
name: 'search', name: "search",
placeholder: 'نام یا شماره تماس', placeholder: "نام یا شماره تماس",
}, },
], []) ],
[],
);
const userRestaurants = usersData?.data ?? [] const userRestaurants = usersData?.data ?? [];
const tableData = useMemo<CustomerListRow[]>( const tableData = useMemo<CustomerListRow[]>(() => userRestaurants.map(mapUserRestaurantToRow), [userRestaurants]);
() => userRestaurants.map(mapUserRestaurantToRow),
[userRestaurants],
)
const totalPages = usersData?.meta?.totalPages || 1 const totalPages = usersData?.meta?.totalPages || 1;
const columns = useMemo(() => [ const columns = useMemo(
() => [
{ {
key: 'fullName', key: "fullName",
title: 'نام و نام خانوادگی', title: "نام و نام خانوادگی",
}, },
{ {
key: 'phone', key: "phone",
title: 'شماره تماس', title: "شماره تماس",
}, },
{ {
key: 'birthDate', key: "birthDate",
title: 'تاریخ تولد', title: "تاریخ تولد",
}, },
{ {
key: 'marriageDate', key: "marriageDate",
title: 'تاریخ ازدواج', title: "تاریخ ازدواج",
}, },
{ {
key: 'orderCount', key: "orderCount",
title: 'تعداد سفارش', title: "تعداد سفارش",
}, },
{ {
key: 'totalOrderAmount', key: "totalOrderAmount",
title: 'مجموع سفارش (تومان)', title: "مجموع سفارش (تومان)",
}, },
{ {
key: 'status', key: "status",
title: 'وضعیت', title: "وضعیت",
render: (item: CustomerListRow) => ( render: (item: CustomerListRow) => (
<span <span className={`px-3 py-1 rounded-full text-xs ${item.status === "فعال" ? "bg-green-100 text-green-700" : "bg-red-100 text-red-600"}`}>{item.status}</span>
className={`px-3 py-1 rounded-full text-xs ${item.status === 'فعال' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}`}
>
{item.status}
</span>
), ),
}, },
{ {
key: 'details', key: "details",
title: 'جزئیات', title: "عملیات",
render: (item: CustomerListRow) => (
<button
type="button"
className="text-primary text-xs flex items-center gap-1"
onClick={(e) => {
e.stopPropagation();
navigate(Pages.customers.update + item.id, {
state: { customer: item },
});
}}
>
<Edit size={18} color="#0047FF" />
</button>
),
}, },
], []) ],
[navigate],
);
return ( return (
<div className='mt-5'> <div className="mt-5">
<div className='flex flex-wrap justify-between items-center gap-3'> <div className="flex flex-wrap justify-between items-center gap-3">
<h1 className='text-lg font-light'>{t('customer.customer_list')}</h1> <h1 className="text-lg font-light">{t("customer.customer_list")}</h1>
<div className='flex flex-wrap gap-2'> <div className="flex flex-wrap gap-2">
<Button <Button type="button" className="w-fit px-4 bg-gray-100 text-gray-700" onClick={downloadCustomerImportSampleExcel}>
type='button' <div className="flex items-center gap-2">
className='w-fit px-4 bg-gray-100 text-gray-700' <DocumentDownload size={18} color="#374151" />
onClick={downloadCustomerImportSampleExcel} <span>{t("customer.download_sample")}</span>
>
<div className='flex items-center gap-2'>
<DocumentDownload size={18} color='#374151' />
<span>{t('customer.download_sample')}</span>
</div> </div>
</Button> </Button>
<Button <Button type="button" className="w-fit px-4" onClick={() => setIsImportModalOpen(true)}>
type='button' <div className="flex items-center gap-2">
className='w-fit px-4' <DocumentUpload size={18} color="#fff" />
onClick={() => setIsImportModalOpen(true)} <span>{t("customer.import_excel")}</span>
>
<div className='flex items-center gap-2'>
<DocumentUpload size={18} color='#fff' />
<span>{t('customer.import_excel')}</span>
</div> </div>
</Button> </Button>
</div> </div>
</div> </div>
<div className='mt-8'> <div className="mt-8">
<Filters <Filters fields={filterFields} onChange={handleFiltersChange} initialValues={filters} searchField="search" />
fields={filterFields}
onChange={handleFiltersChange}
initialValues={filters}
searchField="search"
/>
</div> </div>
<Table <Table
@@ -147,12 +150,9 @@ const CustomersList: FC = () => {
}} }}
/> />
<ImportCustomersModal <ImportCustomersModal open={isImportModalOpen} onClose={() => setIsImportModalOpen(false)} />
open={isImportModalOpen}
onClose={() => setIsImportModalOpen(false)}
/>
</div> </div>
) );
} };
export default CustomersList export default CustomersList;
+215
View File
@@ -0,0 +1,215 @@
import { type FC, useEffect, useState } from 'react'
import { useFormik } from 'formik'
import { TickCircle } from 'iconsax-react'
import { toast } from 'react-toastify'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import Button from '@/components/Button'
import Input from '@/components/Input'
import Select from '@/components/Select'
import DatePicker from '@/components/DatePicker'
import UploadBox from '@/components/UploadBox'
import { useSingleUpload } from '@/pages/uploader/hooks/useUploaderData'
import { useUpdateUser } from './hooks/useUsersData'
import type { CustomerListRow, UpdateUserType } from './types/Types'
import { Pages } from '@/config/Pages'
import type { ErrorType } from '@/helpers/types'
import { extractErrorMessage } from '@/config/func'
type UpdateCustomerFormValues = {
firstName: string
lastName: string
birthDate: string
marriageDate: string
gender: string
}
type CustomerUpdateLocationState = {
customer?: CustomerListRow
}
const UpdateCustomer: FC = () => {
const navigate = useNavigate()
const location = useLocation()
const { id } = useParams<{ id: string }>()
const { mutate: updateUser, isPending: isUpdating } = useUpdateUser()
const { mutate: singleUpload, isPending: isUploading } = useSingleUpload()
const [avatarFile, setAvatarFile] = useState<File[]>([])
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState('')
const customer = (location.state as CustomerUpdateLocationState | null)?.customer
useEffect(() => {
if (!customer?.avatarUrl) {
setAvatarPreviewUrl('')
return
}
setAvatarPreviewUrl(customer.avatarUrl)
}, [customer?.avatarUrl])
useEffect(() => {
if (avatarFile.length > 0) {
const url = URL.createObjectURL(avatarFile[0])
setAvatarPreviewUrl(url)
return () => URL.revokeObjectURL(url)
}
}, [avatarFile])
const formik = useFormik<UpdateCustomerFormValues>({
initialValues: {
firstName: customer?.firstName || '',
lastName: customer?.lastName || '',
birthDate: customer?.birthDateRaw || '',
marriageDate: customer?.marriageDateRaw || '',
gender:
customer?.gender === undefined || customer?.gender === null
? ''
: String(customer.gender),
},
enableReinitialize: true,
onSubmit: (values) => {
if (!id) {
toast.error('شناسه مشتری یافت نشد')
return
}
const submitUpdate = (avatarUrl?: string) => {
const payload: UpdateUserType = {}
if (values.firstName.trim()) payload.firstName = values.firstName.trim()
if (values.lastName.trim()) payload.lastName = values.lastName.trim()
if (values.birthDate) payload.birthDate = values.birthDate
if (values.marriageDate) payload.marriageDate = values.marriageDate
if (values.gender !== '') payload.gender = values.gender === 'true'
if (avatarUrl) payload.avatarUrl = avatarUrl
if (Object.keys(payload).length === 0) {
toast.info('حداقل یک فیلد را برای ویرایش وارد کنید')
return
}
updateUser(
{ userId: id, params: payload },
{
onSuccess: () => {
toast.success('اطلاعات مشتری با موفقیت ویرایش شد')
navigate(Pages.customers.list)
},
onError: (error: ErrorType) => {
toast.error(extractErrorMessage(error, 'خطا در ویرایش مشتری'))
},
},
)
}
if (avatarFile.length > 0) {
singleUpload(avatarFile[0], {
onSuccess: (response) => {
const avatarUrl = response?.data?.url || ''
submitUpdate(avatarUrl)
},
onError: (error: ErrorType) => {
toast.error(extractErrorMessage(error, 'خطا در آپلود تصویر'))
},
})
} else {
submitUpdate(customer?.avatarUrl || undefined)
}
},
})
return (
<div className='mt-5'>
<div className='flex justify-between items-center'>
<h1 className='text-lg font-light'>ویرایش مشتری</h1>
<Button
className='w-fit px-6'
isloading={isUpdating || isUploading}
onClick={() => formik.handleSubmit()}
>
<div className='flex gap-2 items-center'>
<TickCircle size={20} color='#fff' />
<span>ذخیره تغییرات</span>
</div>
</Button>
</div>
<div className='mt-9'>
<div className='w-full bg-white rounded-4xl p-8'>
<div className='text-lg font-light'>اطلاعات مشتری</div>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-6'>
<Input
label='نام'
placeholder=''
name='firstName'
value={formik.values.firstName}
onChange={formik.handleChange}
isNotRequired
/>
<Input
label='نام خانوادگی'
placeholder=''
name='lastName'
value={formik.values.lastName}
onChange={formik.handleChange}
isNotRequired
/>
</div>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-5'>
<DatePicker
label='تاریخ تولد'
placeholder='انتخاب'
isNotRequired
defaulValue={formik.values.birthDate}
onChange={(date) => formik.setFieldValue('birthDate', date)}
/>
<DatePicker
label='تاریخ ازدواج'
placeholder='انتخاب'
isNotRequired
defaulValue={formik.values.marriageDate}
onChange={(date) => formik.setFieldValue('marriageDate', date)}
/>
</div>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-5'>
<Select
label='جنسیت'
placeholder='انتخاب'
name='gender'
value={formik.values.gender}
onChange={formik.handleChange}
isNotRequired
items={[
{ value: 'true', label: 'مرد' },
{ value: 'false', label: 'زن' },
]}
/>
</div>
<div className='mt-5'>
<UploadBox
label='تصویر پروفایل'
isNotRequired
onChange={(files) => setAvatarFile(files)}
isReset={avatarFile.length === 0}
/>
{avatarPreviewUrl && (
<div className='mt-3'>
<img
src={avatarPreviewUrl}
alt='avatar preview'
className='w-20 h-20 rounded-full object-cover'
/>
</div>
)}
</div>
</div>
</div>
</div>
)
}
export default UpdateCustomer
+152
View File
@@ -0,0 +1,152 @@
import Button from "@/components/Button";
import Input from "@/components/Input";
import MultiSelect from "@/components/MultiSelect";
import Select from "@/components/Select";
import { extractErrorMessage } from "@/config/func";
import { Pages } from "@/config/Pages";
import type { ErrorType } from "@/helpers/types";
import { useFormik } from "formik";
import { TickCircle } from "iconsax-react";
import { type FC, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import * as Yup from "yup";
import { useGetUserGroups } from "@/pages/customers/groups/hooks/useUserGroupData";
import { useCreateDiscountCampaign } from "./hooks/useCampaignData";
import type { CreateDiscountCampaignType } from "./types/Types";
const sentTypeOptions = [
{ value: "push", label: "پوش نوتیفیکیشن" },
{ value: "sms", label: "پیامک" },
];
const CreateCampaign: FC = () => {
const navigate = useNavigate();
const { mutate: createCampaign, isPending } = useCreateDiscountCampaign();
const { data: groupsData } = useGetUserGroups();
const groupOptions = useMemo(
() =>
(groupsData?.data || []).map((group) => ({
value: group.id,
label: group.name,
})),
[groupsData]
);
const formik = useFormik<CreateDiscountCampaignType>({
initialValues: {
title: "",
groups: [],
sentType: "push",
discountCode: "",
ocasion: "",
},
validationSchema: Yup.object().shape({
title: Yup.string().required("عنوان کمپین الزامی است"),
groups: Yup.array()
.of(Yup.string())
.min(1, "حداقل یک گروه باید انتخاب شود"),
sentType: Yup.string().required("نوع ارسال الزامی است"),
discountCode: Yup.string().required("کد تخفیف الزامی است"),
ocasion: Yup.string().required("مناسبت الزامی است"),
}),
onSubmit: (values) => {
createCampaign(values, {
onSuccess: () => {
toast.success("کمپین با موفقیت ایجاد شد");
navigate(Pages.customers.campaigns);
},
onError: (error: ErrorType) => {
toast.error(extractErrorMessage(error));
},
});
},
});
return (
<div className="mt-5">
<div className="flex w-full justify-between items-center">
<div className="text-lg font-light">افزودن کمپین تخفیف</div>
<Button
className="px-5 w-fit"
onClick={() => formik.handleSubmit()}
isloading={isPending}
>
<div className="flex gap-2 items-center">
<TickCircle className="size-5" color="white" />
<div>ثبت کمپین</div>
</div>
</Button>
</div>
<div className="mt-6">
<div className="bg-white rounded-3xl p-6 flex flex-col gap-6">
<Input
label="عنوان کمپین"
name="title"
value={formik.values.title}
onChange={formik.handleChange}
error_text={
formik.touched.title && formik.errors.title
? formik.errors.title
: ""
}
/>
<Input
label="کد تخفیف"
name="discountCode"
value={formik.values.discountCode}
onChange={formik.handleChange}
error_text={
formik.touched.discountCode && formik.errors.discountCode
? formik.errors.discountCode
: ""
}
/>
<Input
label="مناسبت"
name="ocasion"
value={formik.values.ocasion}
onChange={formik.handleChange}
error_text={
formik.touched.ocasion && formik.errors.ocasion
? formik.errors.ocasion
: ""
}
/>
<Select
label="نوع ارسال"
name="sentType"
items={sentTypeOptions}
value={formik.values.sentType}
onChange={formik.handleChange}
error_text={
formik.touched.sentType && formik.errors.sentType
? formik.errors.sentType
: ""
}
/>
<MultiSelect
label="گروه‌های مشتری"
items={groupOptions}
value={formik.values.groups}
onChange={(values) => formik.setFieldValue("groups", values)}
placeholder="انتخاب گروه‌ها"
error_text={
formik.touched.groups && formik.errors.groups
? String(formik.errors.groups)
: ""
}
/>
</div>
</div>
</div>
);
};
export default CreateCampaign;
+54
View File
@@ -0,0 +1,54 @@
import Button from "@/components/Button";
import Filters from "@/components/Filters";
import Table from "@/components/Table";
import { Pages } from "@/config/Pages";
import { Add } from "iconsax-react";
import { type FC } from "react";
import { Link } from "react-router-dom";
import { getCampaignTableColumns } from "./components/CampaignTableColumns";
import { useGetCampaigns } from "./hooks/useCampaignData";
import { useCampaignFilters } from "./hooks/useCampaignFilters";
const CampaignsList: FC = () => {
const { filters, currentPage, apiParams, handleFiltersChange, handlePageChange, limit } = useCampaignFilters();
const { data: campaignsData, isLoading } = useGetCampaigns(apiParams);
const campaigns = campaignsData?.data || [];
const totalPages = campaignsData?.meta?.totalPages || Math.max(1, Math.ceil(campaigns.length / limit) || 1);
const columns = getCampaignTableColumns();
return (
<div className="mt-5">
<div className="flex justify-between items-center">
<h1 className="text-lg font-light">کمپینها</h1>
<Link to={Pages.customers.campaignsAdd}>
<Button className="w-fit px-6">
<div className="flex gap-2 items-center">
<Add color="#fff" size={20} />
<span>افزودن کمپین</span>
</div>
</Button>
</Link>
</div>
<div className="mt-8">
<Filters fields={[]} onChange={handleFiltersChange} initialValues={filters} searchField="search" />
</div>
<Table
columns={columns}
data={campaigns}
isloading={isLoading}
pagination={{
currentPage,
totalPages,
onPageChange: handlePageChange,
}}
/>
</div>
);
};
export default CampaignsList;
@@ -0,0 +1,54 @@
import type { ColumnType } from "@/components/types/TableTypes";
import { formatOptionalDate } from "@/helpers/func";
import type { Campaign } from "../types/Types";
const getGroupNames = (groups: Campaign["groups"]) => {
if (!groups?.length) return "-";
return groups.map((group) => (typeof group === "string" ? group : group.name)).join(" ، ");
};
const sentTypeLabels: Record<string, string> = {
push: "پوش نوتیفیکیشن",
sms: "پیامک",
};
export const getCampaignTableColumns = (): ColumnType<Campaign>[] => {
return [
{
key: "title",
title: "عنوان",
render: (item: Campaign) => item.title || "-",
},
{
key: "discountCode",
title: "کد تخفیف",
render: (item: Campaign) => item.discountCode || "-",
},
{
key: "ocasion",
title: "مناسبت",
render: (item: Campaign) => item.ocasion || "-",
},
{
key: "sentType",
title: "نوع ارسال",
render: (item: Campaign) => sentTypeLabels[item.sentType] || item.sentType,
},
{
key: "groups",
title: "گروه‌ها",
render: (item: Campaign) => getGroupNames(item.groups),
},
{
key: "createdAt",
title: "تاریخ ایجاد",
render: (item: Campaign) => formatOptionalDate(item.createdAt),
},
// {
// key: "actions",
// title: "",
// render: (item: Campaign) => (onDelete ? <TrashWithConfrim onDelete={() => onDelete(item.id)} isloading={isDeleting} /> : null),
// },
];
};
@@ -0,0 +1,38 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as api from "../service/CampaignService";
import type { GetCampaignsParams } from "../types/Types";
export const useGetCampaigns = (params?: GetCampaignsParams) => {
return useQuery({
queryKey: ["campaigns", params],
queryFn: () => api.getCampaigns(params),
});
};
export const useGetCampaignById = (id: string) => {
return useQuery({
queryKey: ["campaign", id],
queryFn: () => api.getCampaignById(id),
enabled: !!id,
});
};
export const useCreateDiscountCampaign = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.createDiscountCampaign,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
},
});
};
export const useDeleteCampaign = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.deleteCampaign,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
},
});
};
@@ -0,0 +1,43 @@
import { useMemo, useState } from "react";
import type { FilterValues } from "@/components/Filters";
import type { GetCampaignsParams } from "../types/Types";
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 10;
export const useCampaignFilters = () => {
const [filters, setFilters] = useState<FilterValues>({});
const [currentPage, setCurrentPage] = useState(DEFAULT_PAGE);
const [limit] = useState(DEFAULT_LIMIT);
const apiParams = useMemo<GetCampaignsParams>(() => {
const params: GetCampaignsParams = {
page: currentPage,
limit,
};
if (filters.search) {
params.search = filters.search as string;
}
return params;
}, [filters, currentPage, limit]);
const handleFiltersChange = (newFilters: FilterValues) => {
setFilters(newFilters);
setCurrentPage(DEFAULT_PAGE);
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
return {
filters,
currentPage,
apiParams,
handleFiltersChange,
handlePageChange,
limit,
};
};
@@ -0,0 +1,33 @@
import axios from "@/config/axios";
import type {
CampaignResponse,
CampaignsResponse,
CreateDiscountCampaignType,
GetCampaignsParams,
} from "../types/Types";
export const getCampaigns = async (
params?: GetCampaignsParams
): Promise<CampaignsResponse> => {
const { data } = await axios.get<CampaignsResponse>("/admin/campaigns", {
params,
});
return data;
};
export const getCampaignById = async (id: string): Promise<CampaignResponse> => {
const { data } = await axios.get<CampaignResponse>(`/admin/campaigns/${id}`);
return data;
};
export const createDiscountCampaign = async (
params: CreateDiscountCampaignType
) => {
const { data } = await axios.post("/admin/campaigns/discount", params);
return data;
};
export const deleteCampaign = async (id: string) => {
const { data } = await axios.delete(`/admin/campaigns/${id}`);
return data;
};
@@ -0,0 +1,42 @@
import type { UserGroup } from "@/pages/customers/groups/types/Types";
import type { IResponse } from "@/types/response.types";
export type CampaignSentType = "push" | "sms";
export type Campaign = {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
title: string;
groups: UserGroup[] | string[];
sentType: CampaignSentType;
discountCode: string;
ocasion: string;
};
export type PaginationMeta = {
total: number;
page: number;
limit: number;
totalPages: number;
};
export type GetCampaignsParams = {
page?: number;
limit?: number;
search?: string;
};
export type CampaignsResponse = IResponse<Campaign[]> & {
meta?: PaginationMeta;
};
export type CampaignResponse = IResponse<Campaign>;
export type CreateDiscountCampaignType = {
title: string;
groups: string[];
sentType: CampaignSentType;
discountCode: string;
ocasion: string;
};
+60
View File
@@ -0,0 +1,60 @@
import Button from "@/components/Button";
import Input from "@/components/Input";
import { extractErrorMessage } from "@/config/func";
import { Pages } from "@/config/Pages";
import type { ErrorType } from "@/helpers/types";
import { useFormik } from "formik";
import { TickCircle } from "iconsax-react";
import { type FC } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import * as Yup from "yup";
import { useCreateUserGroup } from "./hooks/useUserGroupData";
import type { CreateUserGroupType } from "./types/Types";
const CreateUserGroup: FC = () => {
const navigate = useNavigate();
const { mutate: createUserGroup, isPending } = useCreateUserGroup();
const formik = useFormik<CreateUserGroupType>({
initialValues: {
name: "",
},
validationSchema: Yup.object().shape({
name: Yup.string().required("نام گروه الزامی است"),
}),
onSubmit: (values) => {
createUserGroup(values, {
onSuccess: () => {
toast.success("گروه با موفقیت ایجاد شد");
navigate(Pages.customers.groups);
},
onError: (error: ErrorType) => {
toast.error(extractErrorMessage(error));
},
});
},
});
return (
<div className="mt-5">
<div className="flex w-full justify-between items-center">
<div className="text-lg font-light">افزودن گروه مشتری</div>
<Button className="px-5 w-fit" onClick={() => formik.handleSubmit()} isloading={isPending}>
<div className="flex gap-2 items-center">
<TickCircle className="size-5" color="white" />
<div>ثبت گروه</div>
</div>
</Button>
</div>
<div className="mt-6">
<div className="bg-white rounded-3xl p-6">
<Input label="نام گروه" name="name" value={formik.values.name} onChange={formik.handleChange} error_text={formik.touched.name && formik.errors.name ? formik.errors.name : ""} />
</div>
</div>
</div>
);
};
export default CreateUserGroup;
+75
View File
@@ -0,0 +1,75 @@
import { type FC } from "react";
import Table from "@/components/Table";
import Filters from "@/components/Filters";
import { Add } from "iconsax-react";
import Button from "@/components/Button";
import { Link } from "react-router-dom";
import { Pages } from "@/config/Pages";
import {
useDeleteUserGroup,
useGetUserGroups,
} from "./hooks/useUserGroupData";
import { useUserGroupFilters } from "./hooks/useUserGroupFilters";
import { getUserGroupTableColumns } from "./components/UserGroupTableColumns";
import { useUserGroupFiltersFields } from "./components/UserGroupFiltersFields";
const UserGroupsList: FC = () => {
const {
filters,
currentPage,
apiParams,
handleFiltersChange,
handlePageChange,
limit,
} = useUserGroupFilters();
const { data: groupsData, isLoading } = useGetUserGroups(apiParams);
const { mutate: deleteUserGroup, isPending: isDeleting } =
useDeleteUserGroup();
const groups = groupsData?.data || [];
const columns = getUserGroupTableColumns({
onDelete: (id: string) => deleteUserGroup(id),
isDeleting,
});
const filterFields = useUserGroupFiltersFields();
const totalPages = Math.ceil(groups.length / limit) || 1;
return (
<div className="mt-5">
<div className="flex justify-between items-center">
<h1 className="text-lg font-light">گروههای مشتری</h1>
<Link to={Pages.customers.groupsAdd}>
<Button className="w-fit px-6">
<div className="flex gap-2 items-center">
<Add color="#fff" size={20} />
<span>افزودن گروه</span>
</div>
</Button>
</Link>
</div>
<div className="mt-8">
<Filters
fields={filterFields}
onChange={handleFiltersChange}
initialValues={filters}
searchField="search"
/>
</div>
<Table
columns={columns}
data={groups}
isloading={isLoading}
pagination={{
currentPage,
totalPages,
onPageChange: handlePageChange,
}}
/>
</div>
);
};
export default UserGroupsList;
+98
View File
@@ -0,0 +1,98 @@
import Button from "@/components/Button";
import Input from "@/components/Input";
import PageLoading from "@/components/PageLoading";
import { extractErrorMessage } from "@/config/func";
import { Pages } from "@/config/Pages";
import type { ErrorType } from "@/helpers/types";
import { useFormik } from "formik";
import { TickCircle } from "iconsax-react";
import { type FC, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import * as Yup from "yup";
import { useGetUserGroup, useUpdateUserGroup } from "./hooks/useUserGroupData";
import type { CreateUserGroupType } from "./types/Types";
import GroupUsersSection from "./components/GroupUsersSection";
const UpdateUserGroup: FC = () => {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const { data: groupData, isLoading } = useGetUserGroup(id || "");
const { mutate: updateUserGroup, isPending } = useUpdateUserGroup();
const group = groupData?.data;
const formik = useFormik<CreateUserGroupType>({
initialValues: {
name: "",
},
validationSchema: Yup.object().shape({
name: Yup.string().required("نام گروه الزامی است"),
}),
onSubmit: (values) => {
if (!id) return;
updateUserGroup(
{ id, params: values },
{
onSuccess: () => {
toast.success("گروه با موفقیت ویرایش شد");
navigate(Pages.customers.groups);
},
onError: (error: ErrorType) => {
toast.error(extractErrorMessage(error));
},
},
);
},
});
useEffect(() => {
if (group) {
formik.setValues({
name: group.name || "",
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [group]);
if (isLoading) {
return <PageLoading />;
}
if (!group) {
return (
<div className="w-full mt-4 flex justify-center items-center">
<div>گروه یافت نشد</div>
</div>
);
}
return (
<div className="mt-5">
<div className="flex w-full justify-between items-center">
<div className="text-lg font-light">ویرایش گروه مشتری</div>
<Button className="px-5 w-fit" onClick={() => formik.handleSubmit()} isloading={isPending}>
<div className="flex gap-2 items-center">
<TickCircle className="size-5" color="white" />
<div>ثبت تغییرات</div>
</div>
</Button>
</div>
<div className="mt-6 bg-white rounded-3xl p-6">
<Input
label="نام گروه"
name="name"
value={formik.values.name}
onChange={formik.handleChange}
error_text={formik.touched.name && formik.errors.name ? formik.errors.name : ""}
/>
</div>
{id && <GroupUsersSection groupId={id} />}
</div>
);
};
export default UpdateUserGroup;
@@ -0,0 +1,144 @@
import Button from "@/components/Button";
import DefaulModal from "@/components/DefaulModal";
import Input from "@/components/Input";
import { Checkbox } from "@/components/ui/checkbox";
import { extractErrorMessage } from "@/config/func";
import type { ErrorType } from "@/helpers/types";
import { useGetUsers } from "@/pages/customers/hooks/useUsersData";
import type { User } from "@/pages/customers/types/Types";
import { type FC, useMemo, useState } from "react";
import { toast } from "react-toastify";
import { useAddUsersToGroup } from "../hooks/useUserGroupData";
type Props = {
open: boolean;
onClose: () => void;
groupId: string;
existingUserIds: string[];
};
const AddUsersModal: FC<Props> = ({ open, onClose, groupId, existingUserIds }) => {
const [search, setSearch] = useState("");
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
const { data: usersData, isLoading } = useGetUsers({
search: search || undefined,
limit: 50,
});
const { mutate: addUsersToGroup, isPending } = useAddUsersToGroup();
const availableUsers = useMemo(() => {
const users =
usersData?.data?.map((userRestaurant) => userRestaurant.user) ?? [];
return users.filter((user) => !existingUserIds.includes(user.id));
}, [usersData?.data, existingUserIds]);
const handleToggle = (userId: string, checked: boolean) => {
if (checked) {
setSelectedUserIds((prev) => [...prev, userId]);
} else {
setSelectedUserIds((prev) => prev.filter((id) => id !== userId));
}
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedUserIds(availableUsers.map((user) => user.id));
} else {
setSelectedUserIds([]);
}
};
const isAllSelected = useMemo(() => {
return (
availableUsers.length > 0 &&
availableUsers.every((user) => selectedUserIds.includes(user.id))
);
}, [availableUsers, selectedUserIds]);
const handleClose = () => {
setSearch("");
setSelectedUserIds([]);
onClose();
};
const handleSubmit = () => {
if (selectedUserIds.length === 0) {
toast.error("حداقل یک کاربر باید انتخاب شود");
return;
}
addUsersToGroup(
{ groupId, params: { userIds: selectedUserIds } },
{
onSuccess: () => {
toast.success("کاربران با موفقیت به گروه اضافه شدند");
handleClose();
},
onError: (error: ErrorType) => {
toast.error(extractErrorMessage(error));
},
},
);
};
const getUserLabel = (user: User) => {
const fullName = [user.firstName, user.lastName].filter(Boolean).join(" ").trim();
return fullName ? `${fullName} - ${user.phone}` : user.phone;
};
return (
<DefaulModal
open={open}
close={handleClose}
isHeader
title_header="افزودن کاربر به گروه"
width={520}
>
<div className="mt-4 flex flex-col gap-4">
<Input
variant="search"
className="bg-[#EEF0F7]"
placeholder="جستجو با نام یا شماره تماس"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="border border-border rounded-xl p-4 max-h-72 overflow-y-auto">
{isLoading ? (
<div className="text-sm text-gray-500 text-center py-4">در حال بارگذاری...</div>
) : availableUsers.length === 0 ? (
<div className="text-sm text-gray-500 text-center py-4">کاربری یافت نشد</div>
) : (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} />
<label className="text-sm cursor-pointer">همه</label>
</div>
{availableUsers.map((user) => (
<div key={user.id} className="flex items-center gap-2">
<Checkbox
checked={selectedUserIds.includes(user.id)}
onCheckedChange={(checked) =>
handleToggle(user.id, checked as boolean)
}
/>
<label className="text-sm cursor-pointer">{getUserLabel(user)}</label>
</div>
))}
</div>
)}
</div>
<div className="flex justify-end">
<Button className="px-6 w-fit" onClick={handleSubmit} isloading={isPending}>
افزودن کاربران
</Button>
</div>
</div>
</DefaulModal>
);
};
export default AddUsersModal;
@@ -0,0 +1,40 @@
import type { ColumnType } from "@/components/types/TableTypes";
import TrashWithConfrim from "@/components/TrashWithConfrim";
import type { User } from "@/pages/customers/types/Types";
interface GetGroupUserTableColumnsParams {
onDelete?: (userId: string) => void;
isDeleting?: boolean;
}
export const getGroupUserTableColumns = ({
onDelete,
isDeleting,
}: GetGroupUserTableColumnsParams = {}): ColumnType<User>[] => {
return [
{
key: "fullName",
title: "نام و نام خانوادگی",
render: (item: User) => {
const fullName = [item.firstName, item.lastName].filter(Boolean).join(" ").trim();
return fullName || "بدون نام";
},
},
{
key: "phone",
title: "شماره تماس",
render: (item: User) => item.phone || "-",
},
{
key: "actions",
title: "",
render: (item: User) =>
onDelete ? (
<TrashWithConfrim
onDelete={() => onDelete(item.id)}
isloading={isDeleting}
/>
) : null,
},
];
};
@@ -0,0 +1,72 @@
import Button from "@/components/Button";
import Table from "@/components/Table";
import { extractErrorMessage } from "@/config/func";
import type { ErrorType } from "@/helpers/types";
import { Add } from "iconsax-react";
import { type FC, useMemo, useState } from "react";
import { toast } from "react-toastify";
import {
useGetGroupUsers,
useRemoveUserFromGroup,
} from "../hooks/useUserGroupData";
import AddUsersModal from "./AddUsersModal";
import { getGroupUserTableColumns } from "./GroupUserTableColumns";
type Props = {
groupId: string;
};
const GroupUsersSection: FC<Props> = ({ groupId }) => {
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const { data: groupUsersData, isLoading } = useGetGroupUsers(groupId);
const { mutate: removeUserFromGroup, isPending: isRemoving } =
useRemoveUserFromGroup();
const users = groupUsersData?.data ?? [];
const existingUserIds = useMemo(() => users.map((user) => user.id), [users]);
const columns = getGroupUserTableColumns({
onDelete: (userId) => {
removeUserFromGroup(
{ groupId, userId },
{
onSuccess: () => {
toast.success("کاربر از گروه حذف شد");
},
onError: (error: ErrorType) => {
toast.error(extractErrorMessage(error));
},
},
);
},
isDeleting: isRemoving,
});
return (
<div className="mt-6">
<div className="bg-white rounded-3xl p-6">
<div className="flex justify-between items-center mb-4">
<div className="text-sm">کاربران گروه</div>
<Button className="w-fit px-5" onClick={() => setIsAddModalOpen(true)}>
<div className="flex gap-2 items-center">
<Add color="#fff" size={18} />
<span>افزودن کاربر</span>
</div>
</Button>
</div>
<Table columns={columns} data={users} isloading={isLoading} />
</div>
<AddUsersModal
open={isAddModalOpen}
onClose={() => setIsAddModalOpen(false)}
groupId={groupId}
existingUserIds={existingUserIds}
/>
</div>
);
};
export default GroupUsersSection;
@@ -0,0 +1,15 @@
import { useMemo } from "react";
import type { FieldType } from "@/components/Filters";
export const useUserGroupFiltersFields = (): FieldType[] => {
return useMemo(
() => [
{
type: "input",
name: "search",
placeholder: "جستجو",
},
],
[]
);
};
@@ -0,0 +1,47 @@
import type { ColumnType } from "@/components/types/TableTypes";
import type { UserGroup } from "../types/Types";
import TrashWithConfrim from "@/components/TrashWithConfrim";
import { Eye } from "iconsax-react";
import { Link } from "react-router-dom";
import { Pages } from "@/config/Pages";
import { formatOptionalDate } from "@/helpers/func";
interface GetUserGroupTableColumnsParams {
onDelete?: (id: string) => void;
isDeleting?: boolean;
}
export const getUserGroupTableColumns = ({
onDelete,
isDeleting,
}: GetUserGroupTableColumnsParams = {}): ColumnType<UserGroup>[] => {
return [
{
key: "name",
title: "نام گروه",
render: (item: UserGroup) => item.name || "-",
},
{
key: "createdAt",
title: "تاریخ ایجاد",
render: (item: UserGroup) => formatOptionalDate(item.createdAt),
},
{
key: "actions",
title: "",
render: (item: UserGroup) => (
<div className="flex gap-2 items-center">
<Link to={Pages.customers.groupsUpdate + item.id}>
<Eye size={20} color="#8C90A3" className="cursor-pointer" />
</Link>
{onDelete && (
<TrashWithConfrim
onDelete={() => onDelete(item.id)}
isloading={isDeleting}
/>
)}
</div>
),
},
];
};
@@ -0,0 +1,98 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as api from "../service/UserGroupService";
import type {
AddUsersToGroupType,
CreateUserGroupType,
GetUserGroupsParams,
} from "../types/Types";
export const useGetUserGroups = (params?: GetUserGroupsParams) => {
return useQuery({
queryKey: ["user-groups", params],
queryFn: () => api.getUserGroups(params),
});
};
export const useCreateUserGroup = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.createUserGroup,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-groups"] });
},
});
};
export const useDeleteUserGroup = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.deleteUserGroup,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-groups"] });
},
});
};
export const useGetUserGroup = (id: string) => {
return useQuery({
queryKey: ["user-group", id],
queryFn: () => api.getUserGroup(id),
enabled: !!id,
});
};
export const useUpdateUserGroup = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, params }: { id: string; params: CreateUserGroupType }) =>
api.updateUserGroup(id, params),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ["user-groups"] });
queryClient.invalidateQueries({ queryKey: ["user-group", id] });
},
});
};
export const useGetGroupUsers = (groupId: string) => {
return useQuery({
queryKey: ["user-group-users", groupId],
queryFn: () => api.getGroupUsers(groupId),
enabled: !!groupId,
});
};
export const useAddUsersToGroup = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
groupId,
params,
}: {
groupId: string;
params: AddUsersToGroupType;
}) => api.addUsersToGroup(groupId, params),
onSuccess: (_, { groupId }) => {
queryClient.invalidateQueries({
queryKey: ["user-group-users", groupId],
});
},
});
};
export const useRemoveUserFromGroup = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
groupId,
userId,
}: {
groupId: string;
userId: string;
}) => api.removeUserFromGroup(groupId, userId),
onSuccess: (_, { groupId }) => {
queryClient.invalidateQueries({
queryKey: ["user-group-users", groupId],
});
},
});
};
@@ -0,0 +1,42 @@
import { useMemo, useState } from "react";
import type { FilterValues } from "@/components/Filters";
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 10;
export const useUserGroupFilters = () => {
const [filters, setFilters] = useState<FilterValues>({});
const [currentPage, setCurrentPage] = useState(DEFAULT_PAGE);
const [limit] = useState(DEFAULT_LIMIT);
const apiParams = useMemo(() => {
const params: Record<string, unknown> = {
page: currentPage,
limit,
};
if (filters.search) {
params.search = filters.search;
}
return params;
}, [filters, currentPage, limit]);
const handleFiltersChange = (newFilters: FilterValues) => {
setFilters(newFilters);
setCurrentPage(DEFAULT_PAGE);
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
return {
filters,
currentPage,
apiParams,
handleFiltersChange,
handlePageChange,
limit,
};
};
@@ -0,0 +1,70 @@
import axios from "@/config/axios";
import type {
AddUsersToGroupType,
CreateUserGroupType,
GetUserGroupsParams,
UserGroupResponse,
UserGroupsResponse,
UsersInGroupResponse,
} from "../types/Types";
export const getUserGroups = async (
params?: GetUserGroupsParams
): Promise<UserGroupsResponse> => {
const { data } = await axios.get<UserGroupsResponse>("/admin/user-groups", {
params,
});
return data;
};
export const createUserGroup = async (params: CreateUserGroupType) => {
const { data } = await axios.post("/admin/user-groups", params);
return data;
};
export const deleteUserGroup = async (id: string) => {
const { data } = await axios.delete(`/admin/user-groups/${id}`);
return data;
};
export const getUserGroup = async (id: string): Promise<UserGroupResponse> => {
const { data } = await axios.get<UserGroupResponse>(
`/admin/user-groups/${id}`
);
return data;
};
export const updateUserGroup = async (
id: string,
params: CreateUserGroupType
) => {
const { data } = await axios.patch(`/admin/user-groups/${id}`, params);
return data;
};
export const getGroupUsers = async (
groupId: string
): Promise<UsersInGroupResponse> => {
const { data } = await axios.get<UsersInGroupResponse>(
`/admin/user-groups/${groupId}/users`
);
return data;
};
export const addUsersToGroup = async (
groupId: string,
params: AddUsersToGroupType
) => {
const { data } = await axios.post(
`/admin/user-groups/${groupId}/users`,
params
);
return data;
};
export const removeUserFromGroup = async (groupId: string, userId: string) => {
const { data } = await axios.delete(
`/admin/user-groups/${groupId}/users/${userId}`
);
return data;
};
+29
View File
@@ -0,0 +1,29 @@
import type { User } from "@/pages/customers/types/Types";
import type { IResponse } from "@/types/response.types";
export type UserGroup = {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
name: string;
};
export type UserGroupsResponse = IResponse<UserGroup[]>;
export type UserGroupResponse = IResponse<UserGroup>;
export type CreateUserGroupType = {
name: string;
};
export type GetUserGroupsParams = {
page?: number;
limit?: number;
search?: string;
};
export type UsersInGroupResponse = IResponse<User[]>;
export type AddUsersToGroupType = {
userIds: string[];
};
+17 -1
View File
@@ -1,6 +1,10 @@
import * as api from "../service/UsersService"; import * as api from "../service/UsersService";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { CreateUserAddressType, GetUsersParams } from "../types/Types"; import type {
CreateUserAddressType,
GetUsersParams,
UpdateUserType,
} from "../types/Types";
export const useGetUsers = (params?: GetUsersParams) => { export const useGetUsers = (params?: GetUsersParams) => {
return useQuery({ return useQuery({
@@ -26,6 +30,18 @@ export const useCreateUser = () => {
}); });
}; };
export const useUpdateUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ userId, params }: { userId: string; params: UpdateUserType }) =>
api.updateUser(userId, params),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
};
export const useCreateUserAddress = () => { export const useCreateUserAddress = () => {
return useMutation({ return useMutation({
mutationFn: ({ mutationFn: ({
@@ -8,6 +8,8 @@ import type {
GetUsersParams, GetUsersParams,
ImportUsersResponse, ImportUsersResponse,
SearchUserByPhoneResponse, SearchUserByPhoneResponse,
UpdateUserResponse,
UpdateUserType,
} from "@/pages/customers/types/Types"; } from "@/pages/customers/types/Types";
export const getUsers = async (params?: GetUsersParams) => { export const getUsers = async (params?: GetUsersParams) => {
@@ -30,6 +32,14 @@ export const createUser = async (params: CreateUserType) => {
return data; return data;
}; };
export const updateUser = async (userId: string, params: UpdateUserType) => {
const { data } = await axios.patch<UpdateUserResponse>(
`/admin/users/${userId}`,
params,
);
return data;
};
export const createUserAddress = async ( export const createUserAddress = async (
userId: string, userId: string,
params: CreateUserAddressType, params: CreateUserAddressType,
+17
View File
@@ -76,6 +76,12 @@ export type CustomerListRow = RowDataType & {
orderCount: string; orderCount: string;
totalOrderAmount: string; totalOrderAmount: string;
status: string; status: string;
firstName?: string;
lastName?: string;
birthDateRaw?: string;
marriageDateRaw?: string;
gender?: boolean;
avatarUrl?: string | null;
}; };
export type ImportUserRowError = { export type ImportUserRowError = {
@@ -129,3 +135,14 @@ export type CreateUserAddressType = {
}; };
export type CreateUserAddressResponse = IResponse<UserSavedAddress>; export type CreateUserAddressResponse = IResponse<UserSavedAddress>;
export type UpdateUserType = {
firstName?: string;
lastName?: string;
birthDate?: string;
marriageDate?: string;
gender?: boolean;
avatarUrl?: string;
};
export type UpdateUserResponse = IResponse<User>;
+1 -2
View File
@@ -21,7 +21,6 @@ const OrdersList: FC = () => {
apiParams, apiParams,
handleFiltersChange, handleFiltersChange,
handlePageChange, handlePageChange,
limit,
} = useOrderFilters() } = useOrderFilters()
const { data: ordersData, isLoading } = useGetOrders(apiParams) const { data: ordersData, isLoading } = useGetOrders(apiParams)
@@ -30,7 +29,7 @@ const OrdersList: FC = () => {
const columns = getOrderTableColumns({ t }) const columns = getOrderTableColumns({ t })
const filterFields = useOrderFiltersFields() const filterFields = useOrderFiltersFields()
const totalPages = Math.ceil(orders.length / limit) || 1 const totalPages = ordersData?.meta?.totalPages || 1
return ( return (
<div className='mt-5'> <div className='mt-5'>
+2 -2
View File
@@ -208,9 +208,9 @@ export const useCreateOrderForm = () => {
}) })
} }
const registerNewCustomer = async (): Promise<string | null> => { const registerNewCustomer = async (): Promise<string | undefined> => {
if (!isNewCustomer) { if (!isNewCustomer) {
return customerUserId return customerUserId ?? undefined
} }
const response = await createUser({ const response = await createUser({
+81 -7
View File
@@ -1,14 +1,80 @@
import { useState, useMemo } from "react"; import { useState, useMemo, useCallback, useEffect } from "react";
import { useSearchParams } from "react-router-dom";
import type { FilterValues } from "@/components/Filters"; import type { FilterValues } from "@/components/Filters";
const DEFAULT_PAGE = 1; const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 10; const DEFAULT_LIMIT = 10;
const DEFAULT_FILTERS: FilterValues = {
statuses: [],
paymentStatus: "",
};
const getFiltersFromParams = (searchParams: URLSearchParams): FilterValues => {
const filters: FilterValues = { ...DEFAULT_FILTERS };
const search = searchParams.get("search");
const statuses = searchParams.getAll("statuses");
const paymentStatus = searchParams.get("paymentStatus");
if (search) filters.search = search;
if (statuses.length > 0) filters.statuses = statuses;
if (paymentStatus) filters.paymentStatus = paymentStatus;
return filters;
};
const getPageFromParams = (searchParams: URLSearchParams): number => {
const page = searchParams.get("page");
const parsed = page ? parseInt(page, 10) : DEFAULT_PAGE;
return Number.isNaN(parsed) || parsed < 1 ? DEFAULT_PAGE : parsed;
};
const buildSearchParams = (filters: FilterValues, page: number): URLSearchParams => {
const params = new URLSearchParams();
if (page > DEFAULT_PAGE) {
params.set("page", String(page));
}
if (filters.search) {
params.set("search", filters.search as string);
}
if (filters.statuses && Array.isArray(filters.statuses) && filters.statuses.length > 0) {
(filters.statuses as string[]).forEach((status) => {
params.append("statuses", status);
});
}
if (filters.paymentStatus) {
params.set("paymentStatus", filters.paymentStatus as string);
}
return params;
};
export const useOrderFilters = () => { export const useOrderFilters = () => {
const [filters, setFilters] = useState<FilterValues>({}); const [searchParams, setSearchParams] = useSearchParams();
const [currentPage, setCurrentPage] = useState(DEFAULT_PAGE);
const [filters, setFilters] = useState<FilterValues>(() =>
getFiltersFromParams(searchParams),
);
const [currentPage, setCurrentPage] = useState(() =>
getPageFromParams(searchParams),
);
const [limit] = useState(DEFAULT_LIMIT); const [limit] = useState(DEFAULT_LIMIT);
useEffect(() => {
setFilters(getFiltersFromParams(searchParams));
setCurrentPage(getPageFromParams(searchParams));
}, [searchParams]);
const updateUrlParams = useCallback(
(newFilters: FilterValues, page: number) => {
setSearchParams(buildSearchParams(newFilters, page), { replace: true });
},
[setSearchParams],
);
const apiParams = useMemo(() => { const apiParams = useMemo(() => {
const params: Record<string, string | number | string[]> = { const params: Record<string, string | number | string[]> = {
page: currentPage, page: currentPage,
@@ -30,14 +96,22 @@ export const useOrderFilters = () => {
return params; return params;
}, [filters, currentPage, limit]); }, [filters, currentPage, limit]);
const handleFiltersChange = (newFilters: FilterValues) => { const handleFiltersChange = useCallback(
(newFilters: FilterValues) => {
setFilters(newFilters); setFilters(newFilters);
setCurrentPage(DEFAULT_PAGE); setCurrentPage(DEFAULT_PAGE);
}; updateUrlParams(newFilters, DEFAULT_PAGE);
},
[updateUrlParams],
);
const handlePageChange = (page: number) => { const handlePageChange = useCallback(
(page: number) => {
setCurrentPage(page); setCurrentPage(page);
}; updateUrlParams(filters, page);
},
[filters, updateUrlParams],
);
return { return {
filters, filters,
+10 -1
View File
@@ -218,7 +218,16 @@ export interface Order extends RowDataType {
items: OrderItem[]; items: OrderItem[];
} }
export type GetOrdersResponse = IResponse<Order[]>; export type PaginationMeta = {
total: number;
page: number;
limit: number;
totalPages: number;
};
export type GetOrdersResponse = IResponse<Order[]> & {
meta: PaginationMeta;
};
export type GetOrderByIdResponse = IResponse<Order>; export type GetOrderByIdResponse = IResponse<Order>;
export interface FoodOrderReportFood { export interface FoodOrderReportFood {
+1
View File
@@ -26,4 +26,5 @@ export const enum PermissionEnum {
MANAGE_SCHEDULES = "manage_schedules", MANAGE_SCHEDULES = "manage_schedules",
MANAGE_REPORTS = "manage_reports", MANAGE_REPORTS = "manage_reports",
MANAGE_CONTACTS = "manage_contacts", MANAGE_CONTACTS = "manage_contacts",
MANAGE_SLIDERS = "manage_sliders",
} }
+163
View File
@@ -0,0 +1,163 @@
import { type FC, useState } from "react";
import { useFormik } from "formik";
import * as Yup from "yup";
import { TickCircle } from "iconsax-react";
import { toast } from "react-toastify";
import { useNavigate } from "react-router-dom";
import Button from "@/components/Button";
import Input from "@/components/Input";
import { Pages } from "@/config/Pages";
import type { ErrorType } from "@/helpers/types";
import { extractErrorMessage } from "@/config/func";
import { useCreateSlider } from "./hooks/useSliderData";
import { useSingleUpload } from "../uploader/hooks/useUploaderData";
import type { CreateSliderType } from "./types/Types";
import SliderSidebar from "./components/SliderSidebar";
const CreateSlider: FC = () => {
const navigate = useNavigate();
const { mutate: createSlider, isPending } = useCreateSlider();
const { mutate: singleUpload, isPending: isUploading } = useSingleUpload();
const [imageFiles, setImageFiles] = useState<File[]>([]);
const formik = useFormik<CreateSliderType>({
initialValues: {
imageUrl: "",
title: "",
link: "",
order: 0,
isActive: true,
},
validationSchema: Yup.object().shape({
title: Yup.string().required("عنوان الزامی است"),
link: Yup.string()
.transform((value) => (value?.trim() === "" ? undefined : value))
.url("لینک معتبر نیست")
.notRequired(),
order: Yup.number().required("ترتیب الزامی است").min(0, "ترتیب باید مثبت باشد"),
isActive: Yup.boolean(),
}),
onSubmit: (values) => {
if (imageFiles.length === 0) {
toast.error("تصویر اسلایدر الزامی است");
return;
}
const submitSlider = (imageUrl: string) => {
createSlider(
{
...values,
imageUrl,
},
{
onSuccess: () => {
toast.success("اسلایدر با موفقیت ایجاد شد");
navigate(Pages.sliders.list);
},
onError: (error: ErrorType) => {
toast.error(extractErrorMessage(error));
},
}
);
};
singleUpload(imageFiles[0], {
onSuccess: (response) => {
const uploadedImageUrl = response?.data?.url || "";
if (!uploadedImageUrl) {
toast.error("آدرس تصویر آپلود شده دریافت نشد");
return;
}
submitSlider(uploadedImageUrl);
},
onError: (error: ErrorType) => {
toast.error(extractErrorMessage(error, "خطا در آپلود تصویر"));
},
});
},
});
return (
<div className="mt-5">
<div className="flex flex-col sm:flex-row w-full justify-between items-start sm:items-center gap-4">
<div className="text-lg font-light">افزودن اسلایدر</div>
<Button
className="px-5 w-full sm:w-auto"
onClick={async () => {
const errors = await formik.validateForm();
formik.setTouched({
title: true,
link: true,
order: true,
});
if (Object.keys(errors).length > 0) {
const firstError = Object.values(errors).find((error) => typeof error === "string");
if (firstError) toast.error(firstError);
return;
}
if (imageFiles.length === 0) {
toast.error("تصویر اسلایدر الزامی است");
return;
}
formik.handleSubmit();
}}
isloading={isPending || isUploading}
>
<div className="flex gap-2 items-center">
<TickCircle className="size-5" color="white" />
<div>ثبت اسلایدر</div>
</div>
</Button>
</div>
<div className="flex flex-col lg:flex-row gap-6 mt-6">
<div className="flex-1">
<div className="bg-white py-6 sm:py-8 xl:px-10 px-4 rounded-3xl">
<Input
label="عنوان"
name="title"
value={formik.values.title}
onChange={formik.handleChange}
error_text={formik.touched.title && formik.errors.title ? formik.errors.title : ""}
/>
<div className="mt-5">
<Input
label="لینک"
name="link"
value={formik.values.link}
onChange={formik.handleChange}
error_text={formik.touched.link && formik.errors.link ? formik.errors.link : ""}
/>
</div>
<div className="mt-5">
<Input
label="ترتیب"
name="order"
type="number"
value={formik.values.order ?? ""}
onChange={(e) => {
const value = e.target.value === "" ? 0 : Number(e.target.value);
formik.setFieldValue("order", value);
}}
error_text={formik.touched.order && formik.errors.order ? formik.errors.order : ""}
/>
</div>
</div>
</div>
<div className="w-full lg:w-auto">
<SliderSidebar
isActive={formik.values.isActive}
onChangeActive={(value) => formik.setFieldValue("isActive", value)}
onChangeImage={setImageFiles}
/>
</div>
</div>
</div>
);
};
export default CreateSlider;
+71
View File
@@ -0,0 +1,71 @@
import { type FC } from "react";
import Table from "@/components/Table";
import Filters from "@/components/Filters";
import { Add } from "iconsax-react";
import Button from "@/components/Button";
import { Link } from "react-router-dom";
import { Pages } from "@/config/Pages";
import { useGetSliders, useDeleteSlider } from "./hooks/useSliderData";
import { useSliderFilters } from "./hooks/useSliderFilters";
import { getSliderTableColumns } from "./components/SliderTableColumns";
import { useSliderFiltersFields } from "./components/SliderFiltersFields";
const SlidersList: FC = () => {
const {
filters,
currentPage,
apiParams,
handleFiltersChange,
handlePageChange,
limit,
} = useSliderFilters();
const { data: slidersData, isLoading } = useGetSliders(apiParams);
const { mutate: deleteSlider, isPending: isDeleting } = useDeleteSlider();
const sliders = slidersData?.data || [];
const columns = getSliderTableColumns({
onDelete: (id: string) => deleteSlider(id),
isDeleting,
});
const filterFields = useSliderFiltersFields();
const totalPages = Math.ceil(sliders.length / limit) || 1;
return (
<div className="mt-5">
<div className="flex justify-between items-center">
<h1 className="text-lg font-light">لیست اسلایدرها</h1>
<Link to={Pages.sliders.add}>
<Button className="w-fit px-6">
<div className="flex gap-2 items-center">
<Add color="#fff" size={20} />
<span>افزودن اسلایدر</span>
</div>
</Button>
</Link>
</div>
<div className="mt-8">
<Filters
fields={filterFields}
onChange={handleFiltersChange}
initialValues={filters}
searchField="search"
/>
</div>
<Table
columns={columns}
data={sliders}
isloading={isLoading}
pagination={{
currentPage,
totalPages,
onPageChange: handlePageChange,
}}
/>
</div>
);
};
export default SlidersList;
+199
View File
@@ -0,0 +1,199 @@
import { type FC, useMemo, useState } from "react";
import { useFormik } from "formik";
import * as Yup from "yup";
import { TickCircle } from "iconsax-react";
import { toast } from "react-toastify";
import { useNavigate, useParams } from "react-router-dom";
import Button from "@/components/Button";
import Input from "@/components/Input";
import { Pages } from "@/config/Pages";
import type { ErrorType } from "@/helpers/types";
import { extractErrorMessage } from "@/config/func";
import { useGetSlider, useUpdateSlider } from "./hooks/useSliderData";
import { useSingleUpload } from "../uploader/hooks/useUploaderData";
import type { CreateSliderType } from "./types/Types";
import SliderSidebar from "./components/SliderSidebar";
const UpdateSlider: FC = () => {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const { mutate: updateSlider, isPending } = useUpdateSlider();
const { mutate: singleUpload, isPending: isUploading } = useSingleUpload();
const { data: sliderData, isLoading } = useGetSlider(id || "");
const [imageFiles, setImageFiles] = useState<File[]>([]);
const initialValues = useMemo<CreateSliderType>(() => {
if (sliderData?.data) {
return {
imageUrl: sliderData.data.imageUrl || "",
title: sliderData.data.title || "",
link: sliderData.data.link || "",
order: sliderData.data.order ?? 0,
isActive: sliderData.data.isActive ?? true,
};
}
return {
imageUrl: "",
title: "",
link: "",
order: 0,
isActive: true,
};
}, [sliderData]);
const formik = useFormik<CreateSliderType>({
initialValues,
enableReinitialize: true,
validationSchema: Yup.object().shape({
title: Yup.string().required("عنوان الزامی است"),
link: Yup.string()
.transform((value) => (value?.trim() === "" ? undefined : value))
.url("لینک معتبر نیست")
.notRequired(),
order: Yup.number().required("ترتیب الزامی است").min(0, "ترتیب باید مثبت باشد"),
isActive: Yup.boolean(),
}),
onSubmit: (values) => {
if (!id) return;
if (imageFiles.length === 0 && !values.imageUrl) {
toast.error("تصویر اسلایدر الزامی است");
return;
}
const submitSlider = (imageUrl: string) => {
updateSlider(
{
id,
params: {
...values,
imageUrl,
},
},
{
onSuccess: () => {
toast.success("اسلایدر با موفقیت ویرایش شد");
navigate(Pages.sliders.list);
},
onError: (error: ErrorType) => {
toast.error(extractErrorMessage(error));
},
}
);
};
if (imageFiles.length > 0) {
singleUpload(imageFiles[0], {
onSuccess: (response) => {
const uploadedImageUrl = response?.data?.url || "";
if (!uploadedImageUrl) {
toast.error("آدرس تصویر آپلود شده دریافت نشد");
return;
}
submitSlider(uploadedImageUrl);
},
onError: (error: ErrorType) => {
toast.error(extractErrorMessage(error, "خطا در آپلود تصویر"));
},
});
return;
}
submitSlider(values.imageUrl);
},
});
if (isLoading) {
return (
<div className="mt-5">
<div className="text-lg font-light">در حال بارگذاری...</div>
</div>
);
}
return (
<div className="mt-5">
<div className="flex flex-col sm:flex-row w-full justify-between items-start sm:items-center gap-4">
<div className="text-lg font-light">ویرایش اسلایدر</div>
<Button
className="px-5 w-full sm:w-auto"
onClick={async () => {
const errors = await formik.validateForm();
formik.setTouched({
title: true,
link: true,
order: true,
});
if (Object.keys(errors).length > 0) {
const firstError = Object.values(errors).find((error) => typeof error === "string");
if (firstError) toast.error(firstError);
return;
}
if (imageFiles.length === 0 && !formik.values.imageUrl) {
toast.error("تصویر اسلایدر الزامی است");
return;
}
formik.handleSubmit();
}}
isloading={isPending || isUploading}
>
<div className="flex gap-2 items-center">
<TickCircle className="size-5" color="white" />
<div>ثبت تغییرات</div>
</div>
</Button>
</div>
<div className="flex flex-col lg:flex-row gap-6 mt-6">
<div className="flex-1">
<div className="bg-white py-6 sm:py-8 xl:px-10 px-4 rounded-3xl">
<Input
label="عنوان"
name="title"
value={formik.values.title}
onChange={formik.handleChange}
error_text={formik.touched.title && formik.errors.title ? formik.errors.title : ""}
/>
<div className="mt-5">
<Input
label="لینک"
name="link"
value={formik.values.link}
onChange={formik.handleChange}
error_text={formik.touched.link && formik.errors.link ? formik.errors.link : ""}
/>
</div>
<div className="mt-5">
<Input
label="ترتیب"
name="order"
type="number"
value={formik.values.order ?? ""}
onChange={(e) => {
const value = e.target.value === "" ? 0 : Number(e.target.value);
formik.setFieldValue("order", value);
}}
error_text={formik.touched.order && formik.errors.order ? formik.errors.order : ""}
/>
</div>
</div>
</div>
<div className="w-full lg:w-auto">
<SliderSidebar
isActive={formik.values.isActive}
onChangeActive={(value) => formik.setFieldValue("isActive", value)}
onChangeImage={setImageFiles}
preview={formik.values.imageUrl ? [formik.values.imageUrl] : []}
/>
</div>
</div>
</div>
);
};
export default UpdateSlider;
@@ -0,0 +1,15 @@
import { useMemo } from "react";
import type { FieldType } from "@/components/Filters";
export const useSliderFiltersFields = (): FieldType[] => {
return useMemo(
() => [
{
type: "input",
name: "search",
placeholder: "جستجو",
},
],
[]
);
};
@@ -0,0 +1,36 @@
import { type FC } from "react";
import UploadBoxDraggble from "@/components/UploadBoxDraggble";
import Switch from "@/components/Switch";
type Props = {
isActive: boolean;
onChangeActive: (value: boolean) => void;
onChangeImage: (files: File[]) => void;
preview?: string[];
};
const SliderSidebar: FC<Props> = (props) => {
return (
<div className="bg-white w-full lg:w-[300px] text-xs h-fit px-5 py-7 rounded-3xl">
<div className="flex items-center justify-between">
<div>وضعیت اسلایدر</div>
<Switch
active={props.isActive}
onChange={(value) => props.onChangeActive(value)}
/>
</div>
<div className="mt-8">
<div className="text-sm mb-2">تصویر اسلایدر</div>
<UploadBoxDraggble
label="تصویر اسلایدر را آپلود کنید"
onChange={(files) => props.onChangeImage(files)}
preview={props.preview}
imageSize="large"
/>
</div>
</div>
);
};
export default SliderSidebar;
@@ -0,0 +1,73 @@
import type { ColumnType } from "@/components/types/TableTypes";
import type { Slider } from "../types/Types";
import TrashWithConfrim from "@/components/TrashWithConfrim";
import { Eye } from "iconsax-react";
import { Link } from "react-router-dom";
import { Pages } from "@/config/Pages";
import Status from "@/components/Status";
interface GetSliderTableColumnsParams {
onDelete?: (id: string) => void;
isDeleting?: boolean;
}
export const getSliderTableColumns = ({
onDelete,
isDeleting,
}: GetSliderTableColumnsParams = {}): ColumnType<Slider>[] => {
return [
{
key: "title",
title: "عنوان",
render: (item: Slider) => item.title || "-",
},
{
key: "imageUrl",
title: "تصویر",
render: (item: Slider) => (
<img
src={item.imageUrl}
alt={item.title}
className="w-16 h-10 rounded object-cover"
/>
),
},
{
key: "link",
title: "لینک",
render: (item: Slider) => item.link || "-",
},
{
key: "order",
title: "ترتیب",
render: (item: Slider) => item.order,
},
{
key: "isActive",
title: "وضعیت",
render: (item: Slider) => (
<Status
variant={item.isActive ? "success" : "error"}
label={item.isActive ? "فعال" : "غیرفعال"}
/>
),
},
{
key: "actions",
title: "",
render: (item: Slider) => (
<div className="flex gap-2 items-center">
<Link to={Pages.sliders.update + item.id}>
<Eye size={20} color="#8C90A3" className="cursor-pointer" />
</Link>
{onDelete && (
<TrashWithConfrim
onDelete={() => onDelete(item.id)}
isloading={isDeleting}
/>
)}
</div>
),
},
];
};
+55
View File
@@ -0,0 +1,55 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as api from "../service/SliderService";
import type { CreateSliderType, GetSlidersParams } from "../types/Types";
export const useGetSliders = (params?: GetSlidersParams) => {
return useQuery({
queryKey: ["sliders", params],
queryFn: () => api.getSliders(params),
});
};
export const useCreateSlider = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.createSlider,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["sliders"],
});
},
});
};
export const useDeleteSlider = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.deleteSlider,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["sliders"],
});
},
});
};
export const useGetSlider = (id: string) => {
return useQuery({
queryKey: ["slider", id],
queryFn: () => api.getSlider(id),
enabled: !!id,
});
};
export const useUpdateSlider = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, params }: { id: string; params: CreateSliderType }) =>
api.updateSlider(id, params),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["sliders"],
});
},
});
};
@@ -0,0 +1,43 @@
import { useMemo, useState } from "react";
import type { FilterValues } from "@/components/Filters";
import type { GetSlidersParams } from "../types/Types";
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 10;
export const useSliderFilters = () => {
const [filters, setFilters] = useState<FilterValues>({});
const [currentPage, setCurrentPage] = useState(DEFAULT_PAGE);
const [limit] = useState(DEFAULT_LIMIT);
const apiParams = useMemo<GetSlidersParams>(() => {
const params: GetSlidersParams = {
page: currentPage,
limit,
};
if (filters.search) {
params.search = filters.search as string;
}
return params;
}, [filters, currentPage, limit]);
const handleFiltersChange = (newFilters: FilterValues) => {
setFilters(newFilters);
setCurrentPage(DEFAULT_PAGE);
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
return {
filters,
currentPage,
apiParams,
handleFiltersChange,
handlePageChange,
limit,
};
};
@@ -0,0 +1,34 @@
import axios from "@/config/axios";
import type {
CreateSliderType,
GetSlidersParams,
SliderResponse,
SlidersResponse,
} from "../types/Types";
export const getSliders = async (
params?: GetSlidersParams
): Promise<SlidersResponse> => {
const { data } = await axios.get<SlidersResponse>("/admin/sliders", { params });
return data;
};
export const createSlider = async (params: CreateSliderType) => {
const { data } = await axios.post("/admin/sliders", params);
return data;
};
export const deleteSlider = async (id: string) => {
const { data } = await axios.delete(`/admin/sliders/${id}`);
return data;
};
export const getSlider = async (id: string): Promise<SliderResponse> => {
const { data } = await axios.get<SliderResponse>(`/admin/sliders/${id}`);
return data;
};
export const updateSlider = async (id: string, params: CreateSliderType) => {
const { data } = await axios.patch(`/admin/sliders/${id}`, params);
return data;
};
+30
View File
@@ -0,0 +1,30 @@
import type { IResponse } from "@/types/response.types";
export type Slider = {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
imageUrl: string;
title: string;
link: string;
order: number;
isActive: boolean;
};
export type SlidersResponse = IResponse<Slider[]>;
export type SliderResponse = IResponse<Slider>;
export type CreateSliderType = {
imageUrl: string;
title: string;
link?: string;
order: number;
isActive: boolean;
};
export type GetSlidersParams = {
page?: number;
limit?: number;
search?: string;
};
+18
View File
@@ -13,6 +13,7 @@ import CategoryFood from '@/pages/food/Category'
import CustomersList from '@/pages/customers/List' import CustomersList from '@/pages/customers/List'
import CustomerDetails from '@/pages/customers/Details' import CustomerDetails from '@/pages/customers/Details'
import CreateCustomer from '@/pages/customers/Create' import CreateCustomer from '@/pages/customers/Create'
import UpdateCustomer from '@/pages/customers/Update'
import OrdersList from '@/pages/orders/List' import OrdersList from '@/pages/orders/List'
import CreateOrder from '@/pages/orders/Create' import CreateOrder from '@/pages/orders/Create'
import OrderDetails from '@/pages/orders/Details' import OrderDetails from '@/pages/orders/Details'
@@ -46,6 +47,14 @@ import NotificationsList from '@/pages/notifications/List'
import Notification from '@/components/Notification' import Notification from '@/components/Notification'
import ReportsList from '@/pages/report/List' import ReportsList from '@/pages/report/List'
import Statistics from '@/pages/statistics/Statistics' import Statistics from '@/pages/statistics/Statistics'
import SlidersList from '@/pages/sliders/List'
import CreateSlider from '@/pages/sliders/Create'
import UpdateSlider from '@/pages/sliders/Update'
import UserGroupsList from '@/pages/customers/groups/List'
import CreateUserGroup from '@/pages/customers/groups/Create'
import UpdateUserGroup from '@/pages/customers/groups/Update'
import CampaignsList from '@/pages/customers/campaigns/List'
import CreateCampaign from '@/pages/customers/campaigns/Create'
const MainRouter: FC = () => { const MainRouter: FC = () => {
const { hasSubMenu } = useSharedStore() const { hasSubMenu } = useSharedStore()
@@ -73,6 +82,12 @@ const MainRouter: FC = () => {
<Route path={Pages.customers.list} element={<CustomersList />} /> <Route path={Pages.customers.list} element={<CustomersList />} />
<Route path={Pages.customers.detail + ':id'} element={<CustomerDetails />} /> <Route path={Pages.customers.detail + ':id'} element={<CustomerDetails />} />
<Route path={Pages.customers.add} element={<CreateCustomer />} /> <Route path={Pages.customers.add} element={<CreateCustomer />} />
<Route path={Pages.customers.update + ':id'} element={<UpdateCustomer />} />
<Route path={Pages.customers.groups} element={<UserGroupsList />} />
<Route path={Pages.customers.groupsAdd} element={<CreateUserGroup />} />
<Route path={Pages.customers.groupsUpdate + ':id'} element={<UpdateUserGroup />} />
<Route path={Pages.customers.campaigns} element={<CampaignsList />} />
<Route path={Pages.customers.campaignsAdd} element={<CreateCampaign />} />
<Route path={Pages.orders.list} element={<OrdersList />} /> <Route path={Pages.orders.list} element={<OrdersList />} />
<Route path={Pages.orders.add} element={<CreateOrder />} /> <Route path={Pages.orders.add} element={<CreateOrder />} />
<Route path={Pages.orders.foodReport} element={<FoodOrderReport />} /> <Route path={Pages.orders.foodReport} element={<FoodOrderReport />} />
@@ -110,6 +125,9 @@ const MainRouter: FC = () => {
<Route path={Pages.pagers.list} element={<PagersList />} /> <Route path={Pages.pagers.list} element={<PagersList />} />
<Route path={Pages.notifications.list} element={<NotificationsList />} /> <Route path={Pages.notifications.list} element={<NotificationsList />} />
<Route path={Pages.sliders.list} element={<SlidersList />} />
<Route path={Pages.sliders.add} element={<CreateSlider />} />
<Route path={Pages.sliders.update + ':id'} element={<UpdateSlider />} />
<Route path={Pages.reports.list} element={<ReportsList />} /> <Route path={Pages.reports.list} element={<ReportsList />} />
+24 -15
View File
@@ -1,21 +1,21 @@
import { type FC, useEffect } from 'react'
import LogoImage from '../assets/images/logo.png'
import LogoSmall from '../assets/images/logo-small.png'
import { useTranslation } from 'react-i18next'
import { DocumentText, Home2, Logout, People, Setting2, TicketDiscount, Calendar, Security, Card, SecurityUser, TruckFast, Element3, Star1, NotificationBing, SmsEdit } from 'iconsax-react'
import SideBarItem from './SideBarItem'
import { useLocation } from 'react-router-dom'
import { Pages } from '../config/Pages'
import { useSharedStore } from './store/sharedStore'
import { clx } from '../helpers/utils'
import FoodsSubMenu from './components/FoodsSubMenu'
import OrdersSubMenu from './components/OrdersSubMenu'
import CustomersSubMenu from './components/CustomersSubMenu'
import DiscountSubMenu from './components/DiscountSubMenu'
import { SideBarItemHasSubMenu } from '../config/SideBarSubMenu'
import { usePermissions } from '@/hooks/usePermissions' import { usePermissions } from '@/hooks/usePermissions'
import { PermissionEnum } from '@/pages/roles/enum/Enum' import { PermissionEnum } from '@/pages/roles/enum/Enum'
import { Calendar, Card, DocumentText, Element3, Gallery, Home2, Logout, NotificationBing, People, Security, SecurityUser, Setting2, SmsEdit, Star1, TicketDiscount, TruckFast } from 'iconsax-react'
import { type FC, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import Skeleton from 'react-loading-skeleton' import Skeleton from 'react-loading-skeleton'
import { useLocation } from 'react-router-dom'
import LogoSmall from '../assets/images/logo-small.png'
import LogoImage from '../assets/images/logo.png'
import { Pages } from '../config/Pages'
import { SideBarItemHasSubMenu } from '../config/SideBarSubMenu'
import { clx } from '../helpers/utils'
import CustomersSubMenu from './components/CustomersSubMenu'
import DiscountSubMenu from './components/DiscountSubMenu'
import FoodsSubMenu from './components/FoodsSubMenu'
import OrdersSubMenu from './components/OrdersSubMenu'
import SideBarItem from './SideBarItem'
import { useSharedStore } from './store/sharedStore'
const SideBar: FC = () => { const SideBar: FC = () => {
@@ -289,6 +289,15 @@ const SideBar: FC = () => {
activeName='reports' activeName='reports'
/> />
)} )}
{checkPermission(PermissionEnum.MANAGE_SLIDERS) && (
<SideBarItem
icon={<Gallery variant={isActive('sliders') ? 'Bold' : 'Outline'} color={isActive('sliders') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
title={t('sidebar.sliders')}
isActive={isActive('sliders')}
link={Pages.sliders.list}
activeName='sliders'
/>
)}
</div> </div>
)} )}
+18 -34
View File
@@ -1,45 +1,29 @@
import { People } from 'iconsax-react' import { People } from "iconsax-react";
import { type FC } from 'react' import { type FC } from "react";
import { useTranslation } from 'react-i18next' import { useTranslation } from "react-i18next";
import SubMenuItem from './SubMenuItem' import { Pages } from "../../config/Pages";
import { Pages } from '../../config/Pages' import SubMenuItem from "./SubMenuItem";
const CustomerSubMenu: FC = () => { const CustomerSubMenu: FC = () => {
const { t } = useTranslation("global");
const { t } = useTranslation('global')
const isActive = (name: string) => { const isActive = (name: string) => {
return location.pathname.includes(name) return location.pathname.includes(name);
} };
return ( return (
<div className='py-12'> <div className="py-12">
<div className='flex gap-3 items-center border-b border-border pb-10 px-6'> <div className="flex gap-3 items-center border-b border-border pb-10 px-6">
<People <People size={24} color="#000" variant="Bold" />
size={24} <div className="text-xs">{t("sidebar.customers")}</div>
color='#000'
variant='Bold'
/>
<div className='text-xs'>
{t('sidebar.customers')}
</div>
</div> </div>
<div className='flex flex-col mt-10 gap-4'> <div className="flex flex-col mt-10 gap-4">
<SubMenuItem <SubMenuItem title={t("submenu.customer_list")} isActive={isActive("list")} link={Pages.orders.list} />
title={t('submenu.customer_list')} <SubMenuItem title={t("submenu.create_customer")} isActive={isActive("create")} link={Pages.orders.add} />
isActive={isActive('list')}
link={Pages.orders.list}
/>
<SubMenuItem
title={t('submenu.create_customer')}
isActive={isActive('create')}
link={Pages.orders.add}
/>
</div> </div>
</div> </div>
) );
} };
export default CustomerSubMenu export default CustomerSubMenu;
+23 -36
View File
@@ -1,47 +1,34 @@
import { People } from 'iconsax-react' import { People } from "iconsax-react";
import { type FC } from 'react' import { type FC } from "react";
import { useTranslation } from 'react-i18next' import { useTranslation } from "react-i18next";
import SubMenuItem from './SubMenuItem' import { useLocation } from "react-router-dom";
import { useLocation } from 'react-router-dom' import { Pages } from "../../config/Pages";
import { Pages } from '../../config/Pages' import SubMenuItem from "./SubMenuItem";
const CustomersSubMenu: FC = () => { const CustomersSubMenu: FC = () => {
const location = useLocation();
const location = useLocation() const { t } = useTranslation("global");
const { t } = useTranslation('global')
const isActive = (name: string) => { const isActive = (name: string) => {
return location.pathname.includes(name) return location.pathname.includes(name);
} };
return ( return (
<div className='py-12'> <div className="py-12">
<div className='flex gap-3 items-center border-b border-border pb-10 px-6'> <div className="flex gap-3 items-center border-b border-border pb-10 px-6">
<People <People size={24} color="#000" variant="Bold" />
size={24} <div className="text-xs">{t("sidebar.customers")}</div>
color='#000'
variant='Bold'
/>
<div className='text-xs'>
{t('sidebar.customers')}
</div>
</div> </div>
<div className='flex flex-col mt-10 gap-4'> <div className="flex flex-col mt-10 gap-4">
<SubMenuItem <SubMenuItem title={t("submenu.customers_list")} isActive={isActive("list")} link={Pages.customers.list} />
title={t('submenu.customers_list')} <SubMenuItem title={t("submenu.add_customer")} isActive={isActive("add")} link={Pages.customers.add} />
isActive={isActive('list')}
link={Pages.customers.list} <SubMenuItem title={t("submenu.groups")} isActive={isActive("groups")} link={Pages.customers.groups} />
/> <SubMenuItem title={t("submenu.campaigns")} isActive={isActive("campaigns")} link={Pages.customers.campaigns} />
<SubMenuItem
title={t('submenu.add_customer')}
isActive={isActive('add')}
link={Pages.customers.add}
/>
</div> </div>
</div> </div>
) );
} };
export default CustomersSubMenu
export default CustomersSubMenu;