Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67dfc03c7b | |||
| 7130c304c5 | |||
| f83d0743c6 | |||
| 45494d7ae5 | |||
| 223b1b5e8f |
@@ -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'
|
||||||
@@ -84,9 +84,14 @@ const Input: FC<Props> = (props: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='w-full'>
|
<div className='w-full'>
|
||||||
<label className='text-sm'>
|
{props.label && (
|
||||||
{props.label}
|
<div className='flex items-center gap-1'>
|
||||||
</label>
|
<label className='text-sm'>{props.label}</label>
|
||||||
|
{props.isNotRequired && (
|
||||||
|
<span className='text-xs text-description'>(اختیاری)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className='w-full relative mt-1'>
|
<div className='w-full relative mt-1'>
|
||||||
<input {...props} onChange={(e) => {
|
<input {...props} onChange={(e) => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { clx } from '../helpers/utils'
|
|||||||
type Props = {
|
type Props = {
|
||||||
label?: string,
|
label?: string,
|
||||||
error_text?: string
|
error_text?: string
|
||||||
|
isNotRequired?: boolean
|
||||||
} & InputHTMLAttributes<HTMLTextAreaElement>
|
} & InputHTMLAttributes<HTMLTextAreaElement>
|
||||||
|
|
||||||
const Textarea: FC<Props> = (props: Props) => {
|
const Textarea: FC<Props> = (props: Props) => {
|
||||||
@@ -12,8 +13,11 @@ const Textarea: FC<Props> = (props: Props) => {
|
|||||||
<div className='w-full relative'>
|
<div className='w-full relative'>
|
||||||
{
|
{
|
||||||
props.label &&
|
props.label &&
|
||||||
<div className='text-sm' >
|
<div className='flex items-center gap-1'>
|
||||||
{props.label}
|
<div className='text-sm'>{props.label}</div>
|
||||||
|
{props.isNotRequired && (
|
||||||
|
<span className='text-xs text-description'>(اختیاری)</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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 { GetUsersParams } from "../types/Types";
|
import type { CreateUserAddressType, GetUsersParams } from "../types/Types";
|
||||||
|
|
||||||
export const useGetUsers = (params?: GetUsersParams) => {
|
export const useGetUsers = (params?: GetUsersParams) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -9,6 +9,33 @@ export const useGetUsers = (params?: GetUsersParams) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useSearchUserByPhone = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: api.searchUserByPhone,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCreateUser = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: api.createUser,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCreateUserAddress = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
userId,
|
||||||
|
...params
|
||||||
|
}: CreateUserAddressType & { userId: string }) =>
|
||||||
|
api.createUserAddress(userId, params),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const useImportUsersFromExcel = () => {
|
export const useImportUsersFromExcel = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import axios from "@/config/axios";
|
import axios from "@/config/axios";
|
||||||
import type {
|
import type {
|
||||||
|
CreateUserAddressResponse,
|
||||||
|
CreateUserAddressType,
|
||||||
|
CreateUserResponse,
|
||||||
|
CreateUserType,
|
||||||
GetCustomersResponse,
|
GetCustomersResponse,
|
||||||
GetUsersParams,
|
GetUsersParams,
|
||||||
ImportUsersResponse,
|
ImportUsersResponse,
|
||||||
|
SearchUserByPhoneResponse,
|
||||||
} from "@/pages/customers/types/Types";
|
} from "@/pages/customers/types/Types";
|
||||||
|
|
||||||
export const getUsers = async (params?: GetUsersParams) => {
|
export const getUsers = async (params?: GetUsersParams) => {
|
||||||
@@ -12,6 +17,30 @@ export const getUsers = async (params?: GetUsersParams) => {
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const searchUserByPhone = async (phone: string) => {
|
||||||
|
const { data } = await axios.get<SearchUserByPhoneResponse>(
|
||||||
|
"/admin/users/search-by-phone",
|
||||||
|
{ params: { phone } },
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createUser = async (params: CreateUserType) => {
|
||||||
|
const { data } = await axios.post<CreateUserResponse>("/admin/users", params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createUserAddress = async (
|
||||||
|
userId: string,
|
||||||
|
params: CreateUserAddressType,
|
||||||
|
) => {
|
||||||
|
const { data } = await axios.post<CreateUserAddressResponse>(
|
||||||
|
`/admin/users/${userId}/addresses`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
export const importUsersFromExcel = async (file: File) => {
|
export const importUsersFromExcel = async (file: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
|||||||
@@ -14,6 +14,28 @@ export type User = {
|
|||||||
referrer?: string | null;
|
referrer?: string | null;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
gender?: boolean;
|
gender?: boolean;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserSavedAddress = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
user: string;
|
||||||
|
title: string;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
province: string;
|
||||||
|
postalCode: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
phone: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserWithAddresses = User & {
|
||||||
|
addresses?: UserSavedAddress[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserRestaurant = {
|
export type UserRestaurant = {
|
||||||
@@ -72,3 +94,34 @@ export type ImportUsersResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ImportUsersResponse = IResponse<ImportUsersResult>;
|
export type ImportUsersResponse = IResponse<ImportUsersResult>;
|
||||||
|
|
||||||
|
export type SearchUserByPhoneResponse = IResponse<UserWithAddresses>;
|
||||||
|
|
||||||
|
export type CreateUserType = {
|
||||||
|
phone: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateUserResult = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
user: User;
|
||||||
|
orderCount: number;
|
||||||
|
totalOrderAmount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateUserResponse = IResponse<CreateUserResult>;
|
||||||
|
|
||||||
|
export type CreateUserAddressType = {
|
||||||
|
title: string;
|
||||||
|
address: string;
|
||||||
|
postalCode?: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
phone: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateUserAddressResponse = IResponse<UserSavedAddress>;
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { type FC } from 'react'
|
||||||
|
import { TickCircle } from 'iconsax-react'
|
||||||
|
import Button from '@/components/Button'
|
||||||
|
import { useCreateOrderForm } from './hooks/useCreateOrderForm'
|
||||||
|
import CreateOrderCustomerSection from './components/CreateOrderCustomerSection'
|
||||||
|
import CreateOrderItemsSection from './components/CreateOrderItemsSection'
|
||||||
|
import CreateOrderDeliverySection from './components/CreateOrderDeliverySection'
|
||||||
|
import CreateOrderSidebar from './components/CreateOrderSidebar'
|
||||||
|
|
||||||
|
const CreateOrder: FC = () => {
|
||||||
|
const {
|
||||||
|
formik,
|
||||||
|
foods,
|
||||||
|
deliveryMethods,
|
||||||
|
cashPaymentMethod,
|
||||||
|
selectedDeliveryMethod,
|
||||||
|
selectedPaymentMethod,
|
||||||
|
isCourierDelivery,
|
||||||
|
isCarDelivery,
|
||||||
|
isDineIn,
|
||||||
|
orderSummary,
|
||||||
|
addFoodToOrder,
|
||||||
|
updateItemQuantity,
|
||||||
|
removeItem,
|
||||||
|
isSubmitting,
|
||||||
|
customerAddresses,
|
||||||
|
selectedAddressId,
|
||||||
|
handleUserFound,
|
||||||
|
handleUserNotFound,
|
||||||
|
clearCustomerData,
|
||||||
|
applySavedAddress,
|
||||||
|
selectManualAddress,
|
||||||
|
handleAddressFieldChange,
|
||||||
|
handleLocationSelect,
|
||||||
|
} = useCreateOrderForm()
|
||||||
|
|
||||||
|
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={isSubmitting}
|
||||||
|
onClick={() => formik.handleSubmit()}
|
||||||
|
>
|
||||||
|
<div className='flex gap-2 items-center'>
|
||||||
|
<TickCircle size={20} color='#fff' />
|
||||||
|
<span>ثبت سفارش</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-col xl:flex-row gap-7 mt-9'>
|
||||||
|
<div className='flex-1 min-w-0 flex flex-col gap-8'>
|
||||||
|
<CreateOrderCustomerSection
|
||||||
|
formik={formik}
|
||||||
|
onUserFound={handleUserFound}
|
||||||
|
onUserNotFound={handleUserNotFound}
|
||||||
|
onClearCustomerData={clearCustomerData}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CreateOrderItemsSection
|
||||||
|
formik={formik}
|
||||||
|
foods={foods}
|
||||||
|
onAddFood={addFoodToOrder}
|
||||||
|
onUpdateQuantity={updateItemQuantity}
|
||||||
|
onRemoveItem={removeItem}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CreateOrderDeliverySection
|
||||||
|
formik={formik}
|
||||||
|
deliveryMethods={deliveryMethods}
|
||||||
|
cashPaymentMethod={cashPaymentMethod}
|
||||||
|
isCourierDelivery={isCourierDelivery}
|
||||||
|
isCarDelivery={isCarDelivery}
|
||||||
|
isDineIn={isDineIn}
|
||||||
|
customerAddresses={customerAddresses}
|
||||||
|
selectedAddressId={selectedAddressId}
|
||||||
|
onSelectSavedAddress={applySavedAddress}
|
||||||
|
onSelectManualAddress={selectManualAddress}
|
||||||
|
onAddressFieldChange={handleAddressFieldChange}
|
||||||
|
onLocationSelect={handleLocationSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='w-full xl:w-[330px] flex-shrink-0 xl:sticky xl:top-4 xl:self-start'>
|
||||||
|
<CreateOrderSidebar
|
||||||
|
formik={formik}
|
||||||
|
orderSummary={orderSummary}
|
||||||
|
selectedDeliveryMethod={selectedDeliveryMethod}
|
||||||
|
selectedPaymentMethod={selectedPaymentMethod}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateOrder
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { type FC } from 'react'
|
import { type FC } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Add } from 'iconsax-react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import Table from '@/components/Table'
|
import Table from '@/components/Table'
|
||||||
import Filters from '@/components/Filters'
|
import Filters from '@/components/Filters'
|
||||||
|
import Button from '@/components/Button'
|
||||||
|
import { Pages } from '@/config/Pages'
|
||||||
import { useGetOrders } from './hooks/useOrderData'
|
import { useGetOrders } from './hooks/useOrderData'
|
||||||
import { useOrderFilters } from './hooks/useOrderFilters'
|
import { useOrderFilters } from './hooks/useOrderFilters'
|
||||||
import { getOrderTableColumns } from './components/OrderTableColumns'
|
import { getOrderTableColumns } from './components/OrderTableColumns'
|
||||||
@@ -32,6 +36,14 @@ const OrdersList: FC = () => {
|
|||||||
<div className='mt-5'>
|
<div className='mt-5'>
|
||||||
<div className='flex justify-between items-center'>
|
<div className='flex justify-between items-center'>
|
||||||
<h1 className='text-lg font-light'>لیست سفارشات</h1>
|
<h1 className='text-lg font-light'>لیست سفارشات</h1>
|
||||||
|
<Link to={Pages.orders.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>
|
||||||
|
|
||||||
<div className='mt-8'>
|
<div className='mt-8'>
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { type FC } from 'react'
|
||||||
|
import { Location } from 'iconsax-react'
|
||||||
|
import type { UserSavedAddress } from '@/pages/customers/types/Types'
|
||||||
|
|
||||||
|
interface CreateOrderAddressSelectorProps {
|
||||||
|
addresses: UserSavedAddress[]
|
||||||
|
selectedAddressId: string | null
|
||||||
|
onSelectAddress: (address: UserSavedAddress) => void
|
||||||
|
onSelectManual: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateOrderAddressSelector: FC<CreateOrderAddressSelectorProps> = ({
|
||||||
|
addresses,
|
||||||
|
selectedAddressId,
|
||||||
|
onSelectAddress,
|
||||||
|
onSelectManual,
|
||||||
|
}) => {
|
||||||
|
if (addresses.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isManualSelected = selectedAddressId === null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='mb-6'>
|
||||||
|
<div className='text-sm mb-3'>انتخاب آدرس ذخیرهشده</div>
|
||||||
|
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
|
||||||
|
{addresses.map((address) => {
|
||||||
|
const isSelected = selectedAddressId === address.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={address.id}
|
||||||
|
type='button'
|
||||||
|
onClick={() => onSelectAddress(address)}
|
||||||
|
className={`text-right p-4 rounded-xl border transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:border-primary/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className='flex items-center justify-between gap-2'>
|
||||||
|
<div className='text-sm font-medium'>{address.title || 'آدرس'}</div>
|
||||||
|
{address.isDefault && (
|
||||||
|
<span className='text-[10px] px-2 py-0.5 rounded-full bg-primary/10 text-primary'>
|
||||||
|
پیشفرض
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='text-xs mt-2 line-clamp-2'>{address.address}</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={onSelectManual}
|
||||||
|
className={`text-right p-4 rounded-xl border transition-colors ${
|
||||||
|
isManualSelected
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:border-primary/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className='flex items-center gap-2 text-sm font-medium'>
|
||||||
|
<Location size={16} color='#000' />
|
||||||
|
<span>ورود آدرس جدید</span>
|
||||||
|
</div>
|
||||||
|
<div className='text-xs text-description mt-1'>
|
||||||
|
آدرس را بهصورت دستی وارد کنید
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateOrderAddressSelector
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { type FC, useState } from 'react'
|
||||||
|
import { SearchNormal, TickCircle, UserAdd } from 'iconsax-react'
|
||||||
|
import type { FormikProps } from 'formik'
|
||||||
|
import { toast } from 'react-toastify'
|
||||||
|
import Button from '@/components/Button'
|
||||||
|
import Input from '@/components/Input'
|
||||||
|
import { useSearchUserByPhone } from '@/pages/customers/hooks/useUsersData'
|
||||||
|
import type { UserWithAddresses } from '@/pages/customers/types/Types'
|
||||||
|
import type { CreateOrderFormValues } from '../hooks/useCreateOrderForm'
|
||||||
|
import type { ErrorType } from '@/helpers/types'
|
||||||
|
import { extractErrorMessage } from '@/config/func'
|
||||||
|
import { isAxiosError } from 'axios'
|
||||||
|
|
||||||
|
interface CreateOrderCustomerSectionProps {
|
||||||
|
formik: FormikProps<CreateOrderFormValues>
|
||||||
|
onUserFound: (user: UserWithAddresses) => void
|
||||||
|
onUserNotFound: () => void
|
||||||
|
onClearCustomerData: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomerSearchState = 'idle' | 'found' | 'not_found'
|
||||||
|
|
||||||
|
const CreateOrderCustomerSection: FC<CreateOrderCustomerSectionProps> = ({
|
||||||
|
formik,
|
||||||
|
onUserFound,
|
||||||
|
onUserNotFound,
|
||||||
|
onClearCustomerData,
|
||||||
|
}) => {
|
||||||
|
const { mutate: searchUser, isPending: isSearching } = useSearchUserByPhone()
|
||||||
|
const [searchState, setSearchState] = useState<CustomerSearchState>('idle')
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
const phone = formik.values.userPhone.trim()
|
||||||
|
if (!phone) {
|
||||||
|
toast.error('شماره تلفن را وارد کنید')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
searchUser(phone, {
|
||||||
|
onSuccess: (response) => {
|
||||||
|
const user = response?.data
|
||||||
|
if (!user) {
|
||||||
|
setSearchState('not_found')
|
||||||
|
onUserNotFound()
|
||||||
|
toast.info('مشتری یافت نشد. اطلاعات را وارد کنید.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
formik.setFieldValue('userPhone', user.phone || phone)
|
||||||
|
formik.setFieldValue('firstName', user.firstName || '')
|
||||||
|
formik.setFieldValue('lastName', user.lastName || '')
|
||||||
|
onUserFound(user)
|
||||||
|
setSearchState('found')
|
||||||
|
toast.success('اطلاعات مشتری بارگذاری شد')
|
||||||
|
},
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
if (isAxiosError(error) && error.response?.status === 404) {
|
||||||
|
setSearchState('not_found')
|
||||||
|
onUserNotFound()
|
||||||
|
toast.info('مشتری یافت نشد. اطلاعات را وارد کنید.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchState('idle')
|
||||||
|
toast.error(extractErrorMessage(error, 'خطا در جستجوی مشتری'))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
formik.handleChange(e)
|
||||||
|
if (searchState !== 'idle') {
|
||||||
|
setSearchState('idle')
|
||||||
|
onClearCustomerData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCustomerLocked = searchState !== 'not_found'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full bg-white rounded-4xl p-8'>
|
||||||
|
<div className='text-lg font-light'>اطلاعات مشتری</div>
|
||||||
|
|
||||||
|
<div className='flex flex-col sm:flex-row gap-3 mt-6 sm:items-end'>
|
||||||
|
<div className='flex-1'>
|
||||||
|
<Input
|
||||||
|
label='شماره تلفن'
|
||||||
|
name='userPhone'
|
||||||
|
placeholder='۰۹۱۲۱۲۳۴۵۶۷'
|
||||||
|
value={formik.values.userPhone}
|
||||||
|
onChange={handlePhoneChange}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSearch()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
error_text={
|
||||||
|
formik.touched.userPhone && formik.errors.userPhone
|
||||||
|
? formik.errors.userPhone
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
className='w-full sm:w-fit px-6'
|
||||||
|
isloading={isSearching}
|
||||||
|
onClick={handleSearch}
|
||||||
|
>
|
||||||
|
<div className='flex gap-2 items-center'>
|
||||||
|
<SearchNormal size={18} color='#fff' />
|
||||||
|
<span>جستجو</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searchState === 'found' && (
|
||||||
|
<div className='mt-4 flex items-center gap-2 text-xs text-green-600 bg-green-50 rounded-xl px-4 py-3'>
|
||||||
|
<TickCircle size={16} color='#16A34A' />
|
||||||
|
<span>مشتری یافت شد و اطلاعات تکمیل شد</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchState === 'not_found' && (
|
||||||
|
<div className='mt-4 flex items-center gap-2 text-xs text-description bg-[#F8F9FC] rounded-xl px-4 py-3'>
|
||||||
|
<UserAdd size={16} color='#8C90A3' />
|
||||||
|
<span>مشتری یافت نشد. نام و نام خانوادگی را وارد کنید.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-6'>
|
||||||
|
<Input
|
||||||
|
label='نام'
|
||||||
|
name='firstName'
|
||||||
|
value={formik.values.firstName}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
readOnly={isCustomerLocked}
|
||||||
|
error_text={
|
||||||
|
formik.touched.firstName && formik.errors.firstName
|
||||||
|
? formik.errors.firstName
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label='نام خانوادگی'
|
||||||
|
name='lastName'
|
||||||
|
value={formik.values.lastName}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
readOnly={isCustomerLocked}
|
||||||
|
error_text={
|
||||||
|
formik.touched.lastName && formik.errors.lastName
|
||||||
|
? formik.errors.lastName
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searchState === 'idle' && (
|
||||||
|
<div className='mt-4 text-xs text-description'>
|
||||||
|
ابتدا با شماره تلفن مشتری را جستجو کنید.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateOrderCustomerSection
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import { type FC, useState } from 'react'
|
||||||
|
import type { FormikProps } from 'formik'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import Input from '@/components/Input'
|
||||||
|
import Textarea from '@/components/Textarea'
|
||||||
|
import DefaulModal from '@/components/DefaulModal'
|
||||||
|
import LocationInput from '@/pages/settings/components/LocationInput'
|
||||||
|
import LocationPicker from '@/pages/settings/components/LocationPicker'
|
||||||
|
import { formatPrice } from '@/helpers/func'
|
||||||
|
import type { CreateOrderFormValues } from '../hooks/useCreateOrderForm'
|
||||||
|
import type { RestaurantPaymentMethod, RestaurantShipmentMethod } from '../hooks/useCreateOrderForm'
|
||||||
|
import type { UserSavedAddress } from '@/pages/customers/types/Types'
|
||||||
|
import CreateOrderAddressSelector from './CreateOrderAddressSelector'
|
||||||
|
|
||||||
|
interface CreateOrderDeliverySectionProps {
|
||||||
|
formik: FormikProps<CreateOrderFormValues>
|
||||||
|
deliveryMethods: RestaurantShipmentMethod[]
|
||||||
|
cashPaymentMethod?: RestaurantPaymentMethod
|
||||||
|
isCourierDelivery: boolean
|
||||||
|
isCarDelivery: boolean
|
||||||
|
isDineIn: boolean
|
||||||
|
customerAddresses: UserSavedAddress[]
|
||||||
|
selectedAddressId: string | null
|
||||||
|
onSelectSavedAddress: (address: UserSavedAddress) => void
|
||||||
|
onSelectManualAddress: () => void
|
||||||
|
onAddressFieldChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void
|
||||||
|
onLocationSelect: (lat: number, lng: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateOrderDeliverySection: FC<CreateOrderDeliverySectionProps> = ({
|
||||||
|
formik,
|
||||||
|
deliveryMethods,
|
||||||
|
cashPaymentMethod,
|
||||||
|
isCourierDelivery,
|
||||||
|
isCarDelivery,
|
||||||
|
isDineIn,
|
||||||
|
customerAddresses,
|
||||||
|
selectedAddressId,
|
||||||
|
onSelectSavedAddress,
|
||||||
|
onSelectManualAddress,
|
||||||
|
onAddressFieldChange,
|
||||||
|
onLocationSelect,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('global')
|
||||||
|
const [isLocationModalOpen, setIsLocationModalOpen] = useState(false)
|
||||||
|
|
||||||
|
const getDeliveryLabel = (method: string) => {
|
||||||
|
return t(`shipment.${method}`, {
|
||||||
|
defaultValue: method,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAddressReadOnly =
|
||||||
|
selectedAddressId !== null && customerAddresses.length > 0
|
||||||
|
|
||||||
|
const handleMapLocationSelect = (lat: number, lng: number) => {
|
||||||
|
onLocationSelect(lat, lng)
|
||||||
|
setIsLocationModalOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const latitude = formik.values.userAddress.latitude
|
||||||
|
const longitude = formik.values.userAddress.longitude
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col gap-8'>
|
||||||
|
<div className='w-full bg-white rounded-4xl p-8'>
|
||||||
|
<div className='text-lg font-light'>تحویل و پرداخت</div>
|
||||||
|
|
||||||
|
<div className='mt-6'>
|
||||||
|
<div className='text-sm mb-3'>روش تحویل</div>
|
||||||
|
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
|
||||||
|
{deliveryMethods.map((method) => {
|
||||||
|
const isSelected = formik.values.deliveryMethodId === method.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={method.id}
|
||||||
|
type='button'
|
||||||
|
onClick={() => formik.setFieldValue('deliveryMethodId', method.id)}
|
||||||
|
className={`text-right p-4 rounded-xl border transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:border-primary/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className='text-sm font-medium'>
|
||||||
|
{getDeliveryLabel(method.method)}
|
||||||
|
</div>
|
||||||
|
{method.description && (
|
||||||
|
<div className='text-xs text-description mt-1 line-clamp-2'>
|
||||||
|
{method.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{method.deliveryFee > 0 && (
|
||||||
|
<div className='text-xs mt-2 text-primary'>
|
||||||
|
{formatPrice(method.deliveryFee)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{formik.touched.deliveryMethodId && formik.errors.deliveryMethodId && (
|
||||||
|
<div className='text-xs text-red-500 mt-2'>
|
||||||
|
{formik.errors.deliveryMethodId}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-8'>
|
||||||
|
<div className='text-sm mb-3'>روش پرداخت</div>
|
||||||
|
<div className='p-4 rounded-xl border border-primary bg-primary/5'>
|
||||||
|
<div className='text-sm font-medium'>پرداخت نقدی</div>
|
||||||
|
{cashPaymentMethod?.description && (
|
||||||
|
<div className='text-xs text-description mt-1'>
|
||||||
|
{cashPaymentMethod.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!cashPaymentMethod && (
|
||||||
|
<div className='text-xs text-red-500 mt-2'>
|
||||||
|
روش پرداخت نقدی فعال یافت نشد
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDineIn && (
|
||||||
|
<div className='mt-8'>
|
||||||
|
<Input
|
||||||
|
label='شماره میز'
|
||||||
|
name='tableNumber'
|
||||||
|
placeholder='مثال: ۱۲'
|
||||||
|
value={formik.values.tableNumber}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCourierDelivery && (
|
||||||
|
<div className='w-full bg-white rounded-4xl p-8'>
|
||||||
|
<div className='text-lg font-light'>آدرس تحویل</div>
|
||||||
|
|
||||||
|
<CreateOrderAddressSelector
|
||||||
|
addresses={customerAddresses}
|
||||||
|
selectedAddressId={selectedAddressId}
|
||||||
|
onSelectAddress={onSelectSavedAddress}
|
||||||
|
onSelectManual={onSelectManualAddress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-6'>
|
||||||
|
<Input
|
||||||
|
label='عنوان آدرس'
|
||||||
|
name='userAddress.title'
|
||||||
|
placeholder='مثال: منزل'
|
||||||
|
value={formik.values.userAddress.title}
|
||||||
|
onChange={onAddressFieldChange}
|
||||||
|
readOnly={isAddressReadOnly}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label='شماره تماس'
|
||||||
|
name='userAddress.phone'
|
||||||
|
value={formik.values.userAddress.phone}
|
||||||
|
onChange={onAddressFieldChange}
|
||||||
|
readOnly={isAddressReadOnly}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label='کد پستی'
|
||||||
|
name='userAddress.postalCode'
|
||||||
|
value={formik.values.userAddress.postalCode}
|
||||||
|
onChange={onAddressFieldChange}
|
||||||
|
readOnly={isAddressReadOnly}
|
||||||
|
isNotRequired
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={isAddressReadOnly ? 'pointer-events-none opacity-60' : ''}>
|
||||||
|
<LocationInput
|
||||||
|
label='موقعیت روی نقشه'
|
||||||
|
latitude={latitude || undefined}
|
||||||
|
longitude={longitude || undefined}
|
||||||
|
onClick={() => setIsLocationModalOpen(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-5'>
|
||||||
|
<Textarea
|
||||||
|
label='آدرس کامل'
|
||||||
|
name='userAddress.address'
|
||||||
|
value={formik.values.userAddress.address}
|
||||||
|
onChange={onAddressFieldChange}
|
||||||
|
readOnly={isAddressReadOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DefaulModal
|
||||||
|
open={isLocationModalOpen}
|
||||||
|
close={() => setIsLocationModalOpen(false)}
|
||||||
|
isHeader={true}
|
||||||
|
title_header='انتخاب موقعیت روی نقشه'
|
||||||
|
width={800}
|
||||||
|
>
|
||||||
|
<div className='p-4'>
|
||||||
|
<LocationPicker
|
||||||
|
initialPosition={
|
||||||
|
latitude && longitude
|
||||||
|
? [latitude, longitude]
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onLocationSelect={handleMapLocationSelect}
|
||||||
|
/>
|
||||||
|
<div className='mt-4 text-xs text-gray-600 text-center'>
|
||||||
|
روی نقشه کلیک کنید تا موقعیت را انتخاب کنید
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DefaulModal>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCarDelivery && (
|
||||||
|
<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='مدل خودرو'
|
||||||
|
name='carAddress.carModel'
|
||||||
|
value={formik.values.carAddress.carModel}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label='رنگ خودرو'
|
||||||
|
name='carAddress.carColor'
|
||||||
|
value={formik.values.carAddress.carColor}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label='پلاک'
|
||||||
|
name='carAddress.plateNumber'
|
||||||
|
value={formik.values.carAddress.plateNumber}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label='شماره تماس'
|
||||||
|
name='carAddress.phone'
|
||||||
|
value={formik.values.carAddress.phone}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='w-full bg-white rounded-4xl p-8'>
|
||||||
|
<div className='text-lg font-light'>توضیحات سفارش</div>
|
||||||
|
<div className='mt-6'>
|
||||||
|
<Textarea
|
||||||
|
label='توضیحات'
|
||||||
|
name='description'
|
||||||
|
placeholder='توضیحات اضافی برای سفارش...'
|
||||||
|
value={formik.values.description}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
isNotRequired
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateOrderDeliverySection
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { type FC, useMemo, useState } from 'react'
|
||||||
|
import type { FormikProps } from 'formik'
|
||||||
|
import { Add, Minus, Trash } from 'iconsax-react'
|
||||||
|
import Input from '@/components/Input'
|
||||||
|
import type { Food } from '@/pages/food/types/Types'
|
||||||
|
import { formatFaNumber, formatPrice } from '@/helpers/func'
|
||||||
|
import { getFoodUnitPrice } from '../hooks/useCreateOrderForm'
|
||||||
|
import type { CreateOrderFormValues } from '../hooks/useCreateOrderForm'
|
||||||
|
|
||||||
|
interface CreateOrderItemsSectionProps {
|
||||||
|
formik: FormikProps<CreateOrderFormValues>
|
||||||
|
foods: Food[]
|
||||||
|
onAddFood: (foodId: string) => void
|
||||||
|
onUpdateQuantity: (foodId: string, quantity: number) => void
|
||||||
|
onRemoveItem: (foodId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateOrderItemsSection: FC<CreateOrderItemsSectionProps> = ({
|
||||||
|
formik,
|
||||||
|
foods,
|
||||||
|
onAddFood,
|
||||||
|
onUpdateQuantity,
|
||||||
|
onRemoveItem,
|
||||||
|
}) => {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
|
const foodsMap = useMemo(
|
||||||
|
() => new Map(foods.map((food) => [food.id, food])),
|
||||||
|
[foods]
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredFoods = useMemo(() => {
|
||||||
|
const query = search.trim().toLowerCase()
|
||||||
|
if (!query) return foods
|
||||||
|
return foods.filter((food) => food.title.toLowerCase().includes(query))
|
||||||
|
}, [foods, search])
|
||||||
|
|
||||||
|
const selectedItems = formik.values.items
|
||||||
|
.map((item) => {
|
||||||
|
const food = foodsMap.get(item.foodId)
|
||||||
|
if (!food) return null
|
||||||
|
return { ...item, food }
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full bg-white rounded-4xl p-8'>
|
||||||
|
<div className='text-lg font-light'>آیتمهای سفارش</div>
|
||||||
|
|
||||||
|
<div className='mt-6 border border-border rounded-xl p-4'>
|
||||||
|
<Input
|
||||||
|
variant='search'
|
||||||
|
className='bg-[#EEF0F7]'
|
||||||
|
placeholder='جستجوی غذا...'
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className='mt-3 space-y-2 max-h-56 overflow-y-auto'>
|
||||||
|
{filteredFoods.length === 0 ? (
|
||||||
|
<div className='text-center py-6 text-xs text-description'>
|
||||||
|
غذایی یافت نشد
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredFoods.map((food) => {
|
||||||
|
const inCart = formik.values.items.some(
|
||||||
|
(item) => item.foodId === food.id
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={food.id}
|
||||||
|
className='flex items-center gap-3 p-2 rounded-xl hover:bg-[#F8F9FC] transition-colors'
|
||||||
|
>
|
||||||
|
{food.images?.[0] ? (
|
||||||
|
<img
|
||||||
|
src={food.images[0]}
|
||||||
|
alt={food.title}
|
||||||
|
className='w-11 h-11 rounded-lg object-cover flex-shrink-0'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='w-11 h-11 rounded-lg bg-gray-100 flex-shrink-0' />
|
||||||
|
)}
|
||||||
|
<div className='flex-1 min-w-0'>
|
||||||
|
<div className='text-sm truncate'>{food.title}</div>
|
||||||
|
<div className='text-xs text-description mt-0.5'>
|
||||||
|
{formatPrice(getFoodUnitPrice(food))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => onAddFood(food.id)}
|
||||||
|
className={`flex items-center gap-1 px-3 h-8 rounded-lg text-xs transition-colors ${
|
||||||
|
inCart
|
||||||
|
? 'bg-primary/10 text-primary'
|
||||||
|
: 'bg-primary text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Add size={14} color={inCart ? '#000' : '#fff'} />
|
||||||
|
{inCart ? 'افزودن' : 'انتخاب'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-6'>
|
||||||
|
<div className='text-sm mb-3'>سبد سفارش</div>
|
||||||
|
|
||||||
|
{selectedItems.length === 0 ? (
|
||||||
|
<div className='border border-dashed border-border rounded-xl py-10 text-center text-xs text-description'>
|
||||||
|
هنوز آیتمی انتخاب نشده است
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='space-y-3'>
|
||||||
|
{selectedItems.map((item) => {
|
||||||
|
if (!item) return null
|
||||||
|
const unitPrice = getFoodUnitPrice(item.food)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.foodId}
|
||||||
|
className='flex items-center gap-3 p-3 border border-border rounded-xl'
|
||||||
|
>
|
||||||
|
{item.food.images?.[0] ? (
|
||||||
|
<img
|
||||||
|
src={item.food.images[0]}
|
||||||
|
alt={item.food.title}
|
||||||
|
className='w-12 h-12 rounded-lg object-cover flex-shrink-0'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='w-12 h-12 rounded-lg bg-gray-100 flex-shrink-0' />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='flex-1 min-w-0'>
|
||||||
|
<div className='text-sm truncate'>{item.food.title}</div>
|
||||||
|
<div className='text-xs text-description mt-1'>
|
||||||
|
{formatPrice(unitPrice)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() =>
|
||||||
|
onUpdateQuantity(item.foodId, item.quantity - 1)
|
||||||
|
}
|
||||||
|
className='w-8 h-8 rounded-lg border border-border flex items-center justify-center'
|
||||||
|
>
|
||||||
|
<Minus size={14} color='#000' />
|
||||||
|
</button>
|
||||||
|
<span className='w-6 text-center text-sm'>
|
||||||
|
{formatFaNumber(item.quantity)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() =>
|
||||||
|
onUpdateQuantity(item.foodId, item.quantity + 1)
|
||||||
|
}
|
||||||
|
className='w-8 h-8 rounded-lg border border-border flex items-center justify-center'
|
||||||
|
>
|
||||||
|
<Add size={14} color='#000' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='text-sm font-medium min-w-[90px] text-left'>
|
||||||
|
{formatPrice(unitPrice * item.quantity)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => onRemoveItem(item.foodId)}
|
||||||
|
className='text-red-500 p-1'
|
||||||
|
>
|
||||||
|
<Trash size={18} color='#EF4444' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formik.touched.items && formik.errors.items && typeof formik.errors.items === 'string' && (
|
||||||
|
<div className='text-xs text-red-500 mt-2'>{formik.errors.items}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateOrderItemsSection
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { formatFaNumber, formatPrice } from "@/helpers/func";
|
||||||
|
import { PaymentMethodEnum } from "@/pages/paymentMethods/enum/Enum";
|
||||||
|
import { DeliveryMethodEnum } from "@/pages/shipmentMethod/enum/Enum";
|
||||||
|
import type { FormikProps } from "formik";
|
||||||
|
import { ShoppingCart, User } from "iconsax-react";
|
||||||
|
import { type FC } from "react";
|
||||||
|
import type { CreateOrderFormValues, RestaurantPaymentMethod, RestaurantShipmentMethod } from "../hooks/useCreateOrderForm";
|
||||||
|
|
||||||
|
interface OrderSummaryData {
|
||||||
|
lineItems: Array<{
|
||||||
|
foodId: string;
|
||||||
|
title: string;
|
||||||
|
image: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPrice: number;
|
||||||
|
totalPrice: number;
|
||||||
|
} | null>;
|
||||||
|
subTotal: number;
|
||||||
|
deliveryFee: number;
|
||||||
|
total: number;
|
||||||
|
totalItems: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateOrderSidebarProps {
|
||||||
|
formik: FormikProps<CreateOrderFormValues>;
|
||||||
|
orderSummary: OrderSummaryData;
|
||||||
|
selectedDeliveryMethod?: RestaurantShipmentMethod;
|
||||||
|
selectedPaymentMethod?: RestaurantPaymentMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDeliveryMethodText = (method?: string): string => {
|
||||||
|
const methodMap: Record<string, string> = {
|
||||||
|
[DeliveryMethodEnum.DineIn]: "سرو در رستوران",
|
||||||
|
[DeliveryMethodEnum.CustomerPickup]: "دریافت از محل",
|
||||||
|
[DeliveryMethodEnum.DeliveryCar]: "تحویل به خودرو",
|
||||||
|
[DeliveryMethodEnum.DeliveryCourier]: "بیرونبر",
|
||||||
|
};
|
||||||
|
return method ? methodMap[method] || method : "-";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPaymentMethodText = (method?: string): string => {
|
||||||
|
const methodMap: Record<string, string> = {
|
||||||
|
[PaymentMethodEnum.Cash]: "پرداخت نقدی",
|
||||||
|
[PaymentMethodEnum.Online]: "پرداخت آنلاین",
|
||||||
|
[PaymentMethodEnum.CreditCard]: "کارت به کارت",
|
||||||
|
[PaymentMethodEnum.Wallet]: "کیف پول",
|
||||||
|
};
|
||||||
|
return method ? methodMap[method] || method : "-";
|
||||||
|
};
|
||||||
|
|
||||||
|
const CreateOrderSidebar: FC<CreateOrderSidebarProps> = ({ formik, orderSummary, selectedDeliveryMethod, selectedPaymentMethod }) => {
|
||||||
|
const customerName = [formik.values.firstName, formik.values.lastName].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white rounded-4xl p-8">
|
||||||
|
<div className="flex items-center gap-2 text-lg font-light">
|
||||||
|
<User size={20} color="#000" />
|
||||||
|
خلاصه سفارش
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-3 text-[13px] font-light">
|
||||||
|
<div className="flex justify-between items-center border-b border-border border-dashed pb-3">
|
||||||
|
<span className="text-description">مشتری</span>
|
||||||
|
<span>{customerName || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center border-b border-border border-dashed pb-3">
|
||||||
|
<span className="text-description">شماره تماس</span>
|
||||||
|
<span>{formik.values.userPhone || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center border-b border-border border-dashed pb-3">
|
||||||
|
<span className="text-description">روش تحویل</span>
|
||||||
|
<span>{getDeliveryMethodText(selectedDeliveryMethod?.method)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center border-b border-border border-dashed pb-3">
|
||||||
|
<span className="text-description">روش پرداخت</span>
|
||||||
|
<span>{getPaymentMethodText(selectedPaymentMethod?.method)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-4xl p-8">
|
||||||
|
<div className="flex items-center gap-2 text-lg font-light">
|
||||||
|
<ShoppingCart size={20} color="#000" />
|
||||||
|
آیتمها
|
||||||
|
{orderSummary.totalItems > 0 && <span className="text-xs text-description">({formatFaNumber(orderSummary.totalItems)})</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{orderSummary.lineItems.length === 0 ? (
|
||||||
|
<div className="mt-6 text-center text-xs text-description py-6">سبد خالی است</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
{orderSummary.lineItems.map((item) => {
|
||||||
|
if (!item) return null;
|
||||||
|
return (
|
||||||
|
<div key={item.foodId} className="flex justify-between items-start gap-3 text-[13px] border-b border-border border-dashed pb-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div>{item.title}</div>
|
||||||
|
<div className="text-description mt-1">
|
||||||
|
{formatFaNumber(item.quantity)} × {formatPrice(item.unitPrice)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>{formatPrice(item.totalPrice)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-3 text-[13px] font-light">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-description">جمع آیتمها</span>
|
||||||
|
<span>{formatPrice(orderSummary.subTotal)}</span>
|
||||||
|
</div>
|
||||||
|
{orderSummary.deliveryFee > 0 && (
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-description">هزینه ارسال</span>
|
||||||
|
<span>{formatPrice(orderSummary.deliveryFee)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between items-center font-bold text-sm pt-3 border-t-2 border-gray-200">
|
||||||
|
<span>جمع کل</span>
|
||||||
|
<span>{formatPrice(orderSummary.total)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateOrderSidebar;
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useFormik } from 'formik'
|
||||||
|
import * as Yup from 'yup'
|
||||||
|
import { toast } from 'react-toastify'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useCreateOrder } from './useOrderData'
|
||||||
|
import { useCreateUser, useCreateUserAddress } from '@/pages/customers/hooks/useUsersData'
|
||||||
|
import { useGetFoods } from '@/pages/food/hooks/useFoodData'
|
||||||
|
import { useGetRestaurantShipmentMethods } from '@/pages/shipmentMethod/hooks/useShipmentMethodData'
|
||||||
|
import { useGetRestaurantPaymentMethods } from '@/pages/paymentMethods/hooks/usePaymentMethodData'
|
||||||
|
import { DeliveryMethodEnum } from '@/pages/shipmentMethod/enum/Enum'
|
||||||
|
import { PaymentMethodEnum } from '@/pages/paymentMethods/enum/Enum'
|
||||||
|
import type {
|
||||||
|
CreateOrderType,
|
||||||
|
CreateOrderItem,
|
||||||
|
CreateOrderUserAddress,
|
||||||
|
CreateOrderCarAddress,
|
||||||
|
} from '../types/Types'
|
||||||
|
import type { Food } from '@/pages/food/types/Types'
|
||||||
|
import type { RestaurantShipmentMethod } from '@/pages/shipmentMethod/types/Types'
|
||||||
|
import type { RestaurantPaymentMethod } from '@/pages/paymentMethods/types/Types'
|
||||||
|
import type { UserSavedAddress, UserWithAddresses } from '@/pages/customers/types/Types'
|
||||||
|
import { mapSavedAddressToOrderAddress } from '../utils/mapSavedAddress'
|
||||||
|
import { mapFormAddressToCreateUserAddress } from '../utils/mapFormAddressToCreateUserAddress'
|
||||||
|
import { Pages } from '@/config/Pages'
|
||||||
|
import type { ErrorType } from '@/helpers/types'
|
||||||
|
import { extractErrorMessage } from '@/config/func'
|
||||||
|
|
||||||
|
export interface CreateOrderFormValues {
|
||||||
|
userPhone: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
items: CreateOrderItem[]
|
||||||
|
deliveryMethodId: string
|
||||||
|
paymentMethodId: string
|
||||||
|
description: string
|
||||||
|
tableNumber: string
|
||||||
|
userAddress: CreateOrderUserAddress
|
||||||
|
carAddress: CreateOrderCarAddress
|
||||||
|
paymentDesc: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyUserAddress = (): CreateOrderUserAddress => ({
|
||||||
|
title: '',
|
||||||
|
phone: '',
|
||||||
|
address: '',
|
||||||
|
postalCode: '',
|
||||||
|
latitude: 0,
|
||||||
|
longitude: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emptyCarAddress = (): CreateOrderCarAddress => ({
|
||||||
|
carModel: '',
|
||||||
|
carColor: '',
|
||||||
|
plateNumber: '',
|
||||||
|
phone: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getFoodUnitPrice = (food: Food) => {
|
||||||
|
const discount = food.discount || 0
|
||||||
|
return Math.max(food.price - discount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCreateOrderForm = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { mutate: createOrder, isPending: isCreating } = useCreateOrder()
|
||||||
|
const { mutateAsync: createUser, isPending: isCreatingUser } = useCreateUser()
|
||||||
|
const { mutateAsync: createUserAddress, isPending: isCreatingAddress } =
|
||||||
|
useCreateUserAddress()
|
||||||
|
const [customerAddresses, setCustomerAddresses] = useState<UserSavedAddress[]>([])
|
||||||
|
const [customerUserId, setCustomerUserId] = useState<string | null>(null)
|
||||||
|
const [selectedAddressId, setSelectedAddressId] = useState<string | null>(null)
|
||||||
|
const [isNewCustomer, setIsNewCustomer] = useState(false)
|
||||||
|
|
||||||
|
const { data: foodsData } = useGetFoods({ limit: 500 })
|
||||||
|
const { data: deliveryMethodsData } = useGetRestaurantShipmentMethods({ limit: 100 })
|
||||||
|
const { data: paymentMethodsData } = useGetRestaurantPaymentMethods({ limit: 100 })
|
||||||
|
|
||||||
|
const foods = useMemo(() => foodsData?.data || [], [foodsData?.data])
|
||||||
|
const deliveryMethods = useMemo(
|
||||||
|
() => (deliveryMethodsData?.data || []).filter((method) => method.enabled),
|
||||||
|
[deliveryMethodsData?.data]
|
||||||
|
)
|
||||||
|
const paymentMethods = useMemo(
|
||||||
|
() =>
|
||||||
|
(paymentMethodsData?.data || []).filter(
|
||||||
|
(method) => method.enabled && method.method === PaymentMethodEnum.Cash
|
||||||
|
),
|
||||||
|
[paymentMethodsData?.data]
|
||||||
|
)
|
||||||
|
|
||||||
|
const formik = useFormik<CreateOrderFormValues>({
|
||||||
|
initialValues: {
|
||||||
|
userPhone: '',
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
items: [],
|
||||||
|
deliveryMethodId: '',
|
||||||
|
paymentMethodId: '',
|
||||||
|
description: '',
|
||||||
|
tableNumber: '',
|
||||||
|
userAddress: emptyUserAddress(),
|
||||||
|
carAddress: emptyCarAddress(),
|
||||||
|
paymentDesc: '',
|
||||||
|
},
|
||||||
|
validationSchema: Yup.object().shape({
|
||||||
|
userPhone: Yup.string().required('شماره تلفن الزامی است'),
|
||||||
|
firstName: Yup.string().required('نام الزامی است'),
|
||||||
|
lastName: Yup.string().required('نام خانوادگی الزامی است'),
|
||||||
|
deliveryMethodId: Yup.string().required('روش تحویل الزامی است'),
|
||||||
|
paymentMethodId: Yup.string().required('روش پرداخت الزامی است'),
|
||||||
|
items: Yup.array()
|
||||||
|
.of(
|
||||||
|
Yup.object().shape({
|
||||||
|
foodId: Yup.string().required(),
|
||||||
|
quantity: Yup.number().min(1).required(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.min(1, 'حداقل یک آیتم الزامی است'),
|
||||||
|
}),
|
||||||
|
onSubmit: async (values) => {
|
||||||
|
const selectedDelivery = deliveryMethods.find(
|
||||||
|
(method) => method.id === values.deliveryMethodId
|
||||||
|
)
|
||||||
|
const selectedPayment = paymentMethods.find(
|
||||||
|
(method) => method.id === values.paymentMethodId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (selectedDelivery?.method === DeliveryMethodEnum.DeliveryCourier) {
|
||||||
|
const address = values.userAddress
|
||||||
|
if (!address.title || !address.phone || !address.address) {
|
||||||
|
toast.error('لطفاً آدرس تحویل را کامل کنید')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!address.latitude || !address.longitude) {
|
||||||
|
toast.error('لطفاً موقعیت روی نقشه را انتخاب کنید')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedDelivery?.method === DeliveryMethodEnum.DeliveryCar) {
|
||||||
|
const car = values.carAddress
|
||||||
|
if (!car.carModel || !car.carColor || !car.plateNumber || !car.phone) {
|
||||||
|
toast.error('لطفاً اطلاعات خودرو را کامل کنید')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectedDelivery?.method === DeliveryMethodEnum.DineIn &&
|
||||||
|
!values.tableNumber.trim()
|
||||||
|
) {
|
||||||
|
toast.error('لطفاً شماره میز را وارد کنید')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitOrder = (userId: string, addressId?: string) => {
|
||||||
|
const payload: CreateOrderType = {
|
||||||
|
userId,
|
||||||
|
items: values.items,
|
||||||
|
deliveryMethodId: values.deliveryMethodId,
|
||||||
|
paymentMethodId: values.paymentMethodId,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.description.trim()) {
|
||||||
|
payload.description = values.description.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectedDelivery?.method === DeliveryMethodEnum.DineIn &&
|
||||||
|
values.tableNumber.trim()
|
||||||
|
) {
|
||||||
|
payload.tableNumber = values.tableNumber.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedDelivery?.method === DeliveryMethodEnum.DeliveryCourier && addressId) {
|
||||||
|
payload.addressId = addressId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedDelivery?.method === DeliveryMethodEnum.DeliveryCar) {
|
||||||
|
payload.carAddress = values.carAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
createOrder(payload, {
|
||||||
|
onSuccess: (response) => {
|
||||||
|
toast.success('سفارش با موفقیت ثبت شد')
|
||||||
|
const orderId = response?.data?.id
|
||||||
|
if (orderId) {
|
||||||
|
navigate(Pages.orders.detail + orderId)
|
||||||
|
} else {
|
||||||
|
navigate(Pages.orders.list)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
toast.error(extractErrorMessage(error))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const registerNewCustomer = async (): Promise<string | null> => {
|
||||||
|
if (!isNewCustomer) {
|
||||||
|
return customerUserId
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await createUser({
|
||||||
|
phone: values.userPhone.trim(),
|
||||||
|
firstName: values.firstName.trim(),
|
||||||
|
lastName: values.lastName.trim(),
|
||||||
|
})
|
||||||
|
const userId = response.data.user.id
|
||||||
|
setCustomerUserId(userId)
|
||||||
|
setIsNewCustomer(false)
|
||||||
|
return userId
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveAddressId = async (userId: string): Promise<string | undefined> => {
|
||||||
|
if (selectedDelivery?.method !== DeliveryMethodEnum.DeliveryCourier) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedAddressId) {
|
||||||
|
return selectedAddressId
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await createUserAddress({
|
||||||
|
userId,
|
||||||
|
...mapFormAddressToCreateUserAddress(
|
||||||
|
values.userAddress,
|
||||||
|
customerAddresses.length === 0
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return response.data.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitFlow = async () => {
|
||||||
|
try {
|
||||||
|
const userId = await registerNewCustomer()
|
||||||
|
if (!userId) {
|
||||||
|
toast.error('کاربر انتخاب نشده است')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let addressId: string | undefined
|
||||||
|
try {
|
||||||
|
addressId = await resolveAddressId(userId)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(extractErrorMessage(error as ErrorType, 'خطا در ثبت آدرس'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectedDelivery?.method === DeliveryMethodEnum.DeliveryCourier &&
|
||||||
|
!addressId
|
||||||
|
) {
|
||||||
|
toast.error('لطفاً آدرس تحویل را انتخاب کنید')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitOrder(userId, addressId)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(extractErrorMessage(error as ErrorType, 'خطا در ثبت مشتری'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedPayment) {
|
||||||
|
toast.error('روش پرداخت نقدی یافت نشد')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await submitFlow()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const applySavedAddress = useCallback(
|
||||||
|
(address: UserSavedAddress) => {
|
||||||
|
formik.setFieldValue('userAddress', mapSavedAddressToOrderAddress(address))
|
||||||
|
setSelectedAddressId(address.id)
|
||||||
|
},
|
||||||
|
[formik]
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectManualAddress = useCallback(() => {
|
||||||
|
setSelectedAddressId(null)
|
||||||
|
formik.setFieldValue('userAddress', {
|
||||||
|
...emptyUserAddress(),
|
||||||
|
phone: formik.values.userPhone,
|
||||||
|
})
|
||||||
|
}, [formik])
|
||||||
|
|
||||||
|
const handleUserFound = useCallback(
|
||||||
|
(user: UserWithAddresses) => {
|
||||||
|
setIsNewCustomer(false)
|
||||||
|
setCustomerUserId(user.id)
|
||||||
|
const addresses = user.addresses ?? []
|
||||||
|
setCustomerAddresses(addresses)
|
||||||
|
|
||||||
|
if (addresses.length === 0) {
|
||||||
|
setSelectedAddressId(null)
|
||||||
|
formik.setFieldValue('userAddress', {
|
||||||
|
...emptyUserAddress(),
|
||||||
|
phone: user.phone || formik.values.userPhone,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultAddress =
|
||||||
|
addresses.find((address) => address.isDefault) ?? addresses[0]
|
||||||
|
applySavedAddress(defaultAddress)
|
||||||
|
},
|
||||||
|
[applySavedAddress, formik]
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearCustomerData = useCallback(() => {
|
||||||
|
setIsNewCustomer(false)
|
||||||
|
setCustomerUserId(null)
|
||||||
|
setCustomerAddresses([])
|
||||||
|
setSelectedAddressId(null)
|
||||||
|
formik.setFieldValue('userAddress', emptyUserAddress())
|
||||||
|
}, [formik])
|
||||||
|
|
||||||
|
const handleUserNotFound = useCallback(() => {
|
||||||
|
setCustomerUserId(null)
|
||||||
|
setCustomerAddresses([])
|
||||||
|
setSelectedAddressId(null)
|
||||||
|
formik.setFieldValue('userAddress', emptyUserAddress())
|
||||||
|
formik.setFieldValue('firstName', '')
|
||||||
|
formik.setFieldValue('lastName', '')
|
||||||
|
setIsNewCustomer(true)
|
||||||
|
}, [formik])
|
||||||
|
|
||||||
|
const cashPaymentMethod = paymentMethods[0]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cashPaymentMethod && formik.values.paymentMethodId !== cashPaymentMethod.id) {
|
||||||
|
formik.setFieldValue('paymentMethodId', cashPaymentMethod.id)
|
||||||
|
}
|
||||||
|
}, [cashPaymentMethod, formik.values.paymentMethodId, formik.setFieldValue])
|
||||||
|
|
||||||
|
const selectedDeliveryMethod = deliveryMethods.find(
|
||||||
|
(method) => method.id === formik.values.deliveryMethodId
|
||||||
|
)
|
||||||
|
const selectedPaymentMethod = paymentMethods.find(
|
||||||
|
(method) => method.id === formik.values.paymentMethodId
|
||||||
|
)
|
||||||
|
|
||||||
|
const isCourierDelivery =
|
||||||
|
selectedDeliveryMethod?.method === DeliveryMethodEnum.DeliveryCourier
|
||||||
|
const isCarDelivery =
|
||||||
|
selectedDeliveryMethod?.method === DeliveryMethodEnum.DeliveryCar
|
||||||
|
const isDineIn = selectedDeliveryMethod?.method === DeliveryMethodEnum.DineIn
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCourierDelivery || customerAddresses.length === 0 || selectedAddressId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultAddress =
|
||||||
|
customerAddresses.find((address) => address.isDefault) ?? customerAddresses[0]
|
||||||
|
applySavedAddress(defaultAddress)
|
||||||
|
}, [isCourierDelivery, customerAddresses, selectedAddressId, applySavedAddress])
|
||||||
|
|
||||||
|
const foodsMap = useMemo(
|
||||||
|
() => new Map(foods.map((food) => [food.id, food])),
|
||||||
|
[foods]
|
||||||
|
)
|
||||||
|
|
||||||
|
const orderSummary = useMemo(() => {
|
||||||
|
const lineItems = formik.values.items
|
||||||
|
.map((item) => {
|
||||||
|
const food = foodsMap.get(item.foodId)
|
||||||
|
if (!food) return null
|
||||||
|
const unitPrice = getFoodUnitPrice(food)
|
||||||
|
return {
|
||||||
|
foodId: item.foodId,
|
||||||
|
title: food.title,
|
||||||
|
image: food.images?.[0] || '',
|
||||||
|
quantity: item.quantity,
|
||||||
|
unitPrice,
|
||||||
|
totalPrice: unitPrice * item.quantity,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const subTotal = lineItems.reduce((sum, item) => sum + (item?.totalPrice || 0), 0)
|
||||||
|
const deliveryFee = selectedDeliveryMethod?.deliveryFee || 0
|
||||||
|
const totalItems = lineItems.reduce((sum, item) => sum + (item?.quantity || 0), 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
lineItems,
|
||||||
|
subTotal,
|
||||||
|
deliveryFee,
|
||||||
|
total: subTotal + deliveryFee,
|
||||||
|
totalItems,
|
||||||
|
}
|
||||||
|
}, [formik.values.items, foodsMap, selectedDeliveryMethod])
|
||||||
|
|
||||||
|
const addFoodToOrder = (foodId: string) => {
|
||||||
|
const existingIndex = formik.values.items.findIndex((item) => item.foodId === foodId)
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
const updated = [...formik.values.items]
|
||||||
|
updated[existingIndex] = {
|
||||||
|
...updated[existingIndex],
|
||||||
|
quantity: updated[existingIndex].quantity + 1,
|
||||||
|
}
|
||||||
|
formik.setFieldValue('items', updated)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
formik.setFieldValue('items', [...formik.values.items, { foodId, quantity: 1 }])
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateItemQuantity = (foodId: string, quantity: number) => {
|
||||||
|
if (quantity < 1) {
|
||||||
|
formik.setFieldValue(
|
||||||
|
'items',
|
||||||
|
formik.values.items.filter((item) => item.foodId !== foodId)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
formik.setFieldValue(
|
||||||
|
'items',
|
||||||
|
formik.values.items.map((item) =>
|
||||||
|
item.foodId === foodId ? { ...item, quantity } : item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddressFieldChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
if (e.target.name?.startsWith('userAddress.')) {
|
||||||
|
setSelectedAddressId(null)
|
||||||
|
}
|
||||||
|
formik.handleChange(e)
|
||||||
|
},
|
||||||
|
[formik]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleLocationSelect = useCallback(
|
||||||
|
(lat: number, lng: number) => {
|
||||||
|
setSelectedAddressId(null)
|
||||||
|
formik.setFieldValue('userAddress.latitude', lat)
|
||||||
|
formik.setFieldValue('userAddress.longitude', lng)
|
||||||
|
},
|
||||||
|
[formik]
|
||||||
|
)
|
||||||
|
|
||||||
|
const removeItem = (foodId: string) => {
|
||||||
|
formik.setFieldValue(
|
||||||
|
'items',
|
||||||
|
formik.values.items.filter((item) => item.foodId !== foodId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
formik,
|
||||||
|
foods,
|
||||||
|
deliveryMethods,
|
||||||
|
selectedDeliveryMethod,
|
||||||
|
selectedPaymentMethod,
|
||||||
|
cashPaymentMethod,
|
||||||
|
isCourierDelivery,
|
||||||
|
isCarDelivery,
|
||||||
|
isDineIn,
|
||||||
|
orderSummary,
|
||||||
|
addFoodToOrder,
|
||||||
|
updateItemQuantity,
|
||||||
|
removeItem,
|
||||||
|
isSubmitting: isCreating || isCreatingUser || isCreatingAddress,
|
||||||
|
customerAddresses,
|
||||||
|
selectedAddressId,
|
||||||
|
handleUserFound,
|
||||||
|
handleUserNotFound,
|
||||||
|
clearCustomerData,
|
||||||
|
applySavedAddress,
|
||||||
|
selectManualAddress,
|
||||||
|
handleAddressFieldChange,
|
||||||
|
handleLocationSelect,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { RestaurantShipmentMethod, RestaurantPaymentMethod }
|
||||||
@@ -2,6 +2,16 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import * as api from "../service/OrderService";
|
import * as api from "../service/OrderService";
|
||||||
import type { GetOrdersParams, ChangeOrderStatusParams, RefundPaymentParams } from "../service/OrderService";
|
import type { GetOrdersParams, ChangeOrderStatusParams, RefundPaymentParams } from "../service/OrderService";
|
||||||
|
|
||||||
|
export const useCreateOrder = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: api.createOrder,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const useGetOrders = (params?: GetOrdersParams) => {
|
export const useGetOrders = (params?: GetOrdersParams) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["orders", params],
|
queryKey: ["orders", params],
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type {
|
|||||||
GetOrderByIdResponse,
|
GetOrderByIdResponse,
|
||||||
GetFoodOrderReportResponse,
|
GetFoodOrderReportResponse,
|
||||||
GetDailyOrderReportResponse,
|
GetDailyOrderReportResponse,
|
||||||
|
CreateOrderType,
|
||||||
|
CreateOrderResponse,
|
||||||
} from "../types/Types";
|
} from "../types/Types";
|
||||||
|
|
||||||
export interface GetOrdersParams {
|
export interface GetOrdersParams {
|
||||||
@@ -22,6 +24,16 @@ export interface RefundPaymentParams {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const createOrder = async (
|
||||||
|
params: CreateOrderType
|
||||||
|
): Promise<CreateOrderResponse> => {
|
||||||
|
const { data } = await axios.post<CreateOrderResponse>(
|
||||||
|
"/admin/orders",
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
export const getOrders = async (
|
export const getOrders = async (
|
||||||
params?: GetOrdersParams
|
params?: GetOrdersParams
|
||||||
): Promise<GetOrdersResponse> => {
|
): Promise<GetOrdersResponse> => {
|
||||||
|
|||||||
@@ -242,3 +242,37 @@ export interface DailyOrderReportItem extends RowDataType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type GetDailyOrderReportResponse = IResponse<DailyOrderReportItem[]>;
|
export type GetDailyOrderReportResponse = IResponse<DailyOrderReportItem[]>;
|
||||||
|
|
||||||
|
export interface CreateOrderItem {
|
||||||
|
foodId: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOrderUserAddress {
|
||||||
|
title: string;
|
||||||
|
phone: string;
|
||||||
|
address: string;
|
||||||
|
postalCode: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOrderCarAddress {
|
||||||
|
carModel: string;
|
||||||
|
carColor: string;
|
||||||
|
plateNumber: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOrderType {
|
||||||
|
userId: string;
|
||||||
|
items: CreateOrderItem[];
|
||||||
|
deliveryMethodId: string;
|
||||||
|
paymentMethodId: string;
|
||||||
|
description?: string;
|
||||||
|
tableNumber?: string;
|
||||||
|
addressId?: string;
|
||||||
|
carAddress?: CreateOrderCarAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateOrderResponse = IResponse<Order>;
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { CreateUserAddressType } from '@/pages/customers/types/Types'
|
||||||
|
import type { CreateOrderUserAddress } from '../types/Types'
|
||||||
|
|
||||||
|
export const mapFormAddressToCreateUserAddress = (
|
||||||
|
address: CreateOrderUserAddress,
|
||||||
|
isDefault: boolean
|
||||||
|
): CreateUserAddressType => {
|
||||||
|
const payload: CreateUserAddressType = {
|
||||||
|
title: address.title || 'آدرس جدید',
|
||||||
|
address: address.address,
|
||||||
|
latitude: Number(address.latitude) || 0,
|
||||||
|
longitude: Number(address.longitude) || 0,
|
||||||
|
phone: address.phone,
|
||||||
|
isDefault,
|
||||||
|
}
|
||||||
|
|
||||||
|
const postalCode = address.postalCode?.trim()
|
||||||
|
if (postalCode) {
|
||||||
|
payload.postalCode = postalCode
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { UserSavedAddress } from '@/pages/customers/types/Types'
|
||||||
|
import type { CreateOrderUserAddress } from '../types/Types'
|
||||||
|
|
||||||
|
export const mapSavedAddressToOrderAddress = (
|
||||||
|
saved: UserSavedAddress
|
||||||
|
): CreateOrderUserAddress => ({
|
||||||
|
title: saved.title || 'آدرس',
|
||||||
|
phone: saved.phone,
|
||||||
|
address: saved.address,
|
||||||
|
postalCode: saved.postalCode || '',
|
||||||
|
latitude: saved.latitude,
|
||||||
|
longitude: saved.longitude,
|
||||||
|
})
|
||||||
@@ -8,6 +8,13 @@ interface LocationPickerProps {
|
|||||||
onLocationSelect: (lat: number, lng: number) => void
|
onLocationSelect: (lat: number, lng: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NominatimSearchResult {
|
||||||
|
place_id: number
|
||||||
|
display_name: string
|
||||||
|
lat: string
|
||||||
|
lon: string
|
||||||
|
}
|
||||||
|
|
||||||
const ChangeView = ({ center, zoom }: { center: LatLngTuple; zoom: number }) => {
|
const ChangeView = ({ center, zoom }: { center: LatLngTuple; zoom: number }) => {
|
||||||
const map = useMap()
|
const map = useMap()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -20,6 +27,10 @@ const LocationPicker: FC<LocationPickerProps> = ({ initialPosition, onLocationSe
|
|||||||
const [position, setPosition] = useState<LatLngTuple>(
|
const [position, setPosition] = useState<LatLngTuple>(
|
||||||
initialPosition || [35.6892, 51.3890] // تهران به عنوان پیشفرض
|
initialPosition || [35.6892, 51.3890] // تهران به عنوان پیشفرض
|
||||||
)
|
)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [searchResults, setSearchResults] = useState<NominatimSearchResult[]>([])
|
||||||
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
|
const [searchError, setSearchError] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialPosition) {
|
if (initialPosition) {
|
||||||
@@ -27,6 +38,75 @@ const LocationPicker: FC<LocationPickerProps> = ({ initialPosition, onLocationSe
|
|||||||
}
|
}
|
||||||
}, [initialPosition])
|
}, [initialPosition])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const query = searchQuery.trim()
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
setSearchResults([])
|
||||||
|
setSearchError('')
|
||||||
|
setIsSearching(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = window.setTimeout(async () => {
|
||||||
|
setIsSearching(true)
|
||||||
|
setSearchError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
q: query,
|
||||||
|
format: 'json',
|
||||||
|
addressdetails: '1',
|
||||||
|
limit: '5',
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`https://nominatim.openstreetmap.org/search?${params.toString()}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('search request failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as NominatimSearchResult[]
|
||||||
|
if (!data.length) {
|
||||||
|
setSearchResults([])
|
||||||
|
setSearchError('نتیجهای پیدا نشد')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchResults(data)
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as DOMException).name === 'AbortError') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSearchResults([])
|
||||||
|
setSearchError('خطا در جستجوی موقعیت')
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false)
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
controller.abort()
|
||||||
|
window.clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [searchQuery])
|
||||||
|
|
||||||
|
const handleSelectSearchResult = (result: NominatimSearchResult) => {
|
||||||
|
const lat = Number(result.lat)
|
||||||
|
const lng = Number(result.lon)
|
||||||
|
const newPosition: LatLngTuple = [lat, lng]
|
||||||
|
setPosition(newPosition)
|
||||||
|
}
|
||||||
|
|
||||||
const LocationMarker = () => {
|
const LocationMarker = () => {
|
||||||
useMapEvents({
|
useMapEvents({
|
||||||
click(e) {
|
click(e) {
|
||||||
@@ -50,7 +130,36 @@ const LocationPicker: FC<LocationPickerProps> = ({ initialPosition, onLocationSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-[400px] rounded-xl overflow-hidden">
|
<div className='w-full'>
|
||||||
|
<div className='mb-3'>
|
||||||
|
<input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder='جستجوی آدرس یا مکان'
|
||||||
|
className='w-full h-10 border border-border rounded-xl px-3 text-sm outline-none focus:border-primary'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSearching && <div className='text-xs text-description mb-2'>در حال جستجو...</div>}
|
||||||
|
|
||||||
|
{searchError && <div className='text-xs text-red-500 mb-2'>{searchError}</div>}
|
||||||
|
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<div className='mb-3 border border-border rounded-xl max-h-32 overflow-auto'>
|
||||||
|
{searchResults.map((result) => (
|
||||||
|
<button
|
||||||
|
key={result.place_id}
|
||||||
|
type='button'
|
||||||
|
onClick={() => handleSelectSearchResult(result)}
|
||||||
|
className='w-full text-right px-3 py-2 text-xs hover:bg-gray-50 border-b border-border last:border-b-0'
|
||||||
|
>
|
||||||
|
{result.display_name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='w-full h-[400px] rounded-xl overflow-hidden'>
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={position}
|
center={position}
|
||||||
zoom={13}
|
zoom={13}
|
||||||
@@ -65,6 +174,7 @@ const LocationPicker: FC<LocationPickerProps> = ({ initialPosition, onLocationSe
|
|||||||
<LocationMarker />
|
<LocationMarker />
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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 OrdersList from '@/pages/orders/List'
|
import OrdersList from '@/pages/orders/List'
|
||||||
|
import CreateOrder from '@/pages/orders/Create'
|
||||||
import OrderDetails from '@/pages/orders/Details'
|
import OrderDetails from '@/pages/orders/Details'
|
||||||
import FoodOrderReport from '@/pages/orders/FoodOrderReport'
|
import FoodOrderReport from '@/pages/orders/FoodOrderReport'
|
||||||
import DailyOrderReport from '@/pages/orders/DailyOrderReport'
|
import DailyOrderReport from '@/pages/orders/DailyOrderReport'
|
||||||
@@ -73,6 +74,7 @@ const MainRouter: FC = () => {
|
|||||||
<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.orders.list} element={<OrdersList />} />
|
<Route path={Pages.orders.list} element={<OrdersList />} />
|
||||||
|
<Route path={Pages.orders.add} element={<CreateOrder />} />
|
||||||
<Route path={Pages.orders.foodReport} element={<FoodOrderReport />} />
|
<Route path={Pages.orders.foodReport} element={<FoodOrderReport />} />
|
||||||
<Route path={Pages.orders.dailyReport} element={<DailyOrderReport />} />
|
<Route path={Pages.orders.dailyReport} element={<DailyOrderReport />} />
|
||||||
<Route path={Pages.orders.detail + ':id'} element={<OrderDetails />} />
|
<Route path={Pages.orders.detail + ':id'} element={<OrderDetails />} />
|
||||||
|
|||||||
@@ -43,11 +43,11 @@ const OrdersSubMenu: FC = () => {
|
|||||||
isActive={isActive('report/by-day')}
|
isActive={isActive('report/by-day')}
|
||||||
link={Pages.orders.dailyReport}
|
link={Pages.orders.dailyReport}
|
||||||
/>
|
/>
|
||||||
{/* <SubMenuItem
|
<SubMenuItem
|
||||||
title={t('submenu.add_order')}
|
title={t('submenu.add_order')}
|
||||||
isActive={isActive('add')}
|
isActive={isActive('add')}
|
||||||
link={Pages.orders.add}
|
link={Pages.orders.add}
|
||||||
/> */}
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user