Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95814d6b00 | |||
| a4eaa50fdf | |||
| 457361e759 | |||
| eb9973678c | |||
| bcf03887f1 | |||
| 6187e7c26d |
@@ -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'
|
||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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": "لیست راهنمای سرویس",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "./groups/List";
|
||||||
+143
-143
@@ -1,158 +1,158 @@
|
|||||||
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',
|
{
|
||||||
name: 'search',
|
type: "input",
|
||||||
placeholder: 'نام یا شماره تماس',
|
name: "search",
|
||||||
},
|
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',
|
{
|
||||||
title: 'نام و نام خانوادگی',
|
key: "fullName",
|
||||||
},
|
title: "نام و نام خانوادگی",
|
||||||
{
|
},
|
||||||
key: 'phone',
|
{
|
||||||
title: 'شماره تماس',
|
key: "phone",
|
||||||
},
|
title: "شماره تماس",
|
||||||
{
|
},
|
||||||
key: 'birthDate',
|
{
|
||||||
title: 'تاریخ تولد',
|
key: "birthDate",
|
||||||
},
|
title: "تاریخ تولد",
|
||||||
{
|
},
|
||||||
key: 'marriageDate',
|
{
|
||||||
title: 'تاریخ ازدواج',
|
key: "marriageDate",
|
||||||
},
|
title: "تاریخ ازدواج",
|
||||||
{
|
},
|
||||||
key: 'orderCount',
|
{
|
||||||
title: 'تعداد سفارش',
|
key: "orderCount",
|
||||||
},
|
title: "تعداد سفارش",
|
||||||
{
|
},
|
||||||
key: 'totalOrderAmount',
|
{
|
||||||
title: 'مجموع سفارش (تومان)',
|
key: "totalOrderAmount",
|
||||||
},
|
title: "مجموع سفارش (تومان)",
|
||||||
{
|
},
|
||||||
key: 'status',
|
{
|
||||||
title: 'وضعیت',
|
key: "status",
|
||||||
render: (item: CustomerListRow) => (
|
title: "وضعیت",
|
||||||
<span
|
render: (item: CustomerListRow) => (
|
||||||
className={`px-3 py-1 rounded-full text-xs ${item.status === 'فعال' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}`}
|
<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>
|
||||||
>
|
),
|
||||||
{item.status}
|
},
|
||||||
</span>
|
{
|
||||||
),
|
key: "details",
|
||||||
},
|
title: "عملیات",
|
||||||
{
|
render: (item: CustomerListRow) => (
|
||||||
key: 'details',
|
<button
|
||||||
title: 'جزئیات',
|
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>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
className='w-fit px-4'
|
|
||||||
onClick={() => setIsImportModalOpen(true)}
|
|
||||||
>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<DocumentUpload size={18} color='#fff' />
|
|
||||||
<span>{t('customer.import_excel')}</span>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Button>
|
||||||
<div className='mt-8'>
|
<Button type="button" className="w-fit px-4" onClick={() => setIsImportModalOpen(true)}>
|
||||||
<Filters
|
<div className="flex items-center gap-2">
|
||||||
fields={filterFields}
|
<DocumentUpload size={18} color="#fff" />
|
||||||
onChange={handleFiltersChange}
|
<span>{t("customer.import_excel")}</span>
|
||||||
initialValues={filters}
|
|
||||||
searchField="search"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Button>
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
data={tableData}
|
|
||||||
isloading={isLoading}
|
|
||||||
selectable
|
|
||||||
pagination={{
|
|
||||||
currentPage,
|
|
||||||
totalPages,
|
|
||||||
onPageChange: handlePageChange,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ImportCustomersModal
|
|
||||||
open={isImportModalOpen}
|
|
||||||
onClose={() => setIsImportModalOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
export default CustomersList
|
<div className="mt-8">
|
||||||
|
<Filters fields={filterFields} onChange={handleFiltersChange} initialValues={filters} searchField="search" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={tableData}
|
||||||
|
isloading={isLoading}
|
||||||
|
selectable
|
||||||
|
pagination={{
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
onPageChange: handlePageChange,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ImportCustomersModal open={isImportModalOpen} onClose={() => setIsImportModalOpen(false)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomersList;
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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[];
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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'>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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(
|
||||||
setFilters(newFilters);
|
(newFilters: FilterValues) => {
|
||||||
setCurrentPage(DEFAULT_PAGE);
|
setFilters(newFilters);
|
||||||
};
|
setCurrentPage(DEFAULT_PAGE);
|
||||||
|
updateUrlParams(newFilters, DEFAULT_PAGE);
|
||||||
|
},
|
||||||
|
[updateUrlParams],
|
||||||
|
);
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
const handlePageChange = useCallback(
|
||||||
setCurrentPage(page);
|
(page: number) => {
|
||||||
};
|
setCurrentPage(page);
|
||||||
|
updateUrlParams(filters, page);
|
||||||
|
},
|
||||||
|
[filters, updateUrlParams],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filters,
|
filters,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
return location.pathname.includes(name);
|
||||||
|
};
|
||||||
|
|
||||||
const isActive = (name: string) => {
|
return (
|
||||||
return location.pathname.includes(name)
|
<div className="py-12">
|
||||||
}
|
<div className="flex gap-3 items-center border-b border-border pb-10 px-6">
|
||||||
|
<People size={24} color="#000" variant="Bold" />
|
||||||
|
<div className="text-xs">{t("sidebar.customers")}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
return (
|
<div className="flex flex-col mt-10 gap-4">
|
||||||
<div className='py-12'>
|
<SubMenuItem title={t("submenu.customer_list")} isActive={isActive("list")} link={Pages.orders.list} />
|
||||||
<div className='flex gap-3 items-center border-b border-border pb-10 px-6'>
|
<SubMenuItem title={t("submenu.create_customer")} isActive={isActive("create")} link={Pages.orders.add} />
|
||||||
<People
|
</div>
|
||||||
size={24}
|
</div>
|
||||||
color='#000'
|
);
|
||||||
variant='Bold'
|
};
|
||||||
/>
|
|
||||||
<div className='text-xs'>
|
|
||||||
{t('sidebar.customers')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex flex-col mt-10 gap-4'>
|
export default CustomerSubMenu;
|
||||||
<SubMenuItem
|
|
||||||
title={t('submenu.customer_list')}
|
|
||||||
isActive={isActive('list')}
|
|
||||||
link={Pages.orders.list}
|
|
||||||
/>
|
|
||||||
<SubMenuItem
|
|
||||||
title={t('submenu.create_customer')}
|
|
||||||
isActive={isActive('create')}
|
|
||||||
link={Pages.orders.add}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CustomerSubMenu
|
|
||||||
|
|||||||
@@ -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 { t } = useTranslation("global");
|
||||||
|
|
||||||
const location = useLocation()
|
const isActive = (name: string) => {
|
||||||
const { t } = useTranslation('global')
|
return location.pathname.includes(name);
|
||||||
|
};
|
||||||
|
|
||||||
const isActive = (name: string) => {
|
return (
|
||||||
return location.pathname.includes(name)
|
<div className="py-12">
|
||||||
}
|
<div className="flex gap-3 items-center border-b border-border pb-10 px-6">
|
||||||
|
<People size={24} color="#000" variant="Bold" />
|
||||||
|
<div className="text-xs">{t("sidebar.customers")}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
return (
|
<div className="flex flex-col mt-10 gap-4">
|
||||||
<div className='py-12'>
|
<SubMenuItem title={t("submenu.customers_list")} isActive={isActive("list")} link={Pages.customers.list} />
|
||||||
<div className='flex gap-3 items-center border-b border-border pb-10 px-6'>
|
<SubMenuItem title={t("submenu.add_customer")} isActive={isActive("add")} link={Pages.customers.add} />
|
||||||
<People
|
|
||||||
size={24}
|
|
||||||
color='#000'
|
|
||||||
variant='Bold'
|
|
||||||
/>
|
|
||||||
<div className='text-xs'>
|
|
||||||
{t('sidebar.customers')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex flex-col mt-10 gap-4'>
|
<SubMenuItem title={t("submenu.groups")} isActive={isActive("groups")} link={Pages.customers.groups} />
|
||||||
<SubMenuItem
|
<SubMenuItem title={t("submenu.campaigns")} isActive={isActive("campaigns")} link={Pages.customers.campaigns} />
|
||||||
title={t('submenu.customers_list')}
|
</div>
|
||||||
isActive={isActive('list')}
|
</div>
|
||||||
link={Pages.customers.list}
|
);
|
||||||
/>
|
};
|
||||||
<SubMenuItem
|
|
||||||
title={t('submenu.add_customer')}
|
|
||||||
isActive={isActive('add')}
|
|
||||||
link={Pages.customers.add}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CustomersSubMenu
|
|
||||||
|
|
||||||
|
export default CustomersSubMenu;
|
||||||
|
|||||||
Reference in New Issue
Block a user