Compare commits

...

7 Commits

Author SHA1 Message Date
hamid zarghami 2d35a428b1 base url
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-02 10:15:14 +03:30
hamid zarghami aac1a4f81b user option and added in modal 2026-07-01 12:55:27 +03:30
hamid zarghami 282e001933 user optional 2026-07-01 12:52:52 +03:30
hamid zarghami 8ec3f307b0 allways show receipt 2026-07-01 12:40:44 +03:30
hamid zarghami 4c240ac9a1 finish order 2026-07-01 12:35:08 +03:30
hamid zarghami d52ff4f07e discountAmount and packingFee in create order admin 2026-07-01 12:28:51 +03:30
hamid zarghami c7b4e77421 fix chart 2026-07-01 12:14:00 +03:30
8 changed files with 385 additions and 246 deletions
+28 -2
View File
@@ -95,6 +95,11 @@ const OrderDetails: FC = () => {
const isOrderPaid = order.status === OrderStatus.PAID const isOrderPaid = order.status === OrderStatus.PAID
const isPreparing = order.status === OrderStatus.PREPARING const isPreparing = order.status === OrderStatus.PREPARING
const isCanceled = order.status === OrderStatus.CANCELED const isCanceled = order.status === OrderStatus.CANCELED
const canCompleteOrder = [
OrderStatus.DELIVERED_TO_WAITER,
OrderStatus.DELIVERED_TO_RECEPTIONIST,
OrderStatus.SHIPPED,
].includes(order.status as OrderStatus)
// بررسی وضعیت پرداخت از payments array یا paymentStatus // بررسی وضعیت پرداخت از payments array یا paymentStatus
// ابتدا از paymentStatus استفاده می‌کنیم، اگر نبود از اولین payment در payments array استفاده می‌کنیم // ابتدا از paymentStatus استفاده می‌کنیم، اگر نبود از اولین payment در payments array استفاده می‌کنیم
const paymentStatus = order.paymentStatus || order.payments?.[0]?.status const paymentStatus = order.paymentStatus || order.payments?.[0]?.status
@@ -104,7 +109,11 @@ const OrderDetails: FC = () => {
const isDineInOrCarDelivery = order.deliveryMethod?.method === DeliveryMethodEnum.DineIn || order.deliveryMethod?.method === DeliveryMethodEnum.DeliveryCar const isDineInOrCarDelivery = order.deliveryMethod?.method === DeliveryMethodEnum.DineIn || order.deliveryMethod?.method === DeliveryMethodEnum.DeliveryCar
const isCustomerPickup = order.deliveryMethod?.method === DeliveryMethodEnum.CustomerPickup const isCustomerPickup = order.deliveryMethod?.method === DeliveryMethodEnum.CustomerPickup
const showVerifyPaymentBox = (isCashPayment || isCreditCardPayment) && isPaymentPending && !isCanceled const payment = order.payments?.[0]
const hasPaymentDescription = Boolean(payment?.description)
const hasPaymentAttachments = Boolean(payment?.attachments && payment.attachments.length > 0)
const showPaymentReceiptBox = isCreditCardPayment && (hasPaymentDescription || hasPaymentAttachments)
const showVerifyPaymentButton = (isCashPayment || isCreditCardPayment) && isPaymentPending && !isCanceled
// نمایش دکمه ارسال به آشپزخانه // نمایش دکمه ارسال به آشپزخانه
// برای pendingPayment با پرداخت نقدی یا برای paid // برای pendingPayment با پرداخت نقدی یا برای paid
@@ -185,6 +194,20 @@ const OrderDetails: FC = () => {
setShowCancelModal(false) setShowCancelModal(false)
} }
const handleCompleteOrder = () => {
changeOrderStatus(
{
orderId: order.id,
status: OrderStatus.COMPLETED,
},
{
onError: (error) => {
toast.error(extractErrorMessage(error))
},
}
)
}
const handleRefund = (text?: string) => { const handleRefund = (text?: string) => {
if (!text) return if (!text) return
refundPayment({ refundPayment({
@@ -232,11 +255,12 @@ const OrderDetails: FC = () => {
getPaymentStatusTextColor={getPaymentStatusTextColor} getPaymentStatusTextColor={getPaymentStatusTextColor}
getPaymentStatusText={getPaymentStatusText} getPaymentStatusText={getPaymentStatusText}
/> />
{showVerifyPaymentBox && ( {showPaymentReceiptBox && (
<PaymentVerificationBox <PaymentVerificationBox
order={order} order={order}
isVerifyingPayment={isVerifyingPayment} isVerifyingPayment={isVerifyingPayment}
onVerifyPayment={handleVerifyPayment} onVerifyPayment={handleVerifyPayment}
showVerifyButton={showVerifyPaymentButton}
/> />
)} )}
<OrderActions <OrderActions
@@ -248,11 +272,13 @@ const OrderDetails: FC = () => {
isDineInOrCarDelivery={isDineInOrCarDelivery} isDineInOrCarDelivery={isDineInOrCarDelivery}
isCustomerPickup={isCustomerPickup} isCustomerPickup={isCustomerPickup}
isCanceled={isCanceled} isCanceled={isCanceled}
canCompleteOrder={canCompleteOrder}
isChangingStatus={isChangingStatus} isChangingStatus={isChangingStatus}
onSendToKitchen={handleSendToKitchen} onSendToKitchen={handleSendToKitchen}
onDeliverToCourier={handleDeliverToCourier} onDeliverToCourier={handleDeliverToCourier}
onDeliverToWaiter={handleDeliverToWaiter} onDeliverToWaiter={handleDeliverToWaiter}
onDeliverToReceptionist={handleDeliverToReceptionist} onDeliverToReceptionist={handleDeliverToReceptionist}
onCompleteOrder={handleCompleteOrder}
onCancelOrder={() => setShowCancelModal(true)} onCancelOrder={() => setShowCancelModal(true)}
/> />
</div> </div>
@@ -3,6 +3,8 @@ import { Profile, SearchNormal, TickCircle, UserAdd } from 'iconsax-react'
import type { FormikProps } from 'formik' import type { FormikProps } from 'formik'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { clx } from '@/helpers/utils' import { clx } from '@/helpers/utils'
import Button from '@/components/Button'
import DefaulModal from '@/components/DefaulModal'
import Input from '@/components/Input' import Input from '@/components/Input'
import { useSearchUserByPhone } from '@/pages/customers/hooks/useUsersData' import { useSearchUserByPhone } from '@/pages/customers/hooks/useUsersData'
import type { UserWithAddresses } from '@/pages/customers/types/Types' import type { UserWithAddresses } from '@/pages/customers/types/Types'
@@ -28,6 +30,7 @@ const CreateOrderCustomerSection: FC<CreateOrderCustomerSectionProps> = ({
}) => { }) => {
const { mutate: searchUser, isPending: isSearching } = useSearchUserByPhone() const { mutate: searchUser, isPending: isSearching } = useSearchUserByPhone()
const [searchState, setSearchState] = useState<CustomerSearchState>('idle') const [searchState, setSearchState] = useState<CustomerSearchState>('idle')
const [isCustomerModalOpen, setIsCustomerModalOpen] = useState(false)
const handleSearch = () => { const handleSearch = () => {
const phone = formik.values.userPhone.trim() const phone = formik.values.userPhone.trim()
@@ -87,131 +90,159 @@ const CreateOrderCustomerSection: FC<CreateOrderCustomerSectionProps> = ({
: '' : ''
return ( return (
<div className='w-full bg-white rounded-4xl px-5 py-4 flex-shrink-0'> <>
<div className='flex items-center justify-between gap-3 mb-3'> <div className='w-full bg-white rounded-4xl px-5 py-4 flex-shrink-0'>
<div className='flex items-center gap-2'> <div className='flex items-center justify-between gap-3'>
<div <div className='flex items-center gap-2'>
className={clx( <div
'w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 transition-colors',
searchState === 'found'
? 'bg-green-50'
: searchState === 'not_found'
? 'bg-[#EEF0F7]'
: 'bg-[#EEF0F7]',
)}
>
{searchState === 'found' ? (
<TickCircle size={18} color='#16A34A' />
) : searchState === 'not_found' ? (
<UserAdd size={18} color='#8C90A3' />
) : (
<Profile size={18} color='#8C90A3' />
)}
</div>
<div>
<div className='text-sm font-light'>اطلاعات مشتری</div>
{searchState === 'found' && customerName && (
<div className='text-[11px] text-green-600 mt-0.5'>
{customerName}
</div>
)}
{searchState === 'not_found' && (
<div className='text-[11px] text-description mt-0.5'>
مشتری جدید
</div>
)}
</div>
</div>
{searchState === 'found' && (
<span className='text-[11px] text-green-600 bg-green-50 rounded-full px-3 py-1 flex-shrink-0'>
مشتری یافت شد
</span>
)}
{searchState === 'idle' && (
<span className='text-[11px] text-description bg-[#F8F9FC] rounded-full px-3 py-1 flex-shrink-0 hidden sm:inline'>
ابتدا شماره را جستجو کنید
</span>
)}
</div>
<div className='grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-[1.2fr_1fr_1fr] gap-3 xl:items-end'>
<div className='w-full'>
<label className='text-xs text-description'>شماره تلفن</label>
<div className='flex mt-1'>
<input
name='userPhone'
placeholder='۰۹۱۲۱۲۳۴۵۶۷'
value={formik.values.userPhone}
onChange={handlePhoneChange}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSearch()
}
}}
className={clx( className={clx(
'flex-1 min-w-0 h-10 text-xs px-4 border border-border bg-white', 'w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 transition-colors',
'rounded-r-xl border-l-0 focus:outline-none', searchState === 'found' ? 'bg-green-50' : 'bg-[#EEF0F7]',
phoneError && 'border-red-400',
)}
/>
<button
type='button'
disabled={isSearching}
onClick={handleSearch}
className={clx(
'h-10 px-4 flex items-center gap-1.5 flex-shrink-0',
'bg-primary text-white text-xs rounded-l-xl',
'hover:opacity-90 transition-opacity disabled:opacity-60',
)} )}
> >
{isSearching ? ( {searchState === 'found' ? (
<span className='w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin' /> <TickCircle size={18} color='#16A34A' />
) : searchState === 'not_found' ? (
<UserAdd size={18} color='#8C90A3' />
) : ( ) : (
<SearchNormal size={16} color='#fff' /> <Profile size={18} color='#8C90A3' />
)} )}
<span className='hidden sm:inline'>جستجو</span> </div>
</button> <div>
<div className='text-sm font-light flex items-center gap-2'>
اطلاعات مشتری
<span className='text-[11px] text-description bg-[#F8F9FC] rounded-full px-2 py-0.5'>
اختیاری
</span>
</div>
{searchState === 'found' && customerName && (
<div className='text-[11px] text-green-600 mt-0.5'>
{customerName}
</div>
)}
{searchState === 'not_found' && (
<div className='text-[11px] text-description mt-0.5'>
مشتری جدید
</div>
)}
{searchState === 'idle' && (
<div className='text-[11px] text-description mt-0.5'>
برای این سفارش وارد کردن اطلاعات مشتری الزامی نیست
</div>
)}
</div>
</div> </div>
{phoneError && ( <Button
<p className='text-[11px] text-red-500 mt-1'>{phoneError}</p> type='button'
)} className='w-fit px-4 h-10'
onClick={() => setIsCustomerModalOpen(true)}
>
{searchState === 'idle' ? 'افزودن مشتری' : 'ویرایش مشتری'}
</Button>
</div> </div>
<Input
label='نام'
name='firstName'
placeholder={isNameDisabled ? 'پس از جستجو' : 'نام'}
value={formik.values.firstName}
onChange={formik.handleChange}
readOnly={isCustomerLocked}
disabled={isNameDisabled}
className={isNameDisabled ? 'bg-[#F8F9FC] border-0 text-description cursor-not-allowed' : ''}
error_text={
formik.touched.firstName && formik.errors.firstName
? formik.errors.firstName
: ''
}
/>
<Input
label='نام خانوادگی'
name='lastName'
placeholder={isNameDisabled ? 'پس از جستجو' : 'نام خانوادگی'}
value={formik.values.lastName}
onChange={formik.handleChange}
readOnly={isCustomerLocked}
disabled={isNameDisabled}
className={isNameDisabled ? 'bg-[#F8F9FC] border-0 text-description cursor-not-allowed' : ''}
error_text={
formik.touched.lastName && formik.errors.lastName
? formik.errors.lastName
: ''
}
/>
</div> </div>
</div>
<DefaulModal
open={isCustomerModalOpen}
close={() => setIsCustomerModalOpen(false)}
isHeader={true}
title_header='اطلاعات مشتری'
width={700}
>
<div className='p-2'>
<div className='text-xs text-description mb-4'>
اطلاعات مشتری اختیاری است و در صورت نیاز میتوانید ثبت کنید.
</div>
<div className='grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-[1.2fr_1fr_1fr] gap-3 xl:items-end'>
<div className='w-full'>
<label className='text-xs text-description'>شماره تلفن</label>
<div className='flex mt-1'>
<input
name='userPhone'
placeholder='۰۹۱۲۱۲۳۴۵۶۷'
value={formik.values.userPhone}
onChange={handlePhoneChange}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSearch()
}
}}
className={clx(
'flex-1 min-w-0 h-10 text-xs px-4 border border-border bg-white',
'rounded-r-xl border-l-0 focus:outline-none',
phoneError && 'border-red-400',
)}
/>
<button
type='button'
disabled={isSearching}
onClick={handleSearch}
className={clx(
'h-10 px-4 flex items-center gap-1.5 flex-shrink-0',
'bg-primary text-white text-xs rounded-l-xl',
'hover:opacity-90 transition-opacity disabled:opacity-60',
)}
>
{isSearching ? (
<span className='w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin' />
) : (
<SearchNormal size={16} color='#fff' />
)}
<span className='hidden sm:inline'>جستجو</span>
</button>
</div>
{phoneError && (
<p className='text-[11px] text-red-500 mt-1'>{phoneError}</p>
)}
</div>
<Input
label='نام'
name='firstName'
placeholder={isNameDisabled ? 'پس از جستجو' : 'نام'}
value={formik.values.firstName}
onChange={formik.handleChange}
readOnly={isCustomerLocked}
disabled={isNameDisabled}
className={isNameDisabled ? 'bg-[#F8F9FC] border-0 text-description cursor-not-allowed' : ''}
error_text={
formik.touched.firstName && formik.errors.firstName
? formik.errors.firstName
: ''
}
/>
<Input
label='نام خانوادگی'
name='lastName'
placeholder={isNameDisabled ? 'پس از جستجو' : 'نام خانوادگی'}
value={formik.values.lastName}
onChange={formik.handleChange}
readOnly={isCustomerLocked}
disabled={isNameDisabled}
className={isNameDisabled ? 'bg-[#F8F9FC] border-0 text-description cursor-not-allowed' : ''}
error_text={
formik.touched.lastName && formik.errors.lastName
? formik.errors.lastName
: ''
}
/>
</div>
<div className='mt-5 flex justify-end'>
<Button
type='button'
className='w-fit px-6'
onClick={() => setIsCustomerModalOpen(false)}
>
تایید
</Button>
</div>
</div>
</DefaulModal>
</>
) )
} }
@@ -29,6 +29,8 @@ interface OrderSummaryData {
} | null> } | null>
subTotal: number subTotal: number
deliveryFee: number deliveryFee: number
packingFee: number
discountAmount: number
total: number total: number
totalItems: number totalItems: number
} }
@@ -129,7 +131,7 @@ const CreateOrderSidebar: FC<CreateOrderSidebarProps> = ({
return ( return (
<> <>
<div className='bg-white rounded-4xl p-5 flex-1 min-h-0 flex flex-col h-full'> <div className='bg-white rounded-4xl p-4 flex-1 min-h-0 flex flex-col h-full'>
<div className='flex items-center justify-between flex-shrink-0'> <div className='flex items-center justify-between flex-shrink-0'>
<div className='flex items-center gap-2 text-sm font-light'> <div className='flex items-center gap-2 text-sm font-light'>
<ShoppingCart size={18} color='#000' /> <ShoppingCart size={18} color='#000' />
@@ -143,12 +145,12 @@ const CreateOrderSidebar: FC<CreateOrderSidebarProps> = ({
</div> </div>
{(customerName || formik.values.userPhone) && ( {(customerName || formik.values.userPhone) && (
<div className='mt-3 p-3 bg-[#F8F9FC] rounded-xl flex-shrink-0'> <div className='mt-2 p-2.5 bg-[#F8F9FC] rounded-xl flex-shrink-0'>
<div className='text-xs font-medium truncate'> <div className='text-[11px] font-medium truncate'>
{customerName || 'مشتری'} {customerName || 'مشتری'}
</div> </div>
{formik.values.userPhone && ( {formik.values.userPhone && (
<div className='text-[11px] text-description mt-0.5'> <div className='text-[10px] text-description mt-0.5'>
{formik.values.userPhone} {formik.values.userPhone}
</div> </div>
)} )}
@@ -158,7 +160,7 @@ const CreateOrderSidebar: FC<CreateOrderSidebarProps> = ({
<button <button
type='button' type='button'
onClick={() => setIsDeliveryModalOpen(true)} onClick={() => setIsDeliveryModalOpen(true)}
className={`mt-3 w-full text-right p-3 rounded-xl border transition-colors flex-shrink-0 ${ className={`mt-2 w-full text-right p-2.5 rounded-xl border transition-colors flex-shrink-0 ${
isDeliverySelected && isPaymentSelected isDeliverySelected && isPaymentSelected
? 'border-border hover:border-primary/40 hover:bg-[#F8F9FC]' ? 'border-border hover:border-primary/40 hover:bg-[#F8F9FC]'
: 'border-dashed border-primary/50 bg-primary/5 hover:bg-primary/10' : 'border-dashed border-primary/50 bg-primary/5 hover:bg-primary/10'
@@ -178,7 +180,7 @@ const CreateOrderSidebar: FC<CreateOrderSidebarProps> = ({
</p> </p>
) : null} ) : null}
<div className='mt-2 space-y-2'> <div className='mt-1.5 space-y-1.5'>
<div className='flex items-center gap-2 text-xs'> <div className='flex items-center gap-2 text-xs'>
<Truck size={15} color='#8C90A3' className='flex-shrink-0' /> <Truck size={15} color='#8C90A3' className='flex-shrink-0' />
<span <span
@@ -221,8 +223,8 @@ const CreateOrderSidebar: FC<CreateOrderSidebarProps> = ({
</div> </div>
</button> </button>
<div className='mt-4 pt-3 border-t border-border border-dashed flex-1 min-h-0 flex flex-col'> <div className='mt-3 pt-2.5 border-t border-border border-dashed flex-1 min-h-0 flex flex-col'>
<div className='text-xs text-description mb-2 flex-shrink-0'>سبد سفارش</div> <div className='text-xs text-description mb-1.5 flex-shrink-0'>سبد سفارش</div>
<div className='flex-1 min-h-0 overflow-y-auto relative'> <div className='flex-1 min-h-0 overflow-y-auto relative'>
{orderSummary.lineItems.length === 0 ? ( {orderSummary.lineItems.length === 0 ? (
@@ -233,7 +235,7 @@ const CreateOrderSidebar: FC<CreateOrderSidebarProps> = ({
</div> </div>
</div> </div>
) : ( ) : (
<div className='space-y-2.5 pb-2'> <div className='space-y-2 pb-2'>
{orderSummary.lineItems.map((item) => { {orderSummary.lineItems.map((item) => {
if (!item) return null if (!item) return null
return ( return (
@@ -319,7 +321,51 @@ const CreateOrderSidebar: FC<CreateOrderSidebarProps> = ({
)} )}
</div> </div>
<div className='mt-3 space-y-2 text-xs pt-3 border-t border-border flex-shrink-0'> <div className='mt-2 space-y-2 text-xs pt-2.5 border-t border-border flex-shrink-0'>
<div className='grid grid-cols-2 gap-2.5 mb-2'>
<div>
<div className='text-xs mb-1.5'>
<span className='font-medium'>هزینه بستهبندی</span>
<span className='text-description mr-1'>(اختیاری)</span>
</div>
<input
name='packingFee'
type='number'
inputMode='numeric'
min={0}
placeholder='تومان'
className='w-full h-10 rounded-xl border border-border px-3 text-sm outline-none focus:border-primary'
value={formik.values.packingFee || ''}
onChange={(e) =>
formik.setFieldValue(
'packingFee',
Math.max(Number(e.target.value) || 0, 0)
)
}
/>
</div>
<div>
<div className='text-xs mb-1.5'>
<span className='font-medium'>تخفیف مبلغی</span>
<span className='text-description mr-1'>(اختیاری)</span>
</div>
<input
name='discountAmount'
type='number'
inputMode='numeric'
min={0}
placeholder='تومان'
className='w-full h-10 rounded-xl border border-border px-3 text-sm outline-none focus:border-primary'
value={formik.values.discountAmount || ''}
onChange={(e) =>
formik.setFieldValue(
'discountAmount',
Math.max(Number(e.target.value) || 0, 0)
)
}
/>
</div>
</div>
<div className='flex justify-between'> <div className='flex justify-between'>
<span className='text-description'>جمع آیتمها</span> <span className='text-description'>جمع آیتمها</span>
<span>{formatPrice(orderSummary.subTotal)}</span> <span>{formatPrice(orderSummary.subTotal)}</span>
@@ -330,6 +376,20 @@ const CreateOrderSidebar: FC<CreateOrderSidebarProps> = ({
<span>{formatPrice(orderSummary.deliveryFee)}</span> <span>{formatPrice(orderSummary.deliveryFee)}</span>
</div> </div>
)} )}
{orderSummary.packingFee > 0 && (
<div className='flex justify-between'>
<span className='text-description'>هزینه بستهبندی</span>
<span>{formatPrice(orderSummary.packingFee)}</span>
</div>
)}
{orderSummary.discountAmount > 0 && (
<div className='flex justify-between'>
<span className='text-description'>تخفیف</span>
<span className='text-green-600'>
-{formatPrice(orderSummary.discountAmount)}
</span>
</div>
)}
<div className='flex justify-between font-bold text-sm pt-2 border-t-2 border-gray-200'> <div className='flex justify-between font-bold text-sm pt-2 border-t-2 border-gray-200'>
<span>جمع کل</span> <span>جمع کل</span>
<span>{formatPrice(orderSummary.total)}</span> <span>{formatPrice(orderSummary.total)}</span>
@@ -10,11 +10,13 @@ interface OrderActionsProps {
isDineInOrCarDelivery: boolean isDineInOrCarDelivery: boolean
isCustomerPickup: boolean isCustomerPickup: boolean
isCanceled: boolean isCanceled: boolean
canCompleteOrder: boolean
isChangingStatus: boolean isChangingStatus: boolean
onSendToKitchen: () => void onSendToKitchen: () => void
onDeliverToCourier: () => void onDeliverToCourier: () => void
onDeliverToWaiter: () => void onDeliverToWaiter: () => void
onDeliverToReceptionist: () => void onDeliverToReceptionist: () => void
onCompleteOrder: () => void
onCancelOrder: () => void onCancelOrder: () => void
} }
@@ -27,11 +29,13 @@ const OrderActions: FC<OrderActionsProps> = ({
isDineInOrCarDelivery, isDineInOrCarDelivery,
isCustomerPickup, isCustomerPickup,
isCanceled, isCanceled,
canCompleteOrder,
isChangingStatus, isChangingStatus,
onSendToKitchen, onSendToKitchen,
onDeliverToCourier, onDeliverToCourier,
onDeliverToWaiter, onDeliverToWaiter,
onDeliverToReceptionist, onDeliverToReceptionist,
onCompleteOrder,
onCancelOrder onCancelOrder
}) => { }) => {
const [loadingAction, setLoadingAction] = useState<string | null>(null) const [loadingAction, setLoadingAction] = useState<string | null>(null)
@@ -67,6 +71,11 @@ const OrderActions: FC<OrderActionsProps> = ({
onCancelOrder() onCancelOrder()
} }
const handleCompleteOrder = () => {
setLoadingAction('completeOrder')
onCompleteOrder()
}
return ( return (
<div className='mt-6 flex flex-col gap-3'> <div className='mt-6 flex flex-col gap-3'>
<Button <Button
@@ -111,6 +120,15 @@ const OrderActions: FC<OrderActionsProps> = ({
/> />
)} )}
{!isCanceled && canCompleteOrder && (
<Button
label='اتمام سفارش'
onClick={handleCompleteOrder}
isloading={isChangingStatus && loadingAction === 'completeOrder'}
className='bg-green-600 hover:bg-green-700'
/>
)}
{!isCanceled && showCancelButton && ( {!isCanceled && showCancelButton && (
<Button <Button
label='لغو سفارش' label='لغو سفارش'
@@ -7,12 +7,14 @@ interface PaymentVerificationBoxProps {
order: Order order: Order
isVerifyingPayment: boolean isVerifyingPayment: boolean
onVerifyPayment: () => void onVerifyPayment: () => void
showVerifyButton: boolean
} }
const PaymentVerificationBox: FC<PaymentVerificationBoxProps> = ({ const PaymentVerificationBox: FC<PaymentVerificationBoxProps> = ({
order, order,
isVerifyingPayment, isVerifyingPayment,
onVerifyPayment, onVerifyPayment,
showVerifyButton,
}) => { }) => {
const payment = order.payments?.[0] const payment = order.payments?.[0]
const isCreditCard = order.paymentMethod?.method === PaymentMethodEnum.CreditCard const isCreditCard = order.paymentMethod?.method === PaymentMethodEnum.CreditCard
@@ -53,12 +55,14 @@ const PaymentVerificationBox: FC<PaymentVerificationBoxProps> = ({
</div> </div>
)} )}
<Button {showVerifyButton && (
label='تایید پرداخت' <Button
onClick={onVerifyPayment} label='تایید پرداخت'
isloading={isVerifyingPayment} onClick={onVerifyPayment}
className='bg-green-600 hover:bg-green-700 w-full' isloading={isVerifyingPayment}
/> className='bg-green-600 hover:bg-green-700 w-full'
/>
)}
</div> </div>
</div> </div>
) )
+41 -14
View File
@@ -33,6 +33,8 @@ export interface CreateOrderFormValues {
items: CreateOrderItem[] items: CreateOrderItem[]
deliveryMethodId: string deliveryMethodId: string
paymentMethodId: string paymentMethodId: string
discountAmount: number
packingFee: number
description: string description: string
tableNumber: string tableNumber: string
userAddress: CreateOrderUserAddress userAddress: CreateOrderUserAddress
@@ -97,6 +99,8 @@ export const useCreateOrderForm = () => {
items: [], items: [],
deliveryMethodId: '', deliveryMethodId: '',
paymentMethodId: '', paymentMethodId: '',
discountAmount: 0,
packingFee: 0,
description: '', description: '',
tableNumber: '', tableNumber: '',
userAddress: emptyUserAddress(), userAddress: emptyUserAddress(),
@@ -104,9 +108,9 @@ export const useCreateOrderForm = () => {
paymentDesc: '', paymentDesc: '',
}, },
validationSchema: Yup.object().shape({ validationSchema: Yup.object().shape({
userPhone: Yup.string().required('شماره تلفن الزامی است'), userPhone: Yup.string(),
firstName: Yup.string().required('نام الزامی است'), firstName: Yup.string(),
lastName: Yup.string().required('نام خانوادگی الزامی است'), lastName: Yup.string(),
deliveryMethodId: Yup.string().required('روش تحویل الزامی است'), deliveryMethodId: Yup.string().required('روش تحویل الزامی است'),
paymentMethodId: Yup.string().required('روش پرداخت الزامی است'), paymentMethodId: Yup.string().required('روش پرداخت الزامی است'),
items: Yup.array() items: Yup.array()
@@ -117,6 +121,8 @@ export const useCreateOrderForm = () => {
}) })
) )
.min(1, 'حداقل یک آیتم الزامی است'), .min(1, 'حداقل یک آیتم الزامی است'),
discountAmount: Yup.number().min(0, 'تخفیف نمی‌تواند منفی باشد'),
packingFee: Yup.number().min(0, 'هزینه بسته‌بندی نمی‌تواند منفی باشد'),
}), }),
onSubmit: async (values) => { onSubmit: async (values) => {
const selectedDelivery = deliveryMethods.find( const selectedDelivery = deliveryMethods.find(
@@ -154,12 +160,17 @@ export const useCreateOrderForm = () => {
return return
} }
const submitOrder = (userId: string, addressId?: string) => { const submitOrder = (userId?: string, addressId?: string) => {
const payload: CreateOrderType = { const payload: CreateOrderType = {
userId,
items: values.items, items: values.items,
deliveryMethodId: values.deliveryMethodId, deliveryMethodId: values.deliveryMethodId,
paymentMethodId: values.paymentMethodId, paymentMethodId: values.paymentMethodId,
discountAmount: values.discountAmount || 0,
packingFee: values.packingFee || 0,
}
if (userId) {
payload.userId = userId
} }
if (values.description.trim()) { if (values.description.trim()) {
@@ -234,18 +245,23 @@ export const useCreateOrderForm = () => {
const submitFlow = async () => { const submitFlow = async () => {
try { try {
const isCustomerRequiredForDelivery =
selectedDelivery?.method === DeliveryMethodEnum.DeliveryCourier
const userId = await registerNewCustomer() const userId = await registerNewCustomer()
if (!userId) { if (isCustomerRequiredForDelivery && !userId) {
toast.error('کاربر انتخاب نشده است') toast.error('برای ثبت آدرس، انتخاب مشتری الزامی است')
return return
} }
let addressId: string | undefined let addressId: string | undefined
try { if (userId) {
addressId = await resolveAddressId(userId) try {
} catch (error) { addressId = await resolveAddressId(userId)
toast.error(extractErrorMessage(error as ErrorType, 'خطا در ثبت آدرس')) } catch (error) {
return toast.error(extractErrorMessage(error as ErrorType, 'خطا در ثبت آدرس'))
return
}
} }
if ( if (
@@ -383,16 +399,27 @@ export const useCreateOrderForm = () => {
const subTotal = lineItems.reduce((sum, item) => sum + (item?.totalPrice || 0), 0) const subTotal = lineItems.reduce((sum, item) => sum + (item?.totalPrice || 0), 0)
const deliveryFee = selectedDeliveryMethod?.deliveryFee || 0 const deliveryFee = selectedDeliveryMethod?.deliveryFee || 0
const packingFee = formik.values.packingFee || 0
const discountAmount = formik.values.discountAmount || 0
const totalItems = lineItems.reduce((sum, item) => sum + (item?.quantity || 0), 0) const totalItems = lineItems.reduce((sum, item) => sum + (item?.quantity || 0), 0)
const total = Math.max(subTotal + deliveryFee + packingFee - discountAmount, 0)
return { return {
lineItems, lineItems,
subTotal, subTotal,
deliveryFee, deliveryFee,
total: subTotal + deliveryFee, packingFee,
discountAmount,
total,
totalItems, totalItems,
} }
}, [formik.values.items, foodsMap, selectedDeliveryMethod]) }, [
formik.values.items,
formik.values.packingFee,
formik.values.discountAmount,
foodsMap,
selectedDeliveryMethod,
])
const addFoodToOrder = (foodId: string) => { const addFoodToOrder = (foodId: string) => {
const existingIndex = formik.values.items.findIndex((item) => item.foodId === foodId) const existingIndex = formik.values.items.findIndex((item) => item.foodId === foodId)
+3 -1
View File
@@ -265,10 +265,12 @@ export interface CreateOrderCarAddress {
} }
export interface CreateOrderType { export interface CreateOrderType {
userId: string; userId?: string;
items: CreateOrderItem[]; items: CreateOrderItem[];
deliveryMethodId: string; deliveryMethodId: string;
paymentMethodId: string; paymentMethodId: string;
discountAmount: number;
packingFee: number;
description?: string; description?: string;
tableNumber?: string; tableNumber?: string;
addressId?: string; addressId?: string;
@@ -5,11 +5,45 @@ import { formatPrice } from "@/helpers/func";
import { ArrowDown, ArrowUp, DocumentDownload } from "iconsax-react"; import { ArrowDown, ArrowUp, DocumentDownload } from "iconsax-react";
import moment from "moment-jalaali"; import moment from "moment-jalaali";
import { type FC, useMemo, useState } from "react"; import { type FC, useMemo, useState } from "react";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import { CartesianGrid, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
import { useGetPaymentsChart } from "../hooks/useStatsData"; import { useGetPaymentsChart } from "../hooks/useStatsData";
const PERSIAN_MONTHS = [
"فروردین",
"اردیبهشت",
"خرداد",
"تیر",
"مرداد",
"شهریور",
"مهر",
"آبان",
"آذر",
"دی",
"بهمن",
"اسفند",
] as const;
const toPersianDigits = (value: string | number): string =>
String(value).replace(/\d/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[Number(d)]);
const formatJalaliAxisLabel = (dateString: string, period: "daily" | "monthly" | "yearly"): string => {
const m = moment(dateString, "YYYY-MM-DD");
const jYear = m.jYear();
const jMonth = m.jMonth();
const jDate = m.jDate();
if (period === "yearly") return toPersianDigits(jYear);
if (period === "monthly") return `${PERSIAN_MONTHS[jMonth]} ${toPersianDigits(jYear)}`;
return toPersianDigits(`${String(jMonth + 1).padStart(2, "0")}/${String(jDate).padStart(2, "0")}`);
};
const formatJalaliFullDate = (dateString: string): string => {
const m = moment(dateString, "YYYY-MM-DD");
return `${toPersianDigits(m.jDate())} ${PERSIAN_MONTHS[m.jMonth()]} ${toPersianDigits(m.jYear())}`;
};
const RevenueChart: FC = () => { const RevenueChart: FC = () => {
const [selectedTab] = useState<"daily" | "monthly" | "yearly">("monthly"); const [selectedTab] = useState<"daily" | "monthly" | "yearly">("daily");
const [startDate, setStartDate] = useState<string>(""); const [startDate, setStartDate] = useState<string>("");
const [endDate, setEndDate] = useState<string>(""); const [endDate, setEndDate] = useState<string>("");
@@ -33,38 +67,20 @@ const RevenueChart: FC = () => {
const { data: paymentsChartData, isLoading } = useGetPaymentsChart(apiParams); const { data: paymentsChartData, isLoading } = useGetPaymentsChart(apiParams);
const formatDateLabel = (dateString: string, period: "daily" | "monthly" | "yearly"): string => {
const date = new Date(dateString);
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
};
if (period === "monthly") {
options.month = "long";
} else if (period === "daily") {
options.month = "short";
options.day = "numeric";
}
const persianDate = new Intl.DateTimeFormat("fa-IR", options).format(date);
return persianDate;
};
const chartData = useMemo(() => { const chartData = useMemo(() => {
if (!paymentsChartData?.data) return []; if (!paymentsChartData?.data) return [];
return paymentsChartData.data.map((item) => ({ return [...paymentsChartData.data]
month: formatDateLabel(item.date, selectedTab), .sort((a, b) => moment(a.date, "YYYY-MM-DD").valueOf() - moment(b.date, "YYYY-MM-DD").valueOf())
online: item.online, .map((item) => ({
cash: item.cash, date: item.date,
})); label: formatJalaliAxisLabel(item.date, selectedTab),
tooltipLabel: formatJalaliFullDate(item.date),
online: item.online,
cash: item.cash,
}));
}, [paymentsChartData?.data, selectedTab]); }, [paymentsChartData?.data, selectedTab]);
const xAxisInterval = useMemo(() => { const chartWidth = useMemo(() => Math.max(chartData.length * 48, 640), [chartData.length]);
const dataLength = chartData.length;
if (dataLength <= 7) return 0;
if (dataLength <= 15) return 1;
if (dataLength <= 30) return 2;
if (dataLength <= 60) return 4;
return Math.floor(dataLength / 12);
}, [chartData.length]);
const totalRevenue = useMemo(() => { const totalRevenue = useMemo(() => {
return chartData.reduce((sum, item) => sum + item.online + item.cash, 0); return chartData.reduce((sum, item) => sum + item.online + item.cash, 0);
@@ -94,49 +110,16 @@ const RevenueChart: FC = () => {
const CustomTooltip = (props: { const CustomTooltip = (props: {
active?: boolean; active?: boolean;
payload?: Array<{ payload?: Array<{
dataKey?: string;
value?: number;
color?: string;
payload?: { payload?: {
cash?: number; cash: number;
online?: number; online: number;
month?: string; tooltipLabel: string;
}; };
}>; }>;
label?: string;
}) => { }) => {
const { active, payload, label } = props; const { active, payload } = props;
if (!active || !payload || payload.length === 0 || !label) { const dataPoint = payload?.[0]?.payload;
return null; if (!active || !dataPoint) {
}
// استفاده از paymentsChartData.data مستقیم برای دریافت داده‌های واقعی
// پیدا کردن آخرین آیتمی که label آن با formatDateLabel match می‌کند
let dataPoint: { cash: number; online: number } | null = null;
if (paymentsChartData?.data) {
// پیدا کردن همه آیتم‌های match شده و استفاده از آخرین
const matchingItems: Array<{ cash: number; online: number }> = [];
for (const item of paymentsChartData.data) {
const formattedLabel = formatDateLabel(item.date, selectedTab);
if (formattedLabel === label) {
matchingItems.push({ cash: item.cash, online: item.online });
}
}
if (matchingItems.length > 0) {
dataPoint = matchingItems[matchingItems.length - 1];
}
}
// اگر پیدا نکردیم، از chartData استفاده می‌کنیم
if (!dataPoint) {
const matchingItems = chartData.filter((item) => item.month === label);
if (matchingItems.length > 0) {
dataPoint = matchingItems[matchingItems.length - 1];
}
}
if (!dataPoint) {
return null; return null;
} }
@@ -145,7 +128,7 @@ const RevenueChart: FC = () => {
return ( return (
<div className="bg-white p-3 rounded-xl shadow-lg border border-gray-200"> <div className="bg-white p-3 rounded-xl shadow-lg border border-gray-200">
<p className="text-sm font-medium text-gray-900 mb-2">{label}</p> <p className="text-sm font-medium text-gray-900 mb-2">{dataPoint.tooltipLabel}</p>
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[#B0BAC9]" /> <div className="w-2 h-2 rounded-full bg-[#B0BAC9]" />
@@ -248,38 +231,26 @@ const RevenueChart: FC = () => {
) : chartData.length === 0 ? ( ) : chartData.length === 0 ? (
<div className="w-full h-[400px] min-h-[400px] flex items-center justify-center text-gray-500">دادهای برای نمایش وجود ندارد</div> <div className="w-full h-[400px] min-h-[400px] flex items-center justify-center text-gray-500">دادهای برای نمایش وجود ندارد</div>
) : ( ) : (
<div className="w-full h-[400px] min-h-[400px]"> <div className="w-full overflow-x-auto pb-2">
<ResponsiveContainer width="100%" height="100%"> <div style={{ width: chartWidth, height: 420 }}>
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 10, bottom: 40 }}> <LineChart data={chartData} width={chartWidth} height={420} margin={{ top: 10, right: 10, left: 10, bottom: 72 }}>
<defs>
<linearGradient id="colorOnline" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6B7FED" stopOpacity={0.3} />
<stop offset="95%" stopColor="#6B7FED" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorCash" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#B0BAC9" stopOpacity={0.2} />
<stop offset="95%" stopColor="#B0BAC9" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="0" stroke="#f0f0f0" vertical={false} /> <CartesianGrid strokeDasharray="0" stroke="#f0f0f0" vertical={false} />
<XAxis <XAxis
dataKey="month" dataKey="label"
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
tick={{ fill: "#9CA3AF", fontSize: 11 }} tick={{ fill: "#9CA3AF", fontSize: 10 }}
reversed interval={0}
interval={xAxisInterval} angle={-45}
angle={chartData.length > 15 ? -45 : 0} textAnchor="end"
textAnchor={chartData.length > 15 ? "end" : "middle"} height={72}
height={chartData.length > 15 ? 60 : 40}
minTickGap={10}
/> />
<YAxis orientation="right" axisLine={false} tickLine={false} tick={{ fill: "#9CA3AF", fontSize: 10 }} tickFormatter={formatYAxis} domain={[0, "dataMax + 2000000"]} tickMargin={50} /> <YAxis orientation="right" axisLine={false} tickLine={false} tick={{ fill: "#9CA3AF", fontSize: 10 }} tickFormatter={formatYAxis} domain={[0, "dataMax + 2000000"]} tickMargin={50} />
<Tooltip content={<CustomTooltip />} /> <Tooltip content={<CustomTooltip />} cursor={{ stroke: "#E5E7EB", strokeWidth: 1 }} />
<Area type="natural" dataKey="cash" stroke="#B0BAC9" strokeWidth={2} fillOpacity={1} fill="url(#colorCash)" /> <Line type="monotone" dataKey="cash" name="پرداخت نقدی" stroke="#B0BAC9" strokeWidth={2} dot={{ r: 3, fill: "#B0BAC9", strokeWidth: 0 }} activeDot={{ r: 5 }} />
<Area type="natural" dataKey="online" stroke="#6B7FED" strokeWidth={2} fillOpacity={1} fill="url(#colorOnline)" /> <Line type="monotone" dataKey="online" name="پرداخت آنلاین" stroke="#6B7FED" strokeWidth={2} dot={{ r: 3, fill: "#6B7FED", strokeWidth: 0 }} activeDot={{ r: 5 }} />
</AreaChart> </LineChart>
</ResponsiveContainer> </div>
</div> </div>
)} )}
</div> </div>