update customer

This commit is contained in:
hamid zarghami
2026-07-02 10:35:38 +03:30
parent 2d35a428b1
commit 6187e7c26d
6 changed files with 404 additions and 144 deletions
+143 -143
View File
@@ -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;
+215
View File
@@ -0,0 +1,215 @@
import { type FC, useEffect, useState } from 'react'
import { useFormik } from 'formik'
import { TickCircle } from 'iconsax-react'
import { toast } from 'react-toastify'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import Button from '@/components/Button'
import Input from '@/components/Input'
import Select from '@/components/Select'
import DatePicker from '@/components/DatePicker'
import UploadBox from '@/components/UploadBox'
import { useSingleUpload } from '@/pages/uploader/hooks/useUploaderData'
import { useUpdateUser } from './hooks/useUsersData'
import type { CustomerListRow, UpdateUserType } from './types/Types'
import { Pages } from '@/config/Pages'
import type { ErrorType } from '@/helpers/types'
import { extractErrorMessage } from '@/config/func'
type UpdateCustomerFormValues = {
firstName: string
lastName: string
birthDate: string
marriageDate: string
gender: string
}
type CustomerUpdateLocationState = {
customer?: CustomerListRow
}
const UpdateCustomer: FC = () => {
const navigate = useNavigate()
const location = useLocation()
const { id } = useParams<{ id: string }>()
const { mutate: updateUser, isPending: isUpdating } = useUpdateUser()
const { mutate: singleUpload, isPending: isUploading } = useSingleUpload()
const [avatarFile, setAvatarFile] = useState<File[]>([])
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState('')
const customer = (location.state as CustomerUpdateLocationState | null)?.customer
useEffect(() => {
if (!customer?.avatarUrl) {
setAvatarPreviewUrl('')
return
}
setAvatarPreviewUrl(customer.avatarUrl)
}, [customer?.avatarUrl])
useEffect(() => {
if (avatarFile.length > 0) {
const url = URL.createObjectURL(avatarFile[0])
setAvatarPreviewUrl(url)
return () => URL.revokeObjectURL(url)
}
}, [avatarFile])
const formik = useFormik<UpdateCustomerFormValues>({
initialValues: {
firstName: customer?.firstName || '',
lastName: customer?.lastName || '',
birthDate: customer?.birthDateRaw || '',
marriageDate: customer?.marriageDateRaw || '',
gender:
customer?.gender === undefined || customer?.gender === null
? ''
: String(customer.gender),
},
enableReinitialize: true,
onSubmit: (values) => {
if (!id) {
toast.error('شناسه مشتری یافت نشد')
return
}
const submitUpdate = (avatarUrl?: string) => {
const payload: UpdateUserType = {}
if (values.firstName.trim()) payload.firstName = values.firstName.trim()
if (values.lastName.trim()) payload.lastName = values.lastName.trim()
if (values.birthDate) payload.birthDate = values.birthDate
if (values.marriageDate) payload.marriageDate = values.marriageDate
if (values.gender !== '') payload.gender = values.gender === 'true'
if (avatarUrl) payload.avatarUrl = avatarUrl
if (Object.keys(payload).length === 0) {
toast.info('حداقل یک فیلد را برای ویرایش وارد کنید')
return
}
updateUser(
{ userId: id, params: payload },
{
onSuccess: () => {
toast.success('اطلاعات مشتری با موفقیت ویرایش شد')
navigate(Pages.customers.list)
},
onError: (error: ErrorType) => {
toast.error(extractErrorMessage(error, 'خطا در ویرایش مشتری'))
},
},
)
}
if (avatarFile.length > 0) {
singleUpload(avatarFile[0], {
onSuccess: (response) => {
const avatarUrl = response?.data?.url || ''
submitUpdate(avatarUrl)
},
onError: (error: ErrorType) => {
toast.error(extractErrorMessage(error, 'خطا در آپلود تصویر'))
},
})
} else {
submitUpdate(customer?.avatarUrl || undefined)
}
},
})
return (
<div className='mt-5'>
<div className='flex justify-between items-center'>
<h1 className='text-lg font-light'>ویرایش مشتری</h1>
<Button
className='w-fit px-6'
isloading={isUpdating || isUploading}
onClick={() => formik.handleSubmit()}
>
<div className='flex gap-2 items-center'>
<TickCircle size={20} color='#fff' />
<span>ذخیره تغییرات</span>
</div>
</Button>
</div>
<div className='mt-9'>
<div className='w-full bg-white rounded-4xl p-8'>
<div className='text-lg font-light'>اطلاعات مشتری</div>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-6'>
<Input
label='نام'
placeholder=''
name='firstName'
value={formik.values.firstName}
onChange={formik.handleChange}
isNotRequired
/>
<Input
label='نام خانوادگی'
placeholder=''
name='lastName'
value={formik.values.lastName}
onChange={formik.handleChange}
isNotRequired
/>
</div>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-5'>
<DatePicker
label='تاریخ تولد'
placeholder='انتخاب'
isNotRequired
defaulValue={formik.values.birthDate}
onChange={(date) => formik.setFieldValue('birthDate', date)}
/>
<DatePicker
label='تاریخ ازدواج'
placeholder='انتخاب'
isNotRequired
defaulValue={formik.values.marriageDate}
onChange={(date) => formik.setFieldValue('marriageDate', date)}
/>
</div>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-5'>
<Select
label='جنسیت'
placeholder='انتخاب'
name='gender'
value={formik.values.gender}
onChange={formik.handleChange}
isNotRequired
items={[
{ value: 'true', label: 'مرد' },
{ value: 'false', label: 'زن' },
]}
/>
</div>
<div className='mt-5'>
<UploadBox
label='تصویر پروفایل'
isNotRequired
onChange={(files) => setAvatarFile(files)}
isReset={avatarFile.length === 0}
/>
{avatarPreviewUrl && (
<div className='mt-3'>
<img
src={avatarPreviewUrl}
alt='avatar preview'
className='w-20 h-20 rounded-full object-cover'
/>
</div>
)}
</div>
</div>
</div>
</div>
)
}
export default UpdateCustomer
+17 -1
View File
@@ -1,6 +1,10 @@
import * as api from "../service/UsersService"; import * as api from "../service/UsersService";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { CreateUserAddressType, GetUsersParams } from "../types/Types"; import type {
CreateUserAddressType,
GetUsersParams,
UpdateUserType,
} from "../types/Types";
export const useGetUsers = (params?: GetUsersParams) => { export const useGetUsers = (params?: GetUsersParams) => {
return useQuery({ return useQuery({
@@ -26,6 +30,18 @@ export const useCreateUser = () => {
}); });
}; };
export const useUpdateUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ userId, params }: { userId: string; params: UpdateUserType }) =>
api.updateUser(userId, params),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
};
export const useCreateUserAddress = () => { export const useCreateUserAddress = () => {
return useMutation({ return useMutation({
mutationFn: ({ mutationFn: ({
@@ -8,6 +8,8 @@ import type {
GetUsersParams, GetUsersParams,
ImportUsersResponse, ImportUsersResponse,
SearchUserByPhoneResponse, SearchUserByPhoneResponse,
UpdateUserResponse,
UpdateUserType,
} from "@/pages/customers/types/Types"; } from "@/pages/customers/types/Types";
export const getUsers = async (params?: GetUsersParams) => { export const getUsers = async (params?: GetUsersParams) => {
@@ -30,6 +32,14 @@ export const createUser = async (params: CreateUserType) => {
return data; return data;
}; };
export const updateUser = async (userId: string, params: UpdateUserType) => {
const { data } = await axios.patch<UpdateUserResponse>(
`/admin/users/${userId}`,
params,
);
return data;
};
export const createUserAddress = async ( export const createUserAddress = async (
userId: string, userId: string,
params: CreateUserAddressType, params: CreateUserAddressType,
+17
View File
@@ -76,6 +76,12 @@ export type CustomerListRow = RowDataType & {
orderCount: string; orderCount: string;
totalOrderAmount: string; totalOrderAmount: string;
status: string; status: string;
firstName?: string;
lastName?: string;
birthDateRaw?: string;
marriageDateRaw?: string;
gender?: boolean;
avatarUrl?: string | null;
}; };
export type ImportUserRowError = { export type ImportUserRowError = {
@@ -129,3 +135,14 @@ export type CreateUserAddressType = {
}; };
export type CreateUserAddressResponse = IResponse<UserSavedAddress>; export type CreateUserAddressResponse = IResponse<UserSavedAddress>;
export type UpdateUserType = {
firstName?: string;
lastName?: string;
birthDate?: string;
marriageDate?: string;
gender?: boolean;
avatarUrl?: string;
};
export type UpdateUserResponse = IResponse<User>;
+2
View File
@@ -13,6 +13,7 @@ import CategoryFood from '@/pages/food/Category'
import CustomersList from '@/pages/customers/List' import CustomersList from '@/pages/customers/List'
import CustomerDetails from '@/pages/customers/Details' import CustomerDetails from '@/pages/customers/Details'
import CreateCustomer from '@/pages/customers/Create' import CreateCustomer from '@/pages/customers/Create'
import UpdateCustomer from '@/pages/customers/Update'
import OrdersList from '@/pages/orders/List' import OrdersList from '@/pages/orders/List'
import CreateOrder from '@/pages/orders/Create' import CreateOrder from '@/pages/orders/Create'
import OrderDetails from '@/pages/orders/Details' import OrderDetails from '@/pages/orders/Details'
@@ -73,6 +74,7 @@ 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.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 />} />