Compare commits
47 Commits
4fc7d19766
...
production
| Author | SHA1 | Date | |
|---|---|---|---|
| e283d4a5c4 | |||
| f30ee4f6e3 | |||
| b9fc6ab247 | |||
| 7ad840fbda | |||
| 6dea561b05 | |||
| b73f00299b | |||
| a6593ed0e8 | |||
| 9f8877daf9 | |||
| 4f2bf864e4 | |||
| ee1655493c | |||
| 66992dd2c3 | |||
| c0fa388ac3 | |||
| 366d9faff3 | |||
| 985a29b50f | |||
| b979bb610a | |||
| 06873bae01 | |||
| 3e17cf9e12 | |||
| 7913c43bc0 | |||
| 331b5fe02d | |||
| dcc071d357 | |||
| 7918547137 | |||
| 8b9ca1707c | |||
| fa83d7d092 | |||
| 9aed33d73b | |||
| 435fe3e947 | |||
| 0b3b7218b9 | |||
| 57284c0641 | |||
| 9f18e33ef6 | |||
| 6a7d5523a7 | |||
| 6a6aa76b1e | |||
| 23f47705bb | |||
| 95814d6b00 | |||
| a4eaa50fdf | |||
| 457361e759 | |||
| eb9973678c | |||
| bcf03887f1 | |||
| 6187e7c26d | |||
| 2d35a428b1 | |||
| aac1a4f81b | |||
| 282e001933 | |||
| 8ec3f307b0 | |||
| 4c240ac9a1 | |||
| d52ff4f07e | |||
| c7b4e77421 | |||
| 3c6d3f009a | |||
| 0539d4bc48 | |||
| e6b3610bfc |
@@ -4,4 +4,5 @@ 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'
|
||||||
|
VITE_INVOICE_URL = 'https://console.danakcorp.com/receipts/'
|
||||||
+2
-2
@@ -66,13 +66,13 @@ server {
|
|||||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/(foods|orders|customers|schedule|payment_methods|discounts|announcements|reports|comments|roles|admins|shipment_methods|coupons|reviews|pagers|notifications)/ {
|
location ~ ^/(foods|orders|customers|schedule|payment_methods|discounts|announcements|reports|comments|roles|admins|shipment_methods|coupons|reviews|pagers|notifications|wallet)/ {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||||
}
|
}
|
||||||
|
|
||||||
# Restaurant menu preview (same-origin iframe; strips X-Frame-Options from upstream)
|
# Restaurant menu preview (same-origin iframe; strips X-Frame-Options from upstream)
|
||||||
location ~ ^/(?!auth|dashboard|setting|statistics|foods|orders|customers|schedule|payment_methods|discounts|announcements|reports|comments|roles|admins|shipment_methods|coupons|reviews|pagers|notifications|assets|icons|_next)([a-zA-Z0-9][a-zA-Z0-9_-]*)(/.*)?$ {
|
location ~ ^/(?!auth|dashboard|setting|statistics|foods|orders|customers|schedule|payment_methods|discounts|announcements|reports|comments|roles|admins|shipment_methods|coupons|reviews|pagers|notifications|wallet|assets|icons|_next)([a-zA-Z0-9][a-zA-Z0-9_-]*)(/.*)?$ {
|
||||||
proxy_pass https://dmenu.danakcorp.com/$1$2$is_args$args;
|
proxy_pass https://dmenu.danakcorp.com/$1$2$is_args$args;
|
||||||
proxy_ssl_server_name on;
|
proxy_ssl_server_name on;
|
||||||
proxy_set_header Host dmenu.danakcorp.com;
|
proxy_set_header Host dmenu.danakcorp.com;
|
||||||
|
|||||||
Generated
+21
@@ -20,6 +20,7 @@
|
|||||||
"iconsax-react": "^0.0.8",
|
"iconsax-react": "^0.0.8",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"moment-jalaali": "^0.10.4",
|
"moment-jalaali": "^0.10.4",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"rc-rate": "^2.13.1",
|
"rc-rate": "^2.13.1",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
@@ -46,6 +47,7 @@
|
|||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/moment-jalaali": "^0.7.9",
|
"@types/moment-jalaali": "^0.7.9",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/qrcode.react": "^1.0.5",
|
||||||
"@types/react": "^19.1.10",
|
"@types/react": "^19.1.10",
|
||||||
"@types/react-dom": "^19.1.7",
|
"@types/react-dom": "^19.1.7",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
@@ -2383,6 +2385,16 @@
|
|||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/qrcode.react": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qrcode.react/-/qrcode.react-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-BghPtnlwvrvq8QkGa1H25YnN+5OIgCKFuQruncGWLGJYOzeSKiix/4+B9BtfKF2wf5ja8yfyWYA3OXju995G8w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.1.11",
|
"version": "19.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.11.tgz",
|
||||||
@@ -4987,6 +4999,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode.react": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"iconsax-react": "^0.0.8",
|
"iconsax-react": "^0.0.8",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"moment-jalaali": "^0.10.4",
|
"moment-jalaali": "^0.10.4",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"rc-rate": "^2.13.1",
|
"rc-rate": "^2.13.1",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/moment-jalaali": "^0.7.9",
|
"@types/moment-jalaali": "^0.7.9",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/qrcode.react": "^1.0.5",
|
||||||
"@types/react": "^19.1.10",
|
"@types/react": "^19.1.10",
|
||||||
"@types/react-dom": "^19.1.7",
|
"@types/react-dom": "^19.1.7",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
|
|||||||
Binary file not shown.
@@ -1,11 +1,13 @@
|
|||||||
import { getToken } from '@/config/func'
|
import { getToken } from '@/config/func'
|
||||||
import { useSocket } from '@/pages/pager/hooks/useSocket'
|
import { useSocket } from '@/pages/pager/hooks/useSocket'
|
||||||
import { type FC, useEffect, useCallback, useState } from 'react'
|
import { type FC, useEffect, useCallback, useState, useRef } from 'react'
|
||||||
import type { NotificationPayload } from '@/types/notification.types'
|
import type { NotificationPayload } from '@/types/notification.types'
|
||||||
import { NotificationSubject } from '@/types/notification.types'
|
import { NotificationSubject } from '@/types/notification.types'
|
||||||
import NotificationItem from '@/components/NotificationItem'
|
import NotificationItem from '@/components/NotificationItem'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
const NOTIFICATION_SOUND_SRC = '/Qoddusi_improved.mp3'
|
||||||
|
|
||||||
interface NotificationItemData extends NotificationPayload {
|
interface NotificationItemData extends NotificationPayload {
|
||||||
id: string
|
id: string
|
||||||
timestamp: number
|
timestamp: number
|
||||||
@@ -13,13 +15,26 @@ interface NotificationItemData extends NotificationPayload {
|
|||||||
|
|
||||||
const Notification: FC = () => {
|
const Notification: FC = () => {
|
||||||
const [notifications, setNotifications] = useState<NotificationItemData[]>([])
|
const [notifications, setNotifications] = useState<NotificationItemData[]>([])
|
||||||
const [audioEnabled, setAudioEnabled] = useState(false)
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
const audioUnlockedRef = useRef(false)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
const socketUrl = `${import.meta.env.VITE_SOCKET_URL}/notifications`
|
const socketUrl = `${import.meta.env.VITE_SOCKET_URL}/notifications`
|
||||||
const { connect, socket, error } = useSocket(socketUrl, token!)
|
const { connect, socket, error } = useSocket(socketUrl, token!)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = new Audio(NOTIFICATION_SOUND_SRC)
|
||||||
|
audio.preload = 'auto'
|
||||||
|
audio.volume = 0.5
|
||||||
|
audioRef.current = audio
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.pause()
|
||||||
|
audioRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('❌ Notification: خطا در اتصال:', error)
|
console.error('❌ Notification: خطا در اتصال:', error)
|
||||||
@@ -31,35 +46,54 @@ const Notification: FC = () => {
|
|||||||
}, [connect])
|
}, [connect])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const enableAudio = async () => {
|
const unlockAudio = async () => {
|
||||||
|
if (audioUnlockedRef.current || !audioRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const audio = new Audio('/mixkit-correct-answer-tone-2870.wav')
|
const audio = audioRef.current
|
||||||
audio.volume = 0
|
audio.muted = true
|
||||||
await audio.play()
|
await audio.play()
|
||||||
audio.pause()
|
audio.pause()
|
||||||
audio.currentTime = 0
|
audio.currentTime = 0
|
||||||
setAudioEnabled(true)
|
audio.muted = false
|
||||||
|
audioUnlockedRef.current = true
|
||||||
} catch {
|
} catch {
|
||||||
console.warn('صدا هنوز فعال نشده است')
|
// Browser may still block until a later gesture — keep listening.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUserInteraction = () => {
|
const handleUserInteraction = () => {
|
||||||
if (!audioEnabled) {
|
void unlockAudio()
|
||||||
enableAudio()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('click', handleUserInteraction, { once: true })
|
window.addEventListener('click', handleUserInteraction)
|
||||||
window.addEventListener('touchstart', handleUserInteraction, { once: true })
|
window.addEventListener('touchstart', handleUserInteraction)
|
||||||
window.addEventListener('keydown', handleUserInteraction, { once: true })
|
window.addEventListener('keydown', handleUserInteraction)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('click', handleUserInteraction)
|
window.removeEventListener('click', handleUserInteraction)
|
||||||
window.removeEventListener('touchstart', handleUserInteraction)
|
window.removeEventListener('touchstart', handleUserInteraction)
|
||||||
window.removeEventListener('keydown', handleUserInteraction)
|
window.removeEventListener('keydown', handleUserInteraction)
|
||||||
}
|
}
|
||||||
}, [audioEnabled])
|
}, [])
|
||||||
|
|
||||||
|
const playNotificationSound = useCallback(() => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio || !audioUnlockedRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
audio.currentTime = 0
|
||||||
|
void audio.play().catch((err) => {
|
||||||
|
console.warn('خطا در پخش صدا:', err)
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('خطا در پخش صدا:', err)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleGetNotification = useCallback((data: NotificationPayload) => {
|
const handleGetNotification = useCallback((data: NotificationPayload) => {
|
||||||
const newNotification: NotificationItemData = {
|
const newNotification: NotificationItemData = {
|
||||||
@@ -68,14 +102,14 @@ const Notification: FC = () => {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
}
|
}
|
||||||
setNotifications((prev) => [...prev, newNotification])
|
setNotifications((prev) => [...prev, newNotification])
|
||||||
|
playNotificationSound()
|
||||||
|
|
||||||
// Refetch بر اساس نوع نوتیفیکیشن
|
|
||||||
if (data.subject === NotificationSubject.PAGER_CREATED) {
|
if (data.subject === NotificationSubject.PAGER_CREATED) {
|
||||||
queryClient.invalidateQueries({ queryKey: ['pagers'] })
|
queryClient.invalidateQueries({ queryKey: ['pagers'] })
|
||||||
} else if (data.subject === NotificationSubject.ORDER_CREATED) {
|
} else if (data.subject === NotificationSubject.ORDER_CREATED) {
|
||||||
queryClient.invalidateQueries({ queryKey: ['orders'] })
|
queryClient.invalidateQueries({ queryKey: ['orders'] })
|
||||||
}
|
}
|
||||||
}, [queryClient])
|
}, [playNotificationSound, queryClient])
|
||||||
|
|
||||||
const handleRemoveNotification = useCallback((id: string) => {
|
const handleRemoveNotification = useCallback((id: string) => {
|
||||||
setNotifications((prev) => prev.filter((notif) => notif.id !== id))
|
setNotifications((prev) => prev.filter((notif) => notif.id !== id))
|
||||||
@@ -105,7 +139,6 @@ const Notification: FC = () => {
|
|||||||
subject={notification.subject}
|
subject={notification.subject}
|
||||||
body={notification.body}
|
body={notification.body}
|
||||||
onRemove={handleRemoveNotification}
|
onRemove={handleRemoveNotification}
|
||||||
audioEnabled={audioEnabled}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type FC, useEffect } from 'react'
|
import { type FC } from 'react'
|
||||||
import { CloseCircle, NotificationBing, ShoppingBag, MessageText1 } from 'iconsax-react'
|
import { CloseCircle, NotificationBing, ShoppingBag, MessageText1 } from 'iconsax-react'
|
||||||
import Button from '@/components/Button'
|
import Button from '@/components/Button'
|
||||||
import { NotificationSubject } from '@/types/notification.types'
|
import { NotificationSubject } from '@/types/notification.types'
|
||||||
@@ -8,32 +8,9 @@ interface NotificationItemProps {
|
|||||||
subject: string
|
subject: string
|
||||||
body: string
|
body: string
|
||||||
onRemove: (id: string) => void
|
onRemove: (id: string) => void
|
||||||
audioEnabled: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const NotificationItem: FC<NotificationItemProps> = ({ id, subject, body, onRemove, audioEnabled }) => {
|
const NotificationItem: FC<NotificationItemProps> = ({ id, subject, body, onRemove }) => {
|
||||||
useEffect(() => {
|
|
||||||
if (!audioEnabled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const playNotificationSound = async () => {
|
|
||||||
try {
|
|
||||||
const audio = new Audio('/mixkit-correct-answer-tone-2870.wav')
|
|
||||||
audio.volume = 0.5
|
|
||||||
|
|
||||||
audio.addEventListener('error', (err) => {
|
|
||||||
console.error('❌ خطا در لود فایل صوتی:', err)
|
|
||||||
}, { once: true })
|
|
||||||
|
|
||||||
await audio.play()
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('خطا در پخش صدا:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
playNotificationSound()
|
|
||||||
}, [audioEnabled])
|
|
||||||
const getNotificationConfig = (subjectType: string) => {
|
const getNotificationConfig = (subjectType: string) => {
|
||||||
switch (subjectType) {
|
switch (subjectType) {
|
||||||
case NotificationSubject.PAGER_CREATED:
|
case NotificationSubject.PAGER_CREATED:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ type Props = {
|
|||||||
|
|
||||||
const RadioGroup: FC<Props> = (props: Props) => {
|
const RadioGroup: FC<Props> = (props: Props) => {
|
||||||
return (
|
return (
|
||||||
<div className='flex gap-5 items-center text-xs'>
|
<div className='flex flex-wrap gap-5 items-center text-xs'>
|
||||||
{
|
{
|
||||||
props.items.map((item, index) => (
|
props.items.map((item, index) => (
|
||||||
<div key={index} className='flex gap-2 items-center'>
|
<div key={index} className='flex gap-2 items-center'>
|
||||||
|
|||||||
+11
-13
@@ -1,4 +1,4 @@
|
|||||||
import { type FC, type InputHTMLAttributes } from 'react'
|
import { type FC, type TextareaHTMLAttributes } from 'react'
|
||||||
import Error from './Error'
|
import Error from './Error'
|
||||||
import { clx } from '../helpers/utils'
|
import { clx } from '../helpers/utils'
|
||||||
|
|
||||||
@@ -6,34 +6,32 @@ type Props = {
|
|||||||
label?: string,
|
label?: string,
|
||||||
error_text?: string
|
error_text?: string
|
||||||
isNotRequired?: boolean
|
isNotRequired?: boolean
|
||||||
} & InputHTMLAttributes<HTMLTextAreaElement>
|
} & TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||||
|
|
||||||
const Textarea: FC<Props> = (props: Props) => {
|
const Textarea: FC<Props> = ({ label, error_text, isNotRequired, className, ...textareaProps }) => {
|
||||||
return (
|
return (
|
||||||
<div className='w-full relative'>
|
<div className='w-full relative'>
|
||||||
{
|
{
|
||||||
props.label &&
|
label &&
|
||||||
<div className='flex items-center gap-1'>
|
<div className='flex items-center gap-1'>
|
||||||
<div className='text-sm'>{props.label}</div>
|
<div className='text-sm'>{label}</div>
|
||||||
{props.isNotRequired && (
|
{isNotRequired && (
|
||||||
<span className='text-xs text-description'>(اختیاری)</span>
|
<span className='text-xs text-description'>(اختیاری)</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
{...props}
|
{...textareaProps}
|
||||||
className={clx(
|
className={clx(
|
||||||
'border border-border leading-7 w-full rounded-xl px-4 py-3 min-h-[100px] mt-1 text-xs',
|
'border border-border leading-7 w-full rounded-xl px-4 py-3 min-h-[100px] mt-1 text-xs',
|
||||||
props.className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
/>
|
||||||
|
|
||||||
</textarea>
|
|
||||||
|
|
||||||
{
|
{
|
||||||
props.error_text &&
|
error_text &&
|
||||||
<Error errorText={props.error_text} />
|
<Error errorText={error_text} />
|
||||||
}
|
}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,12 +27,18 @@ export const Pages = {
|
|||||||
detail: "/orders/detail/",
|
detail: "/orders/detail/",
|
||||||
foodReport: "/orders/report/by-food",
|
foodReport: "/orders/report/by-food",
|
||||||
dailyReport: "/orders/report/by-day",
|
dailyReport: "/orders/report/by-day",
|
||||||
|
cashShifts: "/orders/cash-shifts",
|
||||||
},
|
},
|
||||||
customers: {
|
customers: {
|
||||||
list: "/customers/list",
|
list: "/customers/list",
|
||||||
add: "/customers/add",
|
add: "/customers/add",
|
||||||
detail: "/customers/detail/",
|
detail: "/customers/detail/",
|
||||||
update: "/customers/update/",
|
update: "/customers/update/",
|
||||||
|
groups: "/customers/groups",
|
||||||
|
groupsAdd: "/customers/groups/add",
|
||||||
|
groupsUpdate: "/customers/groups/update/",
|
||||||
|
campaigns: "/customers/campaigns",
|
||||||
|
campaignsAdd: "/customers/campaigns/add",
|
||||||
},
|
},
|
||||||
discount: {
|
discount: {
|
||||||
list: "/discounts/list",
|
list: "/discounts/list",
|
||||||
@@ -83,7 +89,18 @@ export const Pages = {
|
|||||||
notifications: {
|
notifications: {
|
||||||
list: "/notifications/list",
|
list: "/notifications/list",
|
||||||
},
|
},
|
||||||
|
sliders: {
|
||||||
|
list: "/sliders/list",
|
||||||
|
add: "/sliders/create",
|
||||||
|
update: "/sliders/update/",
|
||||||
|
},
|
||||||
statistics: {
|
statistics: {
|
||||||
list: "/statistics",
|
list: "/statistics",
|
||||||
},
|
},
|
||||||
|
wallet: {
|
||||||
|
transactions: "/wallet/transactions",
|
||||||
|
},
|
||||||
|
aiChats: {
|
||||||
|
list: "/ai-chats/list",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export const MENU_PREVIEW_RESERVED_SEGMENTS = new Set([
|
|||||||
"reviews",
|
"reviews",
|
||||||
"pagers",
|
"pagers",
|
||||||
"notifications",
|
"notifications",
|
||||||
|
"sliders",
|
||||||
|
"wallet",
|
||||||
"assets",
|
"assets",
|
||||||
"menu-preview",
|
"menu-preview",
|
||||||
]);
|
]);
|
||||||
|
|||||||
+65
-6
@@ -68,7 +68,9 @@
|
|||||||
"reviews": "نظرات",
|
"reviews": "نظرات",
|
||||||
"pagers": "پیجرها",
|
"pagers": "پیجرها",
|
||||||
"notifications": "تنظیمات اعلانات",
|
"notifications": "تنظیمات اعلانات",
|
||||||
"statistics": "گزارشات"
|
"statistics": "گزارشات",
|
||||||
|
"sliders": "اسلایدرها",
|
||||||
|
"ai_chats": "چتهای AI"
|
||||||
},
|
},
|
||||||
"shipment": {
|
"shipment": {
|
||||||
"dineIn": "سرو در رستوران",
|
"dineIn": "سرو در رستوران",
|
||||||
@@ -99,11 +101,14 @@
|
|||||||
"orders_list": "لیست سفارشات",
|
"orders_list": "لیست سفارشات",
|
||||||
"orders_food_report": "گزارش فروش به تفکیک غذا",
|
"orders_food_report": "گزارش فروش به تفکیک غذا",
|
||||||
"orders_daily_report": "گزارش فروش روزانه",
|
"orders_daily_report": "گزارش فروش روزانه",
|
||||||
|
"cash_shifts": "شیفتهای صندوق",
|
||||||
"add_order": "افزودن سفارش",
|
"add_order": "افزودن سفارش",
|
||||||
"discount_list": "لیست تخفیف",
|
"discount_list": "لیست تخفیف",
|
||||||
"add_discount": "افزودن تخفیف",
|
"add_discount": "افزودن تخفیف",
|
||||||
"customers_list": "لیست مشتریان",
|
"customers_list": "لیست مشتریان",
|
||||||
"add_customer": "افزودن مشتری"
|
"add_customer": "افزودن مشتری",
|
||||||
|
"groups": "گروه مشتری",
|
||||||
|
"campaigns": "کمپینها"
|
||||||
},
|
},
|
||||||
"guide": {
|
"guide": {
|
||||||
"list_guide": "لیست راهنمای سرویس",
|
"list_guide": "لیست راهنمای سرویس",
|
||||||
@@ -608,6 +613,30 @@
|
|||||||
"exsist_special_character": "شامل کاراکتر های خاص (نماد ها) باشد"
|
"exsist_special_character": "شامل کاراکتر های خاص (نماد ها) باشد"
|
||||||
},
|
},
|
||||||
"wallet": {
|
"wallet": {
|
||||||
|
"charge": "شارژ",
|
||||||
|
"charge_title": "شارژ کیف پول",
|
||||||
|
"amount": "مبلغ",
|
||||||
|
"amount_placeholder": "مبلغ را وارد کنید",
|
||||||
|
"amount_required": "مبلغ را وارد کنید",
|
||||||
|
"charge_success": "درخواست شارژ با موفقیت ثبت شد",
|
||||||
|
"invoice_issued": "صورتحساب صادر شد",
|
||||||
|
"cancel": "انصراف",
|
||||||
|
"balance": "موجودی کیف پول",
|
||||||
|
"balance_after": "موجودی پس از تراکنش",
|
||||||
|
"type_credit": "واریز",
|
||||||
|
"type_debit": "برداشت",
|
||||||
|
"reason": "دلیل",
|
||||||
|
"reason_deposit": "شارژ کیف پول",
|
||||||
|
"reason_sms_send": "ارسال پیامک",
|
||||||
|
"transactions_tab": "تراکنشها",
|
||||||
|
"charge_requests_tab": "درخواستهای شارژ",
|
||||||
|
"invoice": "صورتحساب",
|
||||||
|
"view_invoice": "مشاهده صورتحساب",
|
||||||
|
"charge_status_pending": "در انتظار پرداخت",
|
||||||
|
"charge_status_paid": "پرداخت شده",
|
||||||
|
"charge_status_success": "پرداخت موفق",
|
||||||
|
"charge_status_failed": "ناموفق",
|
||||||
|
"charge_status_cancelled": "لغو شده",
|
||||||
"increese_wallet": "افزایش موجودی حساب اعتباری",
|
"increese_wallet": "افزایش موجودی حساب اعتباری",
|
||||||
"online_pay": "پرداخت آنلاین",
|
"online_pay": "پرداخت آنلاین",
|
||||||
"card_to_card": "کارت به کارت",
|
"card_to_card": "کارت به کارت",
|
||||||
@@ -616,7 +645,6 @@
|
|||||||
"enter_your_price": "مبلغ دلخواه خودرا وارد کنید",
|
"enter_your_price": "مبلغ دلخواه خودرا وارد کنید",
|
||||||
"select_payment": "انتخاب درگاه پرداخت",
|
"select_payment": "انتخاب درگاه پرداخت",
|
||||||
"pay": "پرداخت",
|
"pay": "پرداخت",
|
||||||
"amount": "مبلغ",
|
|
||||||
"enter_amount_card": "مبلغ کارت به کارت شده را وارد کنید",
|
"enter_amount_card": "مبلغ کارت به کارت شده را وارد کنید",
|
||||||
"upload_deposit_slip": "آپلود فیش واریزی",
|
"upload_deposit_slip": "آپلود فیش واریزی",
|
||||||
"submit_slip": "ثبت فیش",
|
"submit_slip": "ثبت فیش",
|
||||||
@@ -863,11 +891,9 @@
|
|||||||
"payment_methods_update": "ویرایش روش پرداخت"
|
"payment_methods_update": "ویرایش روش پرداخت"
|
||||||
},
|
},
|
||||||
"order_status": {
|
"order_status": {
|
||||||
"pendingPayment": "در انتظار پرداخت",
|
"new": "جدید",
|
||||||
"paid": "پرداخت شده",
|
|
||||||
"confirmed": "تایید شده",
|
"confirmed": "تایید شده",
|
||||||
"preparing": "در حال آماده سازی",
|
"preparing": "در حال آماده سازی",
|
||||||
"ready": "آماده",
|
|
||||||
"shipped": "ارسال شده",
|
"shipped": "ارسال شده",
|
||||||
"completed": "تکمیل شده",
|
"completed": "تکمیل شده",
|
||||||
"canceled": "لغو شده",
|
"canceled": "لغو شده",
|
||||||
@@ -903,5 +929,38 @@
|
|||||||
},
|
},
|
||||||
"change_status": "تغییر وضعیت",
|
"change_status": "تغییر وضعیت",
|
||||||
"save": "ذخیره"
|
"save": "ذخیره"
|
||||||
|
},
|
||||||
|
"cash_shift": {
|
||||||
|
"list_title": "شیفتهای صندوق",
|
||||||
|
"current_shift": "شیفت فعال",
|
||||||
|
"open_shift": "باز کردن شیفت",
|
||||||
|
"close_shift": "بستن شیفت",
|
||||||
|
"open_title": "باز کردن شیفت صندوق",
|
||||||
|
"close_title": "بستن شیفت صندوق",
|
||||||
|
"detail_title": "جزئیات شیفت صندوق",
|
||||||
|
"summary_title": "خلاصه شیفت",
|
||||||
|
"operator": "اپراتور",
|
||||||
|
"status_label": "وضعیت",
|
||||||
|
"opened_at": "شروع شیفت",
|
||||||
|
"closed_at": "پایان شیفت",
|
||||||
|
"opening_amount": "موجودی اولیه",
|
||||||
|
"opening_amount_placeholder": "مبلغ موجودی اولیه صندوق",
|
||||||
|
"opening_amount_required": "موجودی اولیه الزامی است",
|
||||||
|
"expected_amount": "مبلغ مورد انتظار",
|
||||||
|
"counted_amount": "مبلغ شمارششده",
|
||||||
|
"counted_amount_placeholder": "مبلغ شمارششده در صندوق",
|
||||||
|
"counted_amount_required": "مبلغ شمارششده الزامی است",
|
||||||
|
"difference_amount": "اختلاف",
|
||||||
|
"orders_count": "تعداد سفارشات",
|
||||||
|
"notes": "یادداشت",
|
||||||
|
"notes_placeholder": "یادداشت (اختیاری)",
|
||||||
|
"cancel": "انصراف",
|
||||||
|
"view_details": "مشاهده جزئیات",
|
||||||
|
"opened_success": "شیفت صندوق با موفقیت باز شد",
|
||||||
|
"closed_success": "شیفت صندوق با موفقیت بسته شد",
|
||||||
|
"status_values": {
|
||||||
|
"open": "باز",
|
||||||
|
"closed": "بسته"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { type FC, useMemo, useState } from "react";
|
||||||
|
import Table from "@/components/Table";
|
||||||
|
import Filters from "@/components/Filters";
|
||||||
|
import DefaulModal from "@/components/DefaulModal";
|
||||||
|
import { useGetAiChats } from "./hooks/useAiChatData";
|
||||||
|
import { useAiChatFilters } from "./hooks/useAiChatFilters";
|
||||||
|
import { getAiChatTableColumns } from "./components/AiChatTableColumns";
|
||||||
|
import { useAiChatFiltersFields } from "./components/AiChatFiltersFields";
|
||||||
|
import {
|
||||||
|
getAiChatAnswer,
|
||||||
|
getAiChatQuestion,
|
||||||
|
type AiChat,
|
||||||
|
} from "./types/Types";
|
||||||
|
import type { RowDataType } from "@/components/types/TableTypes";
|
||||||
|
import { formatPrice } from "@/helpers/func";
|
||||||
|
|
||||||
|
const AiChatsList: FC = () => {
|
||||||
|
const [selectedChat, setSelectedChat] = useState<AiChat | null>(null);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
filters,
|
||||||
|
currentPage,
|
||||||
|
apiParams,
|
||||||
|
handleFiltersChange,
|
||||||
|
handlePageChange,
|
||||||
|
} = useAiChatFilters();
|
||||||
|
|
||||||
|
const { data: chatsData, isLoading } = useGetAiChats(apiParams);
|
||||||
|
|
||||||
|
const chats = chatsData?.data || [];
|
||||||
|
const totalPages = chatsData?.meta?.totalPages || 1;
|
||||||
|
|
||||||
|
const filterFields = useAiChatFiltersFields();
|
||||||
|
const columns = useMemo(
|
||||||
|
() =>
|
||||||
|
getAiChatTableColumns({
|
||||||
|
onViewAnswer: (chat) => {
|
||||||
|
setSelectedChat(chat);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setSelectedChat(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="text-lg font-light">لیست چتهای AI</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<Filters
|
||||||
|
fields={filterFields}
|
||||||
|
onChange={handleFiltersChange}
|
||||||
|
initialValues={filters}
|
||||||
|
searchField="search"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table<AiChat & RowDataType>
|
||||||
|
columns={columns}
|
||||||
|
data={chats as (AiChat & RowDataType)[]}
|
||||||
|
isloading={isLoading}
|
||||||
|
pagination={{
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
onPageChange: handlePageChange,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DefaulModal
|
||||||
|
open={isModalOpen}
|
||||||
|
close={handleCloseModal}
|
||||||
|
isHeader={true}
|
||||||
|
title_header="جزئیات چت"
|
||||||
|
>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-description mb-1">سوال</div>
|
||||||
|
<p className="text-sm text-foreground whitespace-pre-wrap break-words">
|
||||||
|
{(selectedChat && getAiChatQuestion(selectedChat)) || "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-description mb-1">پاسخ</div>
|
||||||
|
<p className="text-sm text-foreground whitespace-pre-wrap break-words">
|
||||||
|
{(selectedChat && getAiChatAnswer(selectedChat)) || "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{typeof selectedChat?.cost === "number" && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-description mb-1">هزینه</div>
|
||||||
|
<p className="text-sm text-foreground">
|
||||||
|
{formatPrice(selectedChat.cost)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DefaulModal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AiChatsList;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import type { FieldType } from "@/components/Filters";
|
||||||
|
|
||||||
|
export const useAiChatFiltersFields = (): FieldType[] => {
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
type: "input",
|
||||||
|
name: "search",
|
||||||
|
placeholder: "جستجو در سوالات",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Eye } from "iconsax-react";
|
||||||
|
import type { ColumnType } from "@/components/types/TableTypes";
|
||||||
|
import {
|
||||||
|
getAiChatAnswer,
|
||||||
|
getAiChatQuestion,
|
||||||
|
getAiChatTokens,
|
||||||
|
type AiChat,
|
||||||
|
} from "../types/Types";
|
||||||
|
import { formatOptionalDate, formatPrice } from "@/helpers/func";
|
||||||
|
|
||||||
|
interface GetAiChatTableColumnsProps {
|
||||||
|
onViewAnswer?: (chat: AiChat) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAiChatTableColumns = (
|
||||||
|
props?: GetAiChatTableColumnsProps
|
||||||
|
): ColumnType<AiChat>[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "question",
|
||||||
|
title: "سوال",
|
||||||
|
render: (item: AiChat) => {
|
||||||
|
const question = getAiChatQuestion(item);
|
||||||
|
return (
|
||||||
|
<div className="max-w-md truncate" title={question}>
|
||||||
|
{question || "-"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "type",
|
||||||
|
title: "نوع",
|
||||||
|
render: (item: AiChat) => {
|
||||||
|
return item.type || "-";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "cost",
|
||||||
|
title: "هزینه مصرفشده",
|
||||||
|
render: (item: AiChat) => {
|
||||||
|
return typeof item.cost === "number" ? formatPrice(item.cost) : "-";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "consumedTokens",
|
||||||
|
title: "توکن مصرفی",
|
||||||
|
render: (item: AiChat) => {
|
||||||
|
const tokens = getAiChatTokens(item);
|
||||||
|
return typeof tokens === "number"
|
||||||
|
? tokens.toLocaleString("fa-IR")
|
||||||
|
: "-";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "createdAt",
|
||||||
|
title: "تاریخ",
|
||||||
|
render: (item: AiChat) => {
|
||||||
|
return formatOptionalDate(item.createdAt, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "details",
|
||||||
|
title: "",
|
||||||
|
render: (item: AiChat) => {
|
||||||
|
if (!getAiChatAnswer(item) || !props?.onViewAnswer) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Eye
|
||||||
|
size={20}
|
||||||
|
color="#8C90A3"
|
||||||
|
className="cursor-pointer hover:opacity-70 transition-opacity"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
props.onViewAnswer?.(item);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import * as api from "../service/AiChatService";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { GetAiChatsParams } from "../types/Types";
|
||||||
|
|
||||||
|
export const useGetAiChats = (params?: GetAiChatsParams) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["ai-chats", params],
|
||||||
|
queryFn: () => api.getAiChats(params),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import type { FilterValues } from "@/components/Filters";
|
||||||
|
import type { GetAiChatsParams } from "../types/Types";
|
||||||
|
|
||||||
|
const DEFAULT_PAGE = 1;
|
||||||
|
const DEFAULT_LIMIT = 10;
|
||||||
|
|
||||||
|
export const useAiChatFilters = () => {
|
||||||
|
const [filters, setFilters] = useState<FilterValues>({});
|
||||||
|
const [currentPage, setCurrentPage] = useState(DEFAULT_PAGE);
|
||||||
|
const [limit] = useState(DEFAULT_LIMIT);
|
||||||
|
|
||||||
|
const apiParams = useMemo<GetAiChatsParams>(() => {
|
||||||
|
const params: GetAiChatsParams = {
|
||||||
|
page: currentPage,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (filters.search) {
|
||||||
|
params.search = filters.search as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}, [filters, currentPage, limit]);
|
||||||
|
|
||||||
|
const handleFiltersChange = (newFilters: FilterValues) => {
|
||||||
|
setFilters(newFilters);
|
||||||
|
setCurrentPage(DEFAULT_PAGE);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
filters,
|
||||||
|
currentPage,
|
||||||
|
apiParams,
|
||||||
|
handleFiltersChange,
|
||||||
|
handlePageChange,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import axios from "@/config/axios";
|
||||||
|
import type { GetAiChatsParams, GetAiChatsResponse } from "../types/Types";
|
||||||
|
|
||||||
|
export const getAiChats = async (
|
||||||
|
params?: GetAiChatsParams
|
||||||
|
): Promise<GetAiChatsResponse> => {
|
||||||
|
const { data } = await axios.get<GetAiChatsResponse>("/admin/ai/chats", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { IResponse } from "@/types/response.types";
|
||||||
|
import type { RowDataType } from "@/components/types/TableTypes";
|
||||||
|
|
||||||
|
export interface AiChat extends RowDataType {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
deletedAt?: string | null;
|
||||||
|
question?: string;
|
||||||
|
prompt?: string;
|
||||||
|
message?: string;
|
||||||
|
answer?: string | null;
|
||||||
|
response?: string | null;
|
||||||
|
cost?: number;
|
||||||
|
consumedTokens?: number;
|
||||||
|
totalTokens?: number;
|
||||||
|
promptTokens?: number;
|
||||||
|
completionTokens?: number;
|
||||||
|
type?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaginationMeta = {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalCost?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetAiChatsParams = {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
search?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetAiChatsResponse = IResponse<AiChat[]> & {
|
||||||
|
meta: PaginationMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAiChatQuestion = (chat: AiChat): string =>
|
||||||
|
chat.question || chat.prompt || chat.message || "";
|
||||||
|
|
||||||
|
export const getAiChatAnswer = (chat: AiChat): string =>
|
||||||
|
chat.answer || chat.response || "";
|
||||||
|
|
||||||
|
export const getAiChatTokens = (chat: AiChat): number | undefined =>
|
||||||
|
chat.consumedTokens ?? chat.totalTokens ?? chat.promptTokens;
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { type FC, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Add, Lock1 } from "iconsax-react";
|
||||||
|
import Table from "@/components/Table";
|
||||||
|
import Filters from "@/components/Filters";
|
||||||
|
import Button from "@/components/Button";
|
||||||
|
import type { RowDataType } from "@/components/types/TableTypes";
|
||||||
|
import {
|
||||||
|
useGetCashShifts,
|
||||||
|
useGetCurrentCashShift,
|
||||||
|
useGetCurrentCashShiftSummary,
|
||||||
|
} from "./hooks/useCashShiftData";
|
||||||
|
import { useCashShiftFilters } from "./hooks/useCashShiftFilters";
|
||||||
|
import { getCashShiftTableColumns } from "./components/CashShiftTableColumns";
|
||||||
|
import { useCashShiftFiltersFields } from "./components/CashShiftFiltersFields";
|
||||||
|
import CashShiftSummaryCard from "./components/CashShiftSummaryCard";
|
||||||
|
import OpenCashShiftModal from "./components/OpenCashShiftModal";
|
||||||
|
import CloseCashShiftModal from "./components/CloseCashShiftModal";
|
||||||
|
import CashShiftDetailModal from "./components/CashShiftDetailModal";
|
||||||
|
import type { CashShift } from "./types/Types";
|
||||||
|
import { CashShiftStatus } from "./enum/Enum";
|
||||||
|
import { formatOptionalDate } from "@/helpers/func";
|
||||||
|
import Status from "@/components/Status";
|
||||||
|
import MoonLoader from "react-spinners/MoonLoader";
|
||||||
|
|
||||||
|
const CashShiftsList: FC = () => {
|
||||||
|
const { t } = useTranslation("global");
|
||||||
|
const [isOpenModalVisible, setIsOpenModalVisible] = useState(false);
|
||||||
|
const [isCloseModalVisible, setIsCloseModalVisible] = useState(false);
|
||||||
|
const [isDetailModalVisible, setIsDetailModalVisible] = useState(false);
|
||||||
|
const [selectedShift, setSelectedShift] = useState<CashShift | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
filters,
|
||||||
|
currentPage,
|
||||||
|
apiParams,
|
||||||
|
handleFiltersChange,
|
||||||
|
handlePageChange,
|
||||||
|
} = useCashShiftFilters();
|
||||||
|
|
||||||
|
const { data: shiftsData, isLoading } = useGetCashShifts(apiParams);
|
||||||
|
const { data: currentShiftData, isLoading: isCurrentShiftLoading } =
|
||||||
|
useGetCurrentCashShift();
|
||||||
|
|
||||||
|
const currentShift = currentShiftData?.data ?? null;
|
||||||
|
const hasOpenShift = currentShift?.status === CashShiftStatus.OPEN;
|
||||||
|
|
||||||
|
const { data: currentSummaryData, isLoading: isCurrentSummaryLoading } =
|
||||||
|
useGetCurrentCashShiftSummary(hasOpenShift);
|
||||||
|
|
||||||
|
const shifts = shiftsData?.data || [];
|
||||||
|
const totalPages = shiftsData?.meta?.totalPages || 1;
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() =>
|
||||||
|
getCashShiftTableColumns({
|
||||||
|
t,
|
||||||
|
onView: (shift) => {
|
||||||
|
setSelectedShift(shift);
|
||||||
|
setIsDetailModalVisible(true);
|
||||||
|
},
|
||||||
|
onClose: (shift) => {
|
||||||
|
setSelectedShift(shift);
|
||||||
|
setIsCloseModalVisible(true);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterFields = useCashShiftFiltersFields();
|
||||||
|
|
||||||
|
const getAdminName = (shift: CashShift) => {
|
||||||
|
const name = [shift.admin?.firstName, shift.admin?.lastName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
return name || shift.admin?.phone || "-";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="text-lg font-light">{t("cash_shift.list_title")}</h1>
|
||||||
|
{!hasOpenShift && (
|
||||||
|
<Button className="w-fit px-6" onClick={() => setIsOpenModalVisible(true)}>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Add color="#fff" size={20} />
|
||||||
|
<span>{t("cash_shift.open_shift")}</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCurrentShiftLoading ? (
|
||||||
|
<div className="mt-6 flex items-center gap-2">
|
||||||
|
<MoonLoader size={16} color="#000" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
hasOpenShift &&
|
||||||
|
currentShift && (
|
||||||
|
<div className="mt-6 bg-[#F5F5F5] rounded-2xl p-5 space-y-4">
|
||||||
|
<div className="flex flex-wrap justify-between items-start gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{t("cash_shift.current_shift")}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-description mt-1">
|
||||||
|
{t("cash_shift.operator")}: {getAdminName(currentShift)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-description mt-1">
|
||||||
|
{t("cash_shift.opened_at")}:{" "}
|
||||||
|
{formatOptionalDate(currentShift.openedAt, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Status
|
||||||
|
variant="warning"
|
||||||
|
label={t(`cash_shift.status_values.${CashShiftStatus.OPEN}`)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="w-fit px-5"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedShift(currentShift);
|
||||||
|
setIsCloseModalVisible(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Lock1 color="#fff" size={18} />
|
||||||
|
<span>{t("cash_shift.close_shift")}</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CashShiftSummaryCard
|
||||||
|
summary={currentSummaryData?.data}
|
||||||
|
openingAmount={currentShift.openingAmount}
|
||||||
|
isLoading={isCurrentSummaryLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<Filters
|
||||||
|
fields={filterFields}
|
||||||
|
onChange={handleFiltersChange}
|
||||||
|
initialValues={filters}
|
||||||
|
showClearButton
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table<CashShift & RowDataType>
|
||||||
|
columns={columns}
|
||||||
|
data={shifts as (CashShift & RowDataType)[]}
|
||||||
|
isloading={isLoading}
|
||||||
|
pagination={{
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
onPageChange: handlePageChange,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OpenCashShiftModal
|
||||||
|
open={isOpenModalVisible}
|
||||||
|
onClose={() => setIsOpenModalVisible(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CloseCashShiftModal
|
||||||
|
open={isCloseModalVisible}
|
||||||
|
onClose={() => {
|
||||||
|
setIsCloseModalVisible(false);
|
||||||
|
setSelectedShift(null);
|
||||||
|
}}
|
||||||
|
shift={selectedShift}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CashShiftDetailModal
|
||||||
|
open={isDetailModalVisible}
|
||||||
|
onClose={() => {
|
||||||
|
setIsDetailModalVisible(false);
|
||||||
|
setSelectedShift(null);
|
||||||
|
}}
|
||||||
|
shift={selectedShift}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CashShiftsList;
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { type FC } from "react";
|
||||||
|
import DefaulModal from "@/components/DefaulModal";
|
||||||
|
import Status from "@/components/Status";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
formatFaNumber,
|
||||||
|
formatOptionalDate,
|
||||||
|
formatPrice,
|
||||||
|
} from "@/helpers/func";
|
||||||
|
import { useGetCashShiftSummary } from "../hooks/useCashShiftData";
|
||||||
|
import CashShiftSummaryCard from "./CashShiftSummaryCard";
|
||||||
|
import type { CashShift } from "../types/Types";
|
||||||
|
import { CashShiftStatus } from "../enum/Enum";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
shift: CashShift | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAdminName = (shift: CashShift) => {
|
||||||
|
const name = [shift.admin?.firstName, shift.admin?.lastName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
return name || shift.admin?.phone || "-";
|
||||||
|
};
|
||||||
|
|
||||||
|
const CashShiftDetailModal: FC<Props> = ({ open, onClose, shift }) => {
|
||||||
|
const { t } = useTranslation("global");
|
||||||
|
|
||||||
|
const { data: summaryData, isLoading: isSummaryLoading } =
|
||||||
|
useGetCashShiftSummary(shift?.id ?? "", open && !!shift?.id);
|
||||||
|
|
||||||
|
if (!shift) return null;
|
||||||
|
|
||||||
|
const summary = summaryData?.data;
|
||||||
|
const isOpen = shift.status === CashShiftStatus.OPEN;
|
||||||
|
const statusVariant = isOpen ? "warning" : "success";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaulModal
|
||||||
|
open={open}
|
||||||
|
close={onClose}
|
||||||
|
isHeader
|
||||||
|
title_header={t("cash_shift.detail_title")}
|
||||||
|
width={720}
|
||||||
|
>
|
||||||
|
<div className="mt-6 space-y-6">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 mb-1 block">
|
||||||
|
{t("cash_shift.operator")}
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-medium">{getAdminName(shift)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 mb-1 block">
|
||||||
|
{t("cash_shift.status_label")}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1">
|
||||||
|
<Status
|
||||||
|
variant={statusVariant}
|
||||||
|
label={t(`cash_shift.status_values.${shift.status}`)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 mb-1 block">
|
||||||
|
{t("cash_shift.opened_at")}
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{formatOptionalDate(shift.openedAt, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 mb-1 block">
|
||||||
|
{t("cash_shift.closed_at")}
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{formatOptionalDate(shift.closedAt, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 mb-1 block">
|
||||||
|
{t("cash_shift.opening_amount")}
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{formatPrice(shift.openingAmount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 mb-1 block">
|
||||||
|
{t("cash_shift.orders_count")}
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{formatFaNumber(shift.ordersCount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isOpen && shift.expectedAmount !== null && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 mb-1 block">
|
||||||
|
{t("cash_shift.expected_amount")}
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{formatPrice(shift.expectedAmount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isOpen && shift.countedAmount !== null && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 mb-1 block">
|
||||||
|
{t("cash_shift.counted_amount")}
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{formatPrice(shift.countedAmount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isOpen && shift.differenceAmount !== null && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 mb-1 block">
|
||||||
|
{t("cash_shift.difference_amount")}
|
||||||
|
</label>
|
||||||
|
<p
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
shift.differenceAmount > 0
|
||||||
|
? "text-green-600"
|
||||||
|
: shift.differenceAmount < 0
|
||||||
|
? "text-red-600"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatPrice(shift.differenceAmount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shift.notes && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-500 mb-1 block">
|
||||||
|
{t("cash_shift.notes")}
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-medium bg-gray-50 p-3 rounded-lg whitespace-pre-wrap break-words">
|
||||||
|
{shift.notes}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-[#F5F5F5] rounded-2xl p-4">
|
||||||
|
<div className="text-sm font-medium mb-3">
|
||||||
|
{t("cash_shift.summary_title")}
|
||||||
|
</div>
|
||||||
|
<CashShiftSummaryCard
|
||||||
|
summary={summary}
|
||||||
|
openingAmount={shift.openingAmount}
|
||||||
|
isLoading={isSummaryLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DefaulModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CashShiftDetailModal;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import type { FieldType } from "@/components/Filters";
|
||||||
|
import { CashShiftStatus } from "../enum/Enum";
|
||||||
|
|
||||||
|
export const useCashShiftFiltersFields = (): FieldType[] => {
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
name: "status",
|
||||||
|
placeholder: "وضعیت شیفت",
|
||||||
|
defaultValue: "",
|
||||||
|
options: [
|
||||||
|
{ label: "همه", value: "" },
|
||||||
|
{ label: "باز", value: CashShiftStatus.OPEN },
|
||||||
|
{ label: "بسته", value: CashShiftStatus.CLOSED },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "datetime",
|
||||||
|
name: "startDate",
|
||||||
|
placeholder: "از تاریخ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "datetime",
|
||||||
|
name: "endDate",
|
||||||
|
placeholder: "تا تاریخ",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { type FC } from "react";
|
||||||
|
import MoonLoader from "react-spinners/MoonLoader";
|
||||||
|
import { formatFaNumber, formatPrice } from "@/helpers/func";
|
||||||
|
import type { CashShiftSummary } from "../types/Types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
summary?: CashShiftSummary | null;
|
||||||
|
openingAmount?: number;
|
||||||
|
isLoading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SummaryItem: FC<{ label: string; value: string }> = ({ label, value }) => (
|
||||||
|
<div className="bg-white rounded-xl px-4 py-3 min-w-[140px]">
|
||||||
|
<div className="text-xs text-description">{label}</div>
|
||||||
|
<div className="text-sm font-medium mt-1">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CashShiftSummaryCard: FC<Props> = ({ summary, openingAmount = 0, isLoading }) => {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-6">
|
||||||
|
<MoonLoader size={20} color="#000" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!summary) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<SummaryItem label="موجودی اولیه" value={formatPrice(openingAmount)} />
|
||||||
|
<SummaryItem label="نقدی" value={formatPrice(summary.cashReceived)} />
|
||||||
|
<SummaryItem label="آنلاین" value={formatPrice(summary.onlineReceived)} />
|
||||||
|
<SummaryItem label="کارت به کارت" value={formatPrice(summary.creditCardReceived)} />
|
||||||
|
<SummaryItem label="مجموع دریافتی" value={formatPrice(summary.totalReceived)} />
|
||||||
|
<SummaryItem label="مبلغ مورد انتظار" value={formatPrice(summary.expectedAmount)} />
|
||||||
|
<SummaryItem
|
||||||
|
label="تعداد سفارشات"
|
||||||
|
value={formatFaNumber(summary.ordersCount)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CashShiftSummaryCard;
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { Eye, Lock1 } from "iconsax-react";
|
||||||
|
import type { ColumnType } from "@/components/types/TableTypes";
|
||||||
|
import type { CashShift } from "../types/Types";
|
||||||
|
import {
|
||||||
|
formatFaNumber,
|
||||||
|
formatOptionalDate,
|
||||||
|
formatPrice,
|
||||||
|
} from "@/helpers/func";
|
||||||
|
import Status from "@/components/Status";
|
||||||
|
import { CashShiftStatus } from "../enum/Enum";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
|
||||||
|
interface GetCashShiftTableColumnsParams {
|
||||||
|
t: TFunction;
|
||||||
|
onView: (shift: CashShift) => void;
|
||||||
|
onClose?: (shift: CashShift) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAdminName = (shift: CashShift) => {
|
||||||
|
const name = [shift.admin?.firstName, shift.admin?.lastName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
return name || shift.admin?.phone || "-";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCashShiftTableColumns = ({
|
||||||
|
t,
|
||||||
|
onView,
|
||||||
|
onClose,
|
||||||
|
}: GetCashShiftTableColumnsParams): ColumnType<CashShift>[] => [
|
||||||
|
{
|
||||||
|
key: "openedAt",
|
||||||
|
title: t("cash_shift.opened_at"),
|
||||||
|
render: (item) =>
|
||||||
|
formatOptionalDate(item.openedAt, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "closedAt",
|
||||||
|
title: t("cash_shift.closed_at"),
|
||||||
|
render: (item) =>
|
||||||
|
formatOptionalDate(item.closedAt, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "admin",
|
||||||
|
title: t("cash_shift.operator"),
|
||||||
|
render: (item) => getAdminName(item),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "openingAmount",
|
||||||
|
title: t("cash_shift.opening_amount"),
|
||||||
|
render: (item) => formatPrice(item.openingAmount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "expectedAmount",
|
||||||
|
title: t("cash_shift.expected_amount"),
|
||||||
|
render: (item) =>
|
||||||
|
item.expectedAmount !== null ? formatPrice(item.expectedAmount) : "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "differenceAmount",
|
||||||
|
title: t("cash_shift.difference_amount"),
|
||||||
|
render: (item) => {
|
||||||
|
if (item.differenceAmount === null) return "-";
|
||||||
|
const value = formatPrice(item.differenceAmount);
|
||||||
|
if (item.differenceAmount > 0) {
|
||||||
|
return <span className="text-green-600">{value}</span>;
|
||||||
|
}
|
||||||
|
if (item.differenceAmount < 0) {
|
||||||
|
return <span className="text-red-600">{value}</span>;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ordersCount",
|
||||||
|
title: t("cash_shift.orders_count"),
|
||||||
|
render: (item) => formatFaNumber(item.ordersCount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
title: t("cash_shift.status_label"),
|
||||||
|
render: (item) => (
|
||||||
|
<Status
|
||||||
|
variant={item.status === CashShiftStatus.OPEN ? "warning" : "success"}
|
||||||
|
label={t(`cash_shift.status_values.${item.status}`)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
title: "",
|
||||||
|
render: (item) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onView(item)}
|
||||||
|
className="cursor-pointer hover:opacity-70 transition-opacity"
|
||||||
|
title={t("cash_shift.view_details")}
|
||||||
|
>
|
||||||
|
<Eye size={20} color="#8C90A3" />
|
||||||
|
</button>
|
||||||
|
{item.status === CashShiftStatus.OPEN && onClose && (
|
||||||
|
<button
|
||||||
|
onClick={() => onClose(item)}
|
||||||
|
className="cursor-pointer hover:opacity-70 transition-opacity"
|
||||||
|
title={t("cash_shift.close_shift")}
|
||||||
|
>
|
||||||
|
<Lock1 size={20} color="#8C90A3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { type FC, useState } from "react";
|
||||||
|
import DefaulModal from "@/components/DefaulModal";
|
||||||
|
import Input from "@/components/Input";
|
||||||
|
import Textarea from "@/components/Textarea";
|
||||||
|
import Button from "@/components/Button";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { extractErrorMessage } from "@/config/func";
|
||||||
|
import {
|
||||||
|
useCloseCashShift,
|
||||||
|
useGetCashShiftSummary,
|
||||||
|
} from "../hooks/useCashShiftData";
|
||||||
|
import CashShiftSummaryCard from "./CashShiftSummaryCard";
|
||||||
|
import type { CashShift } from "../types/Types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
shift: CashShift | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CloseCashShiftModal: FC<Props> = ({ open, onClose, shift }) => {
|
||||||
|
const { t } = useTranslation("global");
|
||||||
|
const [countedAmount, setCountedAmount] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const closeShift = useCloseCashShift();
|
||||||
|
|
||||||
|
const { data: summaryData, isLoading: isSummaryLoading } =
|
||||||
|
useGetCashShiftSummary(shift?.id ?? "", open && !!shift?.id);
|
||||||
|
|
||||||
|
const summary = summaryData?.data;
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setCountedAmount("");
|
||||||
|
setNotes("");
|
||||||
|
setError("");
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!shift) return;
|
||||||
|
|
||||||
|
const numericAmount = Number(countedAmount);
|
||||||
|
|
||||||
|
if (Number.isNaN(numericAmount) || numericAmount < 0) {
|
||||||
|
setError(t("cash_shift.counted_amount_required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeShift.mutate(
|
||||||
|
{
|
||||||
|
shiftId: shift.id,
|
||||||
|
payload: {
|
||||||
|
countedAmount: numericAmount,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("cash_shift.closed_success"));
|
||||||
|
handleClose();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(extractErrorMessage(err));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!shift) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaulModal
|
||||||
|
open={open}
|
||||||
|
close={handleClose}
|
||||||
|
isHeader
|
||||||
|
title_header={t("cash_shift.close_title")}
|
||||||
|
width={720}
|
||||||
|
>
|
||||||
|
<div className="mt-6 space-y-6">
|
||||||
|
<div className="bg-[#F5F5F5] rounded-2xl p-4">
|
||||||
|
<div className="text-sm font-medium mb-3">
|
||||||
|
{t("cash_shift.summary_title")}
|
||||||
|
</div>
|
||||||
|
<CashShiftSummaryCard
|
||||||
|
summary={summary}
|
||||||
|
openingAmount={shift.openingAmount}
|
||||||
|
isLoading={isSummaryLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
name="countedAmount"
|
||||||
|
label={t("cash_shift.counted_amount")}
|
||||||
|
placeholder={t("cash_shift.counted_amount_placeholder")}
|
||||||
|
type="number"
|
||||||
|
seprator
|
||||||
|
value={countedAmount}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCountedAmount(e.target.value);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
error_text={error}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
name="notes"
|
||||||
|
label={t("cash_shift.notes")}
|
||||||
|
placeholder={t("cash_shift.notes_placeholder")}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
isNotRequired
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
label={t("cash_shift.cancel")}
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="bg-gray-100 text-gray-700"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label={t("cash_shift.close_shift")}
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
isloading={closeShift.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DefaulModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CloseCashShiftModal;
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { type FC, useState } from "react";
|
||||||
|
import DefaulModal from "@/components/DefaulModal";
|
||||||
|
import Input from "@/components/Input";
|
||||||
|
import Textarea from "@/components/Textarea";
|
||||||
|
import Button from "@/components/Button";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { extractErrorMessage } from "@/config/func";
|
||||||
|
import { useOpenCashShift } from "../hooks/useCashShiftData";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OpenCashShiftModal: FC<Props> = ({ open, onClose }) => {
|
||||||
|
const { t } = useTranslation("global");
|
||||||
|
const [openingAmount, setOpeningAmount] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const openShift = useOpenCashShift();
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setOpeningAmount("");
|
||||||
|
setNotes("");
|
||||||
|
setError("");
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const numericAmount = Number(openingAmount);
|
||||||
|
|
||||||
|
if (Number.isNaN(numericAmount) || numericAmount < 0) {
|
||||||
|
setError(t("cash_shift.opening_amount_required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
openShift.mutate(
|
||||||
|
{
|
||||||
|
openingAmount: numericAmount,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("cash_shift.opened_success"));
|
||||||
|
handleClose();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(extractErrorMessage(err));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaulModal
|
||||||
|
open={open}
|
||||||
|
close={handleClose}
|
||||||
|
isHeader
|
||||||
|
title_header={t("cash_shift.open_title")}
|
||||||
|
width={480}
|
||||||
|
>
|
||||||
|
<div className="mt-6">
|
||||||
|
<Input
|
||||||
|
name="openingAmount"
|
||||||
|
label={t("cash_shift.opening_amount")}
|
||||||
|
placeholder={t("cash_shift.opening_amount_placeholder")}
|
||||||
|
type="number"
|
||||||
|
seprator
|
||||||
|
value={openingAmount}
|
||||||
|
onChange={(e) => {
|
||||||
|
setOpeningAmount(e.target.value);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
error_text={error}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<Textarea
|
||||||
|
name="notes"
|
||||||
|
label={t("cash_shift.notes")}
|
||||||
|
placeholder={t("cash_shift.notes_placeholder")}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
isNotRequired
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mt-6">
|
||||||
|
<Button
|
||||||
|
label={t("cash_shift.cancel")}
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="bg-gray-100 text-gray-700"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label={t("cash_shift.open_shift")}
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
isloading={openShift.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DefaulModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OpenCashShiftModal;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export enum CashShiftStatus {
|
||||||
|
OPEN = "open",
|
||||||
|
CLOSED = "closed",
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import * as api from "../service/CashShiftService";
|
||||||
|
import type {
|
||||||
|
CloseCashShiftPayload,
|
||||||
|
OpenCashShiftPayload,
|
||||||
|
} from "../types/Types";
|
||||||
|
import type { GetCashShiftsParams } from "../service/CashShiftService";
|
||||||
|
|
||||||
|
const invalidateCashShiftQueries = (queryClient: ReturnType<typeof useQueryClient>) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["cash-shifts"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["cash-shift-current"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["cash-shift-current-summary"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["cash-shift-summary"] });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetCashShifts = (params?: GetCashShiftsParams) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["cash-shifts", params],
|
||||||
|
queryFn: () => api.getCashShifts(params),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetCurrentCashShift = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["cash-shift-current"],
|
||||||
|
queryFn: () => api.getCurrentCashShift(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetCurrentCashShiftSummary = (enabled = true) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["cash-shift-current-summary"],
|
||||||
|
queryFn: () => api.getCurrentCashShiftSummary(),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetCashShiftSummary = (shiftId: string, enabled = true) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["cash-shift-summary", shiftId],
|
||||||
|
queryFn: () => api.getCashShiftSummary(shiftId),
|
||||||
|
enabled: enabled && !!shiftId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useOpenCashShift = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: OpenCashShiftPayload) => api.openCashShift(payload),
|
||||||
|
onSuccess: () => invalidateCashShiftQueries(queryClient),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCloseCashShift = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
shiftId,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
shiftId: string;
|
||||||
|
payload: CloseCashShiftPayload;
|
||||||
|
}) => api.closeCashShift(shiftId, payload),
|
||||||
|
onSuccess: () => invalidateCashShiftQueries(queryClient),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import type { FilterValues } from "@/components/Filters";
|
||||||
|
|
||||||
|
const DEFAULT_PAGE = 1;
|
||||||
|
const DEFAULT_LIMIT = 10;
|
||||||
|
const DEFAULT_FILTERS: FilterValues = {
|
||||||
|
status: "",
|
||||||
|
startDate: "",
|
||||||
|
endDate: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFiltersFromParams = (searchParams: URLSearchParams): FilterValues => {
|
||||||
|
const filters: FilterValues = { ...DEFAULT_FILTERS };
|
||||||
|
const status = searchParams.get("status");
|
||||||
|
const startDate = searchParams.get("startDate");
|
||||||
|
const endDate = searchParams.get("endDate");
|
||||||
|
|
||||||
|
if (status) filters.status = status;
|
||||||
|
if (startDate) filters.startDate = startDate;
|
||||||
|
if (endDate) filters.endDate = endDate;
|
||||||
|
|
||||||
|
return filters;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPageFromParams = (searchParams: URLSearchParams): number => {
|
||||||
|
const page = searchParams.get("page");
|
||||||
|
const parsed = page ? parseInt(page, 10) : DEFAULT_PAGE;
|
||||||
|
return Number.isNaN(parsed) || parsed < 1 ? DEFAULT_PAGE : parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSearchParams = (filters: FilterValues, page: number): URLSearchParams => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (page > DEFAULT_PAGE) {
|
||||||
|
params.set("page", String(page));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.status) {
|
||||||
|
params.set("status", filters.status as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.startDate) {
|
||||||
|
params.set("startDate", filters.startDate as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.endDate) {
|
||||||
|
params.set("endDate", filters.endDate as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCashShiftFilters = () => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const [filters, setFilters] = useState<FilterValues>(() =>
|
||||||
|
getFiltersFromParams(searchParams)
|
||||||
|
);
|
||||||
|
const [currentPage, setCurrentPage] = useState(() =>
|
||||||
|
getPageFromParams(searchParams)
|
||||||
|
);
|
||||||
|
const [limit] = useState(DEFAULT_LIMIT);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFilters(getFiltersFromParams(searchParams));
|
||||||
|
setCurrentPage(getPageFromParams(searchParams));
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const updateUrlParams = useCallback(
|
||||||
|
(newFilters: FilterValues, page: number) => {
|
||||||
|
setSearchParams(buildSearchParams(newFilters, page), { replace: true });
|
||||||
|
},
|
||||||
|
[setSearchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const apiParams = useMemo(() => {
|
||||||
|
const params: Record<string, string | number> = {
|
||||||
|
page: currentPage,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (filters.status) {
|
||||||
|
params.status = filters.status as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.startDate) {
|
||||||
|
params.startDate = filters.startDate as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.endDate) {
|
||||||
|
params.endDate = filters.endDate as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}, [filters, currentPage, limit]);
|
||||||
|
|
||||||
|
const handleFiltersChange = useCallback(
|
||||||
|
(newFilters: FilterValues) => {
|
||||||
|
setFilters(newFilters);
|
||||||
|
setCurrentPage(DEFAULT_PAGE);
|
||||||
|
updateUrlParams(newFilters, DEFAULT_PAGE);
|
||||||
|
},
|
||||||
|
[updateUrlParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePageChange = useCallback(
|
||||||
|
(page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
updateUrlParams(filters, page);
|
||||||
|
},
|
||||||
|
[filters, updateUrlParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filters,
|
||||||
|
currentPage,
|
||||||
|
apiParams,
|
||||||
|
handleFiltersChange,
|
||||||
|
handlePageChange,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import axios from "@/config/axios";
|
||||||
|
import type {
|
||||||
|
CloseCashShiftPayload,
|
||||||
|
GetCashShiftResponse,
|
||||||
|
GetCashShiftsResponse,
|
||||||
|
GetCashShiftSummaryResponse,
|
||||||
|
OpenCashShiftPayload,
|
||||||
|
} from "../types/Types";
|
||||||
|
|
||||||
|
export interface GetCashShiftsParams {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
status?: string;
|
||||||
|
adminId?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
orderBy?: string;
|
||||||
|
order?: "asc" | "desc";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCashShifts = async (
|
||||||
|
params?: GetCashShiftsParams
|
||||||
|
): Promise<GetCashShiftsResponse> => {
|
||||||
|
const { data } = await axios.get<GetCashShiftsResponse>("/admin/cash-shifts", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCurrentCashShift = async (): Promise<GetCashShiftResponse> => {
|
||||||
|
const { data } = await axios.get<GetCashShiftResponse>(
|
||||||
|
"/admin/cash-shifts/current"
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCurrentCashShiftSummary =
|
||||||
|
async (): Promise<GetCashShiftSummaryResponse> => {
|
||||||
|
const { data } = await axios.get<GetCashShiftSummaryResponse>(
|
||||||
|
"/admin/cash-shifts/current/summary"
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCashShiftSummary = async (
|
||||||
|
shiftId: string
|
||||||
|
): Promise<GetCashShiftSummaryResponse> => {
|
||||||
|
const { data } = await axios.get<GetCashShiftSummaryResponse>(
|
||||||
|
`/admin/cash-shifts/${shiftId}/summary`
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCashShiftById = async (
|
||||||
|
shiftId: string
|
||||||
|
): Promise<GetCashShiftResponse> => {
|
||||||
|
const { data } = await axios.get<GetCashShiftResponse>(
|
||||||
|
`/admin/cash-shifts/${shiftId}`
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const openCashShift = async (
|
||||||
|
payload: OpenCashShiftPayload
|
||||||
|
): Promise<GetCashShiftResponse> => {
|
||||||
|
const { data } = await axios.post<GetCashShiftResponse>(
|
||||||
|
"/admin/cash-shifts/open",
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const closeCashShift = async (
|
||||||
|
shiftId: string,
|
||||||
|
payload: CloseCashShiftPayload
|
||||||
|
): Promise<GetCashShiftResponse> => {
|
||||||
|
const { data } = await axios.patch<GetCashShiftResponse>(
|
||||||
|
`/admin/cash-shifts/${shiftId}/close`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { IResponse } from "@/types/response.types";
|
||||||
|
import type { RowDataType } from "@/components/types/TableTypes";
|
||||||
|
import type { CashShiftStatus } from "../enum/Enum";
|
||||||
|
|
||||||
|
export interface CashShiftAdmin {
|
||||||
|
id: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CashShift extends RowDataType {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
openedAt: string;
|
||||||
|
closedAt: string | null;
|
||||||
|
openingAmount: number;
|
||||||
|
expectedAmount: number | null;
|
||||||
|
countedAmount: number | null;
|
||||||
|
differenceAmount: number | null;
|
||||||
|
ordersCount: number;
|
||||||
|
status: CashShiftStatus;
|
||||||
|
notes: string | null;
|
||||||
|
admin: CashShiftAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CashShiftSummary {
|
||||||
|
ordersCount: number;
|
||||||
|
cashReceived: number;
|
||||||
|
onlineReceived: number;
|
||||||
|
creditCardReceived: number;
|
||||||
|
totalReceived: number;
|
||||||
|
expectedAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationMeta {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetCashShiftsResponse = IResponse<CashShift[]> & {
|
||||||
|
meta: PaginationMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetCashShiftResponse = IResponse<CashShift>;
|
||||||
|
export type GetCashShiftSummaryResponse = IResponse<CashShiftSummary | null>;
|
||||||
|
|
||||||
|
export interface OpenCashShiftPayload {
|
||||||
|
openingAmount: number;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloseCashShiftPayload {
|
||||||
|
countedAmount: number;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type FC, useEffect, useState } from 'react'
|
import { type FC, useEffect, useMemo, useState } from 'react'
|
||||||
import { useFormik } from 'formik'
|
import { useFormik } from 'formik'
|
||||||
import * as Yup from 'yup'
|
import * as Yup from 'yup'
|
||||||
import { TickCircle } from 'iconsax-react'
|
import { TickCircle } from 'iconsax-react'
|
||||||
@@ -6,11 +6,13 @@ import { toast } from 'react-toastify'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import Button from '@/components/Button'
|
import Button from '@/components/Button'
|
||||||
import Input from '@/components/Input'
|
import Input from '@/components/Input'
|
||||||
|
import MultiSelect from '@/components/MultiSelect'
|
||||||
import Select from '@/components/Select'
|
import Select from '@/components/Select'
|
||||||
import DatePicker from '@/components/DatePicker'
|
import DatePicker from '@/components/DatePicker'
|
||||||
import UploadBox from '@/components/UploadBox'
|
import UploadBox from '@/components/UploadBox'
|
||||||
import { useCreateUser } from './hooks/useUsersData'
|
import { useCreateUser } from './hooks/useUsersData'
|
||||||
import { useSingleUpload } from '@/pages/uploader/hooks/useUploaderData'
|
import { useSingleUpload } from '@/pages/uploader/hooks/useUploaderData'
|
||||||
|
import { useGetUserGroups } from '@/pages/customers/groups/hooks/useUserGroupData'
|
||||||
import type { CreateUserType } from './types/Types'
|
import type { CreateUserType } from './types/Types'
|
||||||
import { Pages } from '@/config/Pages'
|
import { Pages } from '@/config/Pages'
|
||||||
import type { ErrorType } from '@/helpers/types'
|
import type { ErrorType } from '@/helpers/types'
|
||||||
@@ -23,6 +25,7 @@ type CreateCustomerFormValues = {
|
|||||||
birthDate: string
|
birthDate: string
|
||||||
marriageDate: string
|
marriageDate: string
|
||||||
gender: string
|
gender: string
|
||||||
|
groupIds: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildCreateUserPayload = (
|
const buildCreateUserPayload = (
|
||||||
@@ -39,6 +42,7 @@ const buildCreateUserPayload = (
|
|||||||
if (values.marriageDate) payload.marriageDate = values.marriageDate
|
if (values.marriageDate) payload.marriageDate = values.marriageDate
|
||||||
if (values.gender !== '') payload.gender = values.gender === 'true'
|
if (values.gender !== '') payload.gender = values.gender === 'true'
|
||||||
if (avatarUrl) payload.avatarUrl = avatarUrl
|
if (avatarUrl) payload.avatarUrl = avatarUrl
|
||||||
|
if (values.groupIds.length > 0) payload.groupIds = values.groupIds
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
@@ -47,9 +51,19 @@ const CreateCustomer: FC = () => {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { mutate: createUser, isPending } = useCreateUser()
|
const { mutate: createUser, isPending } = useCreateUser()
|
||||||
const { mutate: singleUpload, isPending: isUploading } = useSingleUpload()
|
const { mutate: singleUpload, isPending: isUploading } = useSingleUpload()
|
||||||
|
const { data: groupsData } = useGetUserGroups()
|
||||||
const [avatarFile, setAvatarFile] = useState<File[]>([])
|
const [avatarFile, setAvatarFile] = useState<File[]>([])
|
||||||
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState('')
|
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState('')
|
||||||
|
|
||||||
|
const groupOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
(groupsData?.data || []).map((group) => ({
|
||||||
|
value: group.id,
|
||||||
|
label: group.name,
|
||||||
|
})),
|
||||||
|
[groupsData],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (avatarFile.length > 0) {
|
if (avatarFile.length > 0) {
|
||||||
const url = URL.createObjectURL(avatarFile[0])
|
const url = URL.createObjectURL(avatarFile[0])
|
||||||
@@ -67,6 +81,7 @@ const CreateCustomer: FC = () => {
|
|||||||
birthDate: '',
|
birthDate: '',
|
||||||
marriageDate: '',
|
marriageDate: '',
|
||||||
gender: '',
|
gender: '',
|
||||||
|
groupIds: [],
|
||||||
},
|
},
|
||||||
validationSchema: Yup.object().shape({
|
validationSchema: Yup.object().shape({
|
||||||
phone: Yup.string().required('شماره تلفن الزامی است'),
|
phone: Yup.string().required('شماره تلفن الزامی است'),
|
||||||
@@ -75,6 +90,7 @@ const CreateCustomer: FC = () => {
|
|||||||
birthDate: Yup.string(),
|
birthDate: Yup.string(),
|
||||||
marriageDate: Yup.string(),
|
marriageDate: Yup.string(),
|
||||||
gender: Yup.string(),
|
gender: Yup.string(),
|
||||||
|
groupIds: Yup.array().of(Yup.string()),
|
||||||
}),
|
}),
|
||||||
onSubmit: (values) => {
|
onSubmit: (values) => {
|
||||||
const submitCustomer = (avatarUrl?: string) => {
|
const submitCustomer = (avatarUrl?: string) => {
|
||||||
@@ -197,6 +213,21 @@ const CreateCustomer: FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-5'>
|
||||||
|
<MultiSelect
|
||||||
|
label='گروهها'
|
||||||
|
items={groupOptions}
|
||||||
|
value={formik.values.groupIds}
|
||||||
|
onChange={(values) => formik.setFieldValue('groupIds', values)}
|
||||||
|
placeholder='انتخاب گروهها'
|
||||||
|
error_text={
|
||||||
|
formik.touched.groupIds && formik.errors.groupIds
|
||||||
|
? String(formik.errors.groupIds)
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='mt-5'>
|
<div className='mt-5'>
|
||||||
<UploadBox
|
<UploadBox
|
||||||
label='تصویر پروفایل'
|
label='تصویر پروفایل'
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "./groups/List";
|
||||||
+143
-143
@@ -1,158 +1,158 @@
|
|||||||
import Table from '@/components/Table'
|
import Button from "@/components/Button";
|
||||||
import Filters, { type FieldType } from '@/components/Filters'
|
import Filters, { type FieldType } from "@/components/Filters";
|
||||||
import Button from '@/components/Button'
|
import Table from "@/components/Table";
|
||||||
import { type FC, useMemo, useState } from 'react'
|
import { Pages } from "@/config/Pages";
|
||||||
import { useTranslation } from 'react-i18next'
|
import { formatFaNumber, formatOptionalDate } from "@/helpers/func";
|
||||||
import { DocumentDownload, DocumentUpload } from 'iconsax-react'
|
import type { CustomerListRow, UserRestaurant } from "@/pages/customers/types/Types";
|
||||||
import { useGetUsers } from './hooks/useUsersData'
|
import { DocumentDownload, DocumentUpload, Edit } from "iconsax-react";
|
||||||
import { useCustomerFilters } from './hooks/useCustomerFilters'
|
import { type FC, useMemo, useState } from "react";
|
||||||
import ImportCustomersModal from './components/ImportCustomersModal'
|
import { useTranslation } from "react-i18next";
|
||||||
import { downloadCustomerImportSampleExcel } from './utils/downloadCustomerImportSampleExcel'
|
import { useNavigate } from "react-router-dom";
|
||||||
import type { CustomerListRow, UserRestaurant } from '@/pages/customers/types/Types'
|
import ImportCustomersModal from "./components/ImportCustomersModal";
|
||||||
import { formatOptionalDate, formatFaNumber } from '@/helpers/func'
|
import { useCustomerFilters } from "./hooks/useCustomerFilters";
|
||||||
|
import { useGetUsers } from "./hooks/useUsersData";
|
||||||
|
import { downloadCustomerImportSampleExcel } from "./utils/downloadCustomerImportSampleExcel";
|
||||||
|
|
||||||
const mapUserRestaurantToRow = (userRestaurant: UserRestaurant): CustomerListRow => {
|
const mapUserRestaurantToRow = (userRestaurant: UserRestaurant): CustomerListRow => {
|
||||||
const { user, orderCount, totalOrderAmount } = userRestaurant
|
const { user, orderCount, totalOrderAmount } = userRestaurant;
|
||||||
const fullName = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
|
const fullName = [user.firstName, user.lastName].filter(Boolean).join(" ").trim();
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
fullName: fullName || 'بدون نام',
|
fullName: fullName || "بدون نام",
|
||||||
phone: user.phone || '-',
|
phone: user.phone || "-",
|
||||||
birthDate: formatOptionalDate(user.birthDate),
|
birthDate: formatOptionalDate(user.birthDate),
|
||||||
marriageDate: formatOptionalDate(user.marriageDate),
|
marriageDate: formatOptionalDate(user.marriageDate),
|
||||||
orderCount: formatFaNumber(orderCount),
|
orderCount: formatFaNumber(orderCount),
|
||||||
totalOrderAmount: formatFaNumber(totalOrderAmount),
|
totalOrderAmount: formatFaNumber(totalOrderAmount),
|
||||||
status: user.isActive !== false ? 'فعال' : 'غیرفعال',
|
status: user.isActive !== false ? "فعال" : "غیرفعال",
|
||||||
}
|
firstName: user.firstName,
|
||||||
}
|
lastName: user.lastName,
|
||||||
|
birthDateRaw: user.birthDate,
|
||||||
|
marriageDateRaw: user.marriageDate,
|
||||||
|
gender: user.gender,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const CustomersList: FC = () => {
|
const CustomersList: FC = () => {
|
||||||
const { t } = useTranslation('global')
|
const { t } = useTranslation("global");
|
||||||
const [isImportModalOpen, setIsImportModalOpen] = useState(false)
|
const navigate = useNavigate();
|
||||||
|
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||||||
|
|
||||||
const {
|
const { filters, currentPage, apiParams, handleFiltersChange, handlePageChange } = useCustomerFilters();
|
||||||
filters,
|
|
||||||
currentPage,
|
|
||||||
apiParams,
|
|
||||||
handleFiltersChange,
|
|
||||||
handlePageChange,
|
|
||||||
} = useCustomerFilters()
|
|
||||||
|
|
||||||
const { data: usersData, isLoading } = useGetUsers(apiParams)
|
const { data: usersData, isLoading } = useGetUsers(apiParams);
|
||||||
|
|
||||||
const filterFields = useMemo<FieldType[]>(() => [
|
const filterFields = useMemo<FieldType[]>(
|
||||||
{
|
() => [
|
||||||
type: 'input',
|
{
|
||||||
name: 'search',
|
type: "input",
|
||||||
placeholder: 'نام یا شماره تماس',
|
name: "search",
|
||||||
},
|
placeholder: "نام یا شماره تماس",
|
||||||
], [])
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const userRestaurants = usersData?.data ?? []
|
const userRestaurants = usersData?.data ?? [];
|
||||||
const tableData = useMemo<CustomerListRow[]>(
|
const tableData = useMemo<CustomerListRow[]>(() => userRestaurants.map(mapUserRestaurantToRow), [userRestaurants]);
|
||||||
() => userRestaurants.map(mapUserRestaurantToRow),
|
|
||||||
[userRestaurants],
|
|
||||||
)
|
|
||||||
|
|
||||||
const totalPages = usersData?.meta?.totalPages || 1
|
const totalPages = usersData?.meta?.totalPages || 1;
|
||||||
|
|
||||||
const columns = useMemo(() => [
|
const columns = useMemo(
|
||||||
{
|
() => [
|
||||||
key: 'fullName',
|
{
|
||||||
title: 'نام و نام خانوادگی',
|
key: "fullName",
|
||||||
},
|
title: "نام و نام خانوادگی",
|
||||||
{
|
},
|
||||||
key: 'phone',
|
{
|
||||||
title: 'شماره تماس',
|
key: "phone",
|
||||||
},
|
title: "شماره تماس",
|
||||||
{
|
},
|
||||||
key: 'birthDate',
|
{
|
||||||
title: 'تاریخ تولد',
|
key: "birthDate",
|
||||||
},
|
title: "تاریخ تولد",
|
||||||
{
|
},
|
||||||
key: 'marriageDate',
|
{
|
||||||
title: 'تاریخ ازدواج',
|
key: "marriageDate",
|
||||||
},
|
title: "تاریخ ازدواج",
|
||||||
{
|
},
|
||||||
key: 'orderCount',
|
{
|
||||||
title: 'تعداد سفارش',
|
key: "orderCount",
|
||||||
},
|
title: "تعداد سفارش",
|
||||||
{
|
},
|
||||||
key: 'totalOrderAmount',
|
{
|
||||||
title: 'مجموع سفارش (تومان)',
|
key: "totalOrderAmount",
|
||||||
},
|
title: "مجموع سفارش (تومان)",
|
||||||
{
|
},
|
||||||
key: 'status',
|
{
|
||||||
title: 'وضعیت',
|
key: "status",
|
||||||
render: (item: CustomerListRow) => (
|
title: "وضعیت",
|
||||||
<span
|
render: (item: CustomerListRow) => (
|
||||||
className={`px-3 py-1 rounded-full text-xs ${item.status === 'فعال' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}`}
|
<span className={`px-3 py-1 rounded-full text-xs ${item.status === "فعال" ? "bg-green-100 text-green-700" : "bg-red-100 text-red-600"}`}>{item.status}</span>
|
||||||
>
|
),
|
||||||
{item.status}
|
},
|
||||||
</span>
|
{
|
||||||
),
|
key: "details",
|
||||||
},
|
title: "عملیات",
|
||||||
{
|
render: (item: CustomerListRow) => (
|
||||||
key: 'details',
|
<button
|
||||||
title: 'جزئیات',
|
type="button"
|
||||||
},
|
className="text-primary text-xs flex items-center gap-1"
|
||||||
], [])
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(Pages.customers.update + item.id, {
|
||||||
|
state: { customer: item },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit size={18} color="#0047FF" />
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[navigate],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-5'>
|
<div className="mt-5">
|
||||||
<div className='flex flex-wrap justify-between items-center gap-3'>
|
<div className="flex flex-wrap justify-between items-center gap-3">
|
||||||
<h1 className='text-lg font-light'>{t('customer.customer_list')}</h1>
|
<h1 className="text-lg font-light">{t("customer.customer_list")}</h1>
|
||||||
<div className='flex flex-wrap gap-2'>
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button type="button" className="w-fit px-4 bg-gray-100 text-gray-700" onClick={downloadCustomerImportSampleExcel}>
|
||||||
type='button'
|
<div className="flex items-center gap-2">
|
||||||
className='w-fit px-4 bg-gray-100 text-gray-700'
|
<DocumentDownload size={18} color="#374151" />
|
||||||
onClick={downloadCustomerImportSampleExcel}
|
<span>{t("customer.download_sample")}</span>
|
||||||
>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<DocumentDownload size={18} color='#374151' />
|
|
||||||
<span>{t('customer.download_sample')}</span>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
className='w-fit px-4'
|
|
||||||
onClick={() => setIsImportModalOpen(true)}
|
|
||||||
>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<DocumentUpload size={18} color='#fff' />
|
|
||||||
<span>{t('customer.import_excel')}</span>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Button>
|
||||||
<div className='mt-8'>
|
<Button type="button" className="w-fit px-4" onClick={() => setIsImportModalOpen(true)}>
|
||||||
<Filters
|
<div className="flex items-center gap-2">
|
||||||
fields={filterFields}
|
<DocumentUpload size={18} color="#fff" />
|
||||||
onChange={handleFiltersChange}
|
<span>{t("customer.import_excel")}</span>
|
||||||
initialValues={filters}
|
|
||||||
searchField="search"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Button>
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
data={tableData}
|
|
||||||
isloading={isLoading}
|
|
||||||
selectable
|
|
||||||
pagination={{
|
|
||||||
currentPage,
|
|
||||||
totalPages,
|
|
||||||
onPageChange: handlePageChange,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ImportCustomersModal
|
|
||||||
open={isImportModalOpen}
|
|
||||||
onClose={() => setIsImportModalOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
export default CustomersList
|
<div className="mt-8">
|
||||||
|
<Filters fields={filterFields} onChange={handleFiltersChange} initialValues={filters} searchField="search" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={tableData}
|
||||||
|
isloading={isLoading}
|
||||||
|
selectable
|
||||||
|
pagination={{
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
onPageChange: handlePageChange,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ImportCustomersModal open={isImportModalOpen} onClose={() => setIsImportModalOpen(false)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomersList;
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import { type FC, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useFormik } from 'formik'
|
||||||
|
import { TickCircle } from 'iconsax-react'
|
||||||
|
import { toast } from 'react-toastify'
|
||||||
|
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import Button from '@/components/Button'
|
||||||
|
import Input from '@/components/Input'
|
||||||
|
import MultiSelect from '@/components/MultiSelect'
|
||||||
|
import Select from '@/components/Select'
|
||||||
|
import DatePicker from '@/components/DatePicker'
|
||||||
|
import UploadBox from '@/components/UploadBox'
|
||||||
|
import { useSingleUpload } from '@/pages/uploader/hooks/useUploaderData'
|
||||||
|
import { useGetUserGroupsForUser, useUpdateUser } from './hooks/useUsersData'
|
||||||
|
import { useGetUserGroups } from '@/pages/customers/groups/hooks/useUserGroupData'
|
||||||
|
import type { CustomerListRow, UpdateUserType } from './types/Types'
|
||||||
|
import { Pages } from '@/config/Pages'
|
||||||
|
import type { ErrorType } from '@/helpers/types'
|
||||||
|
import { extractErrorMessage } from '@/config/func'
|
||||||
|
|
||||||
|
type UpdateCustomerFormValues = {
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
birthDate: string
|
||||||
|
marriageDate: string
|
||||||
|
gender: string
|
||||||
|
groupIds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomerUpdateLocationState = {
|
||||||
|
customer?: CustomerListRow
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdateCustomer: FC = () => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const { mutate: updateUser, isPending: isUpdating } = useUpdateUser()
|
||||||
|
const { mutate: singleUpload, isPending: isUploading } = useSingleUpload()
|
||||||
|
const { data: userGroupsData } = useGetUserGroupsForUser(id || '')
|
||||||
|
const { data: allGroupsData } = useGetUserGroups()
|
||||||
|
const [avatarFile, setAvatarFile] = useState<File[]>([])
|
||||||
|
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState('')
|
||||||
|
|
||||||
|
const customer = (location.state as CustomerUpdateLocationState | null)?.customer
|
||||||
|
|
||||||
|
const initialGroupIds = useMemo(
|
||||||
|
() => (userGroupsData?.data || []).map((group) => group.id),
|
||||||
|
[userGroupsData],
|
||||||
|
)
|
||||||
|
|
||||||
|
const groupOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
(allGroupsData?.data || []).map((group) => ({
|
||||||
|
value: group.id,
|
||||||
|
label: group.name,
|
||||||
|
})),
|
||||||
|
[allGroupsData],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!customer?.avatarUrl) {
|
||||||
|
setAvatarPreviewUrl('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAvatarPreviewUrl(customer.avatarUrl)
|
||||||
|
}, [customer?.avatarUrl])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (avatarFile.length > 0) {
|
||||||
|
const url = URL.createObjectURL(avatarFile[0])
|
||||||
|
setAvatarPreviewUrl(url)
|
||||||
|
return () => URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
}, [avatarFile])
|
||||||
|
|
||||||
|
const formik = useFormik<UpdateCustomerFormValues>({
|
||||||
|
initialValues: {
|
||||||
|
firstName: customer?.firstName || '',
|
||||||
|
lastName: customer?.lastName || '',
|
||||||
|
birthDate: customer?.birthDateRaw || '',
|
||||||
|
marriageDate: customer?.marriageDateRaw || '',
|
||||||
|
gender:
|
||||||
|
customer?.gender === undefined || customer?.gender === null
|
||||||
|
? ''
|
||||||
|
: String(customer.gender),
|
||||||
|
groupIds: initialGroupIds,
|
||||||
|
},
|
||||||
|
enableReinitialize: true,
|
||||||
|
onSubmit: (values) => {
|
||||||
|
if (!id) {
|
||||||
|
toast.error('شناسه مشتری یافت نشد')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitUpdate = (avatarUrl?: string) => {
|
||||||
|
const payload: UpdateUserType = {
|
||||||
|
groupIds: values.groupIds,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.firstName.trim()) payload.firstName = values.firstName.trim()
|
||||||
|
if (values.lastName.trim()) payload.lastName = values.lastName.trim()
|
||||||
|
if (values.birthDate) payload.birthDate = values.birthDate
|
||||||
|
if (values.marriageDate) payload.marriageDate = values.marriageDate
|
||||||
|
if (values.gender !== '') payload.gender = values.gender === 'true'
|
||||||
|
if (avatarUrl) payload.avatarUrl = avatarUrl
|
||||||
|
|
||||||
|
const groupsChanged =
|
||||||
|
JSON.stringify([...values.groupIds].sort()) !==
|
||||||
|
JSON.stringify([...initialGroupIds].sort())
|
||||||
|
const hasProfileChanges =
|
||||||
|
Boolean(payload.firstName) ||
|
||||||
|
Boolean(payload.lastName) ||
|
||||||
|
Boolean(payload.birthDate) ||
|
||||||
|
Boolean(payload.marriageDate) ||
|
||||||
|
payload.gender !== undefined ||
|
||||||
|
Boolean(payload.avatarUrl)
|
||||||
|
|
||||||
|
if (!hasProfileChanges && !groupsChanged) {
|
||||||
|
toast.info('حداقل یک فیلد را برای ویرایش وارد کنید')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUser(
|
||||||
|
{ userId: id, params: payload },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('اطلاعات مشتری با موفقیت ویرایش شد')
|
||||||
|
navigate(Pages.customers.list)
|
||||||
|
},
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
toast.error(extractErrorMessage(error, 'خطا در ویرایش مشتری'))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avatarFile.length > 0) {
|
||||||
|
singleUpload(avatarFile[0], {
|
||||||
|
onSuccess: (response) => {
|
||||||
|
const avatarUrl = response?.data?.url || ''
|
||||||
|
submitUpdate(avatarUrl)
|
||||||
|
},
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
toast.error(extractErrorMessage(error, 'خطا در آپلود تصویر'))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
submitUpdate(customer?.avatarUrl || undefined)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='mt-5'>
|
||||||
|
<div className='flex justify-between items-center'>
|
||||||
|
<h1 className='text-lg font-light'>ویرایش مشتری</h1>
|
||||||
|
<Button
|
||||||
|
className='w-fit px-6'
|
||||||
|
isloading={isUpdating || isUploading}
|
||||||
|
onClick={() => formik.handleSubmit()}
|
||||||
|
>
|
||||||
|
<div className='flex gap-2 items-center'>
|
||||||
|
<TickCircle size={20} color='#fff' />
|
||||||
|
<span>ذخیره تغییرات</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-9'>
|
||||||
|
<div className='w-full bg-white rounded-4xl p-8'>
|
||||||
|
<div className='text-lg font-light'>اطلاعات مشتری</div>
|
||||||
|
|
||||||
|
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-6'>
|
||||||
|
<Input
|
||||||
|
label='نام'
|
||||||
|
placeholder=''
|
||||||
|
name='firstName'
|
||||||
|
value={formik.values.firstName}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
isNotRequired
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label='نام خانوادگی'
|
||||||
|
placeholder=''
|
||||||
|
name='lastName'
|
||||||
|
value={formik.values.lastName}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
isNotRequired
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-5'>
|
||||||
|
<DatePicker
|
||||||
|
label='تاریخ تولد'
|
||||||
|
placeholder='انتخاب'
|
||||||
|
isNotRequired
|
||||||
|
defaulValue={formik.values.birthDate}
|
||||||
|
onChange={(date) => formik.setFieldValue('birthDate', date)}
|
||||||
|
/>
|
||||||
|
<DatePicker
|
||||||
|
label='تاریخ ازدواج'
|
||||||
|
placeholder='انتخاب'
|
||||||
|
isNotRequired
|
||||||
|
defaulValue={formik.values.marriageDate}
|
||||||
|
onChange={(date) => formik.setFieldValue('marriageDate', date)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-5'>
|
||||||
|
<Select
|
||||||
|
label='جنسیت'
|
||||||
|
placeholder='انتخاب'
|
||||||
|
name='gender'
|
||||||
|
value={formik.values.gender}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
isNotRequired
|
||||||
|
items={[
|
||||||
|
{ value: 'true', label: 'مرد' },
|
||||||
|
{ value: 'false', label: 'زن' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-5'>
|
||||||
|
<MultiSelect
|
||||||
|
label='گروهها'
|
||||||
|
items={groupOptions}
|
||||||
|
value={formik.values.groupIds}
|
||||||
|
onChange={(values) => formik.setFieldValue('groupIds', values)}
|
||||||
|
placeholder='انتخاب گروهها'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-5'>
|
||||||
|
<UploadBox
|
||||||
|
label='تصویر پروفایل'
|
||||||
|
isNotRequired
|
||||||
|
onChange={(files) => setAvatarFile(files)}
|
||||||
|
isReset={avatarFile.length === 0}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{avatarPreviewUrl && (
|
||||||
|
<div className='mt-3'>
|
||||||
|
<img
|
||||||
|
src={avatarPreviewUrl}
|
||||||
|
alt='avatar preview'
|
||||||
|
className='w-20 h-20 rounded-full object-cover'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UpdateCustomer
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import Button from "@/components/Button";
|
||||||
|
import Input from "@/components/Input";
|
||||||
|
import MultiSelect from "@/components/MultiSelect";
|
||||||
|
import Select from "@/components/Select";
|
||||||
|
import { extractErrorMessage } from "@/config/func";
|
||||||
|
import { Pages } from "@/config/Pages";
|
||||||
|
import type { ErrorType } from "@/helpers/types";
|
||||||
|
import { useFormik } from "formik";
|
||||||
|
import { TickCircle } from "iconsax-react";
|
||||||
|
import { type FC, useMemo } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import * as Yup from "yup";
|
||||||
|
import { useGetUserGroups } from "@/pages/customers/groups/hooks/useUserGroupData";
|
||||||
|
import { useCreateDiscountCampaign } from "./hooks/useCampaignData";
|
||||||
|
import type { CreateDiscountCampaignType } from "./types/Types";
|
||||||
|
|
||||||
|
const sentTypeOptions = [
|
||||||
|
{ value: "push", label: "پوش نوتیفیکیشن" },
|
||||||
|
{ value: "sms", label: "پیامک" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CreateCampaign: FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { mutate: createCampaign, isPending } = useCreateDiscountCampaign();
|
||||||
|
const { data: groupsData } = useGetUserGroups();
|
||||||
|
|
||||||
|
const groupOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
(groupsData?.data || []).map((group) => ({
|
||||||
|
value: group.id,
|
||||||
|
label: group.name,
|
||||||
|
})),
|
||||||
|
[groupsData]
|
||||||
|
);
|
||||||
|
|
||||||
|
const formik = useFormik<CreateDiscountCampaignType>({
|
||||||
|
initialValues: {
|
||||||
|
title: "",
|
||||||
|
groups: [],
|
||||||
|
sentType: "push",
|
||||||
|
discountCode: "",
|
||||||
|
ocasion: "",
|
||||||
|
},
|
||||||
|
validationSchema: Yup.object().shape({
|
||||||
|
title: Yup.string().required("عنوان کمپین الزامی است"),
|
||||||
|
groups: Yup.array()
|
||||||
|
.of(Yup.string())
|
||||||
|
.min(1, "حداقل یک گروه باید انتخاب شود"),
|
||||||
|
sentType: Yup.string().required("نوع ارسال الزامی است"),
|
||||||
|
discountCode: Yup.string().required("کد تخفیف الزامی است"),
|
||||||
|
ocasion: Yup.string().required("مناسبت الزامی است"),
|
||||||
|
}),
|
||||||
|
onSubmit: (values) => {
|
||||||
|
createCampaign(values, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("کمپین با موفقیت ایجاد شد");
|
||||||
|
navigate(Pages.customers.campaigns);
|
||||||
|
},
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
toast.error(extractErrorMessage(error));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="flex w-full justify-between items-center">
|
||||||
|
<div className="text-lg font-light">افزودن کمپین تخفیف</div>
|
||||||
|
<Button
|
||||||
|
className="px-5 w-fit"
|
||||||
|
onClick={() => formik.handleSubmit()}
|
||||||
|
isloading={isPending}
|
||||||
|
>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<TickCircle className="size-5" color="white" />
|
||||||
|
<div>ثبت کمپین</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="bg-white rounded-3xl p-6 flex flex-col gap-6">
|
||||||
|
<Input
|
||||||
|
label="عنوان کمپین"
|
||||||
|
name="title"
|
||||||
|
value={formik.values.title}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
error_text={
|
||||||
|
formik.touched.title && formik.errors.title
|
||||||
|
? formik.errors.title
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="کد تخفیف"
|
||||||
|
name="discountCode"
|
||||||
|
value={formik.values.discountCode}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
error_text={
|
||||||
|
formik.touched.discountCode && formik.errors.discountCode
|
||||||
|
? formik.errors.discountCode
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="مناسبت"
|
||||||
|
name="ocasion"
|
||||||
|
value={formik.values.ocasion}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
error_text={
|
||||||
|
formik.touched.ocasion && formik.errors.ocasion
|
||||||
|
? formik.errors.ocasion
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="نوع ارسال"
|
||||||
|
name="sentType"
|
||||||
|
items={sentTypeOptions}
|
||||||
|
value={formik.values.sentType}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
error_text={
|
||||||
|
formik.touched.sentType && formik.errors.sentType
|
||||||
|
? formik.errors.sentType
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MultiSelect
|
||||||
|
label="گروههای مشتری"
|
||||||
|
items={groupOptions}
|
||||||
|
value={formik.values.groups}
|
||||||
|
onChange={(values) => formik.setFieldValue("groups", values)}
|
||||||
|
placeholder="انتخاب گروهها"
|
||||||
|
error_text={
|
||||||
|
formik.touched.groups && formik.errors.groups
|
||||||
|
? String(formik.errors.groups)
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateCampaign;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Button from "@/components/Button";
|
||||||
|
import Filters from "@/components/Filters";
|
||||||
|
import Table from "@/components/Table";
|
||||||
|
import { Pages } from "@/config/Pages";
|
||||||
|
import { Add } from "iconsax-react";
|
||||||
|
import { type FC } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { getCampaignTableColumns } from "./components/CampaignTableColumns";
|
||||||
|
import { useGetCampaigns } from "./hooks/useCampaignData";
|
||||||
|
import { useCampaignFilters } from "./hooks/useCampaignFilters";
|
||||||
|
|
||||||
|
const CampaignsList: FC = () => {
|
||||||
|
const { filters, currentPage, apiParams, handleFiltersChange, handlePageChange, limit } = useCampaignFilters();
|
||||||
|
|
||||||
|
const { data: campaignsData, isLoading } = useGetCampaigns(apiParams);
|
||||||
|
|
||||||
|
const campaigns = campaignsData?.data || [];
|
||||||
|
const totalPages = campaignsData?.meta?.totalPages || Math.max(1, Math.ceil(campaigns.length / limit) || 1);
|
||||||
|
|
||||||
|
const columns = getCampaignTableColumns();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="text-lg font-light">کمپینها</h1>
|
||||||
|
<Link to={Pages.customers.campaignsAdd}>
|
||||||
|
<Button className="w-fit px-6">
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Add color="#fff" size={20} />
|
||||||
|
<span>افزودن کمپین</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<Filters fields={[]} onChange={handleFiltersChange} initialValues={filters} searchField="search" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={campaigns}
|
||||||
|
isloading={isLoading}
|
||||||
|
pagination={{
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
onPageChange: handlePageChange,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CampaignsList;
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import Status from "@/components/Status";
|
||||||
|
import type { ColumnType } from "@/components/types/TableTypes";
|
||||||
|
import { formatFaNumber, formatOptionalDate, formatPrice } from "@/helpers/func";
|
||||||
|
import type { Campaign } from "../types/Types";
|
||||||
|
|
||||||
|
const getGroupNames = (groups: Campaign["groups"]) => {
|
||||||
|
if (!groups?.length) return "-";
|
||||||
|
|
||||||
|
return groups
|
||||||
|
.map((group) => {
|
||||||
|
if (typeof group === "string") return group;
|
||||||
|
const count = group.count != null ? ` (${formatFaNumber(group.count)})` : "";
|
||||||
|
return `${group.name}${count}`;
|
||||||
|
})
|
||||||
|
.join(" ، ");
|
||||||
|
};
|
||||||
|
|
||||||
|
const sentTypeLabels: Record<string, string> = {
|
||||||
|
push: "پوش نوتیفیکیشن",
|
||||||
|
sms: "پیامک",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
pending: "در انتظار",
|
||||||
|
processing: "در حال ارسال",
|
||||||
|
completed: "تکمیل شده",
|
||||||
|
failed: "ناموفق",
|
||||||
|
partial: "ناقص",
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusVariant = (status: string): "success" | "error" | "warning" | "info" | "pending" => {
|
||||||
|
const variantMap: Record<string, "success" | "error" | "warning" | "info" | "pending"> = {
|
||||||
|
pending: "pending",
|
||||||
|
processing: "info",
|
||||||
|
completed: "success",
|
||||||
|
failed: "error",
|
||||||
|
partial: "warning",
|
||||||
|
};
|
||||||
|
return variantMap[status] || "pending";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCampaignTableColumns = (): ColumnType<Campaign>[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "title",
|
||||||
|
title: "عنوان",
|
||||||
|
render: (item: Campaign) => item.title || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "discountCode",
|
||||||
|
title: "کد تخفیف",
|
||||||
|
render: (item: Campaign) => item.discountCode || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ocasion",
|
||||||
|
title: "مناسبت",
|
||||||
|
render: (item: Campaign) => item.ocasion || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "sentType",
|
||||||
|
title: "نوع ارسال",
|
||||||
|
render: (item: Campaign) => sentTypeLabels[item.sentType] || item.sentType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "groups",
|
||||||
|
title: "گروهها",
|
||||||
|
render: (item: Campaign) => getGroupNames(item.groups),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
title: "وضعیت",
|
||||||
|
render: (item: Campaign) => (
|
||||||
|
<Status variant={getStatusVariant(item.status)} label={statusLabels[item.status] || item.status} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "totalCount",
|
||||||
|
title: "تعداد کل",
|
||||||
|
render: (item: Campaign) => formatFaNumber(item.totalCount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "sentCount",
|
||||||
|
title: "ارسال موفق",
|
||||||
|
render: (item: Campaign) => formatFaNumber(item.sentCount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "failedCount",
|
||||||
|
title: "ارسال ناموفق",
|
||||||
|
render: (item: Campaign) => formatFaNumber(item.failedCount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "totalCost",
|
||||||
|
title: "هزینه کل",
|
||||||
|
render: (item: Campaign) => formatPrice(item.totalCost),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "refunded",
|
||||||
|
title: "بازگشتی",
|
||||||
|
render: (item: Campaign) => formatPrice(item.refunded),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "createdAt",
|
||||||
|
title: "تاریخ ایجاد",
|
||||||
|
render: (item: Campaign) =>
|
||||||
|
formatOptionalDate(item.createdAt, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import * as api from "../service/CampaignService";
|
||||||
|
import type { GetCampaignsParams } from "../types/Types";
|
||||||
|
|
||||||
|
export const useGetCampaigns = (params?: GetCampaignsParams) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["campaigns", params],
|
||||||
|
queryFn: () => api.getCampaigns(params),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetCampaignById = (id: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["campaign", id],
|
||||||
|
queryFn: () => api.getCampaignById(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCreateDiscountCampaign = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: api.createDiscountCampaign,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteCampaign = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: api.deleteCampaign,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import type { FilterValues } from "@/components/Filters";
|
||||||
|
import type { GetCampaignsParams } from "../types/Types";
|
||||||
|
|
||||||
|
const DEFAULT_PAGE = 1;
|
||||||
|
const DEFAULT_LIMIT = 10;
|
||||||
|
|
||||||
|
export const useCampaignFilters = () => {
|
||||||
|
const [filters, setFilters] = useState<FilterValues>({});
|
||||||
|
const [currentPage, setCurrentPage] = useState(DEFAULT_PAGE);
|
||||||
|
const [limit] = useState(DEFAULT_LIMIT);
|
||||||
|
|
||||||
|
const apiParams = useMemo<GetCampaignsParams>(() => {
|
||||||
|
const params: GetCampaignsParams = {
|
||||||
|
page: currentPage,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (filters.search) {
|
||||||
|
params.search = filters.search as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}, [filters, currentPage, limit]);
|
||||||
|
|
||||||
|
const handleFiltersChange = (newFilters: FilterValues) => {
|
||||||
|
setFilters(newFilters);
|
||||||
|
setCurrentPage(DEFAULT_PAGE);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
filters,
|
||||||
|
currentPage,
|
||||||
|
apiParams,
|
||||||
|
handleFiltersChange,
|
||||||
|
handlePageChange,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import axios from "@/config/axios";
|
||||||
|
import type {
|
||||||
|
CampaignResponse,
|
||||||
|
CampaignsResponse,
|
||||||
|
CreateDiscountCampaignType,
|
||||||
|
GetCampaignsParams,
|
||||||
|
} from "../types/Types";
|
||||||
|
|
||||||
|
export const getCampaigns = async (
|
||||||
|
params?: GetCampaignsParams
|
||||||
|
): Promise<CampaignsResponse> => {
|
||||||
|
const { data } = await axios.get<CampaignsResponse>("/admin/campaigns", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCampaignById = async (id: string): Promise<CampaignResponse> => {
|
||||||
|
const { data } = await axios.get<CampaignResponse>(`/admin/campaigns/${id}`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createDiscountCampaign = async (
|
||||||
|
params: CreateDiscountCampaignType
|
||||||
|
) => {
|
||||||
|
const { data } = await axios.post("/admin/campaigns/discount", params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteCampaign = async (id: string) => {
|
||||||
|
const { data } = await axios.delete(`/admin/campaigns/${id}`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { UserGroup } from "@/pages/customers/groups/types/Types";
|
||||||
|
import type { IResponse } from "@/types/response.types";
|
||||||
|
|
||||||
|
export type CampaignSentType = "push" | "sms";
|
||||||
|
|
||||||
|
export type CampaignStatus = "pending" | "processing" | "completed" | "failed" | "partial";
|
||||||
|
|
||||||
|
export type CampaignGroup = UserGroup & {
|
||||||
|
restaurant?: string;
|
||||||
|
count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Campaign = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
restaurant?: string;
|
||||||
|
title: string;
|
||||||
|
groups: CampaignGroup[] | string[];
|
||||||
|
sentType: CampaignSentType;
|
||||||
|
discountCode: string;
|
||||||
|
ocasion?: string;
|
||||||
|
totalCount: number;
|
||||||
|
totalCost: number;
|
||||||
|
refunded: number;
|
||||||
|
sentCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
status: CampaignStatus | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaginationMeta = {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetCampaignsParams = {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
search?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignsResponse = IResponse<Campaign[]> & {
|
||||||
|
meta?: PaginationMeta;
|
||||||
|
};
|
||||||
|
export type CampaignResponse = IResponse<Campaign>;
|
||||||
|
|
||||||
|
export type CreateDiscountCampaignType = {
|
||||||
|
title: string;
|
||||||
|
groups: string[];
|
||||||
|
sentType: CampaignSentType;
|
||||||
|
discountCode: string;
|
||||||
|
ocasion: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import Button from "@/components/Button";
|
||||||
|
import Input from "@/components/Input";
|
||||||
|
import { extractErrorMessage } from "@/config/func";
|
||||||
|
import { Pages } from "@/config/Pages";
|
||||||
|
import type { ErrorType } from "@/helpers/types";
|
||||||
|
import { useFormik } from "formik";
|
||||||
|
import { TickCircle } from "iconsax-react";
|
||||||
|
import { type FC } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import * as Yup from "yup";
|
||||||
|
import { useCreateUserGroup } from "./hooks/useUserGroupData";
|
||||||
|
import type { CreateUserGroupType } from "./types/Types";
|
||||||
|
|
||||||
|
const CreateUserGroup: FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { mutate: createUserGroup, isPending } = useCreateUserGroup();
|
||||||
|
|
||||||
|
const formik = useFormik<CreateUserGroupType>({
|
||||||
|
initialValues: {
|
||||||
|
name: "",
|
||||||
|
},
|
||||||
|
validationSchema: Yup.object().shape({
|
||||||
|
name: Yup.string().required("نام گروه الزامی است"),
|
||||||
|
}),
|
||||||
|
onSubmit: (values) => {
|
||||||
|
createUserGroup(values, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("گروه با موفقیت ایجاد شد");
|
||||||
|
navigate(Pages.customers.groups);
|
||||||
|
},
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
toast.error(extractErrorMessage(error));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="flex w-full justify-between items-center">
|
||||||
|
<div className="text-lg font-light">افزودن گروه مشتری</div>
|
||||||
|
<Button className="px-5 w-fit" onClick={() => formik.handleSubmit()} isloading={isPending}>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<TickCircle className="size-5" color="white" />
|
||||||
|
<div>ثبت گروه</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="bg-white rounded-3xl p-6">
|
||||||
|
<Input label="نام گروه" name="name" value={formik.values.name} onChange={formik.handleChange} error_text={formik.touched.name && formik.errors.name ? formik.errors.name : ""} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateUserGroup;
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { type FC } from "react";
|
||||||
|
import Table from "@/components/Table";
|
||||||
|
import Filters from "@/components/Filters";
|
||||||
|
import { Add } from "iconsax-react";
|
||||||
|
import Button from "@/components/Button";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Pages } from "@/config/Pages";
|
||||||
|
import {
|
||||||
|
useDeleteUserGroup,
|
||||||
|
useGetUserGroups,
|
||||||
|
} from "./hooks/useUserGroupData";
|
||||||
|
import { useUserGroupFilters } from "./hooks/useUserGroupFilters";
|
||||||
|
import { getUserGroupTableColumns } from "./components/UserGroupTableColumns";
|
||||||
|
import { useUserGroupFiltersFields } from "./components/UserGroupFiltersFields";
|
||||||
|
|
||||||
|
const UserGroupsList: FC = () => {
|
||||||
|
const {
|
||||||
|
filters,
|
||||||
|
currentPage,
|
||||||
|
apiParams,
|
||||||
|
handleFiltersChange,
|
||||||
|
handlePageChange,
|
||||||
|
limit,
|
||||||
|
} = useUserGroupFilters();
|
||||||
|
|
||||||
|
const { data: groupsData, isLoading } = useGetUserGroups(apiParams);
|
||||||
|
const { mutate: deleteUserGroup, isPending: isDeleting } =
|
||||||
|
useDeleteUserGroup();
|
||||||
|
|
||||||
|
const groups = groupsData?.data || [];
|
||||||
|
const columns = getUserGroupTableColumns({
|
||||||
|
onDelete: (id: string) => deleteUserGroup(id),
|
||||||
|
isDeleting,
|
||||||
|
});
|
||||||
|
const filterFields = useUserGroupFiltersFields();
|
||||||
|
const totalPages = Math.ceil(groups.length / limit) || 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="text-lg font-light">گروههای مشتری</h1>
|
||||||
|
<Link to={Pages.customers.groupsAdd}>
|
||||||
|
<Button className="w-fit px-6">
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Add color="#fff" size={20} />
|
||||||
|
<span>افزودن گروه</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<Filters
|
||||||
|
fields={filterFields}
|
||||||
|
onChange={handleFiltersChange}
|
||||||
|
initialValues={filters}
|
||||||
|
searchField="search"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={groups}
|
||||||
|
isloading={isLoading}
|
||||||
|
pagination={{
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
onPageChange: handlePageChange,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserGroupsList;
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import Button from "@/components/Button";
|
||||||
|
import Input from "@/components/Input";
|
||||||
|
import PageLoading from "@/components/PageLoading";
|
||||||
|
import { extractErrorMessage } from "@/config/func";
|
||||||
|
import { Pages } from "@/config/Pages";
|
||||||
|
import type { ErrorType } from "@/helpers/types";
|
||||||
|
import { useFormik } from "formik";
|
||||||
|
import { TickCircle } from "iconsax-react";
|
||||||
|
import { type FC, useEffect } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import * as Yup from "yup";
|
||||||
|
import { useGetUserGroup, useUpdateUserGroup } from "./hooks/useUserGroupData";
|
||||||
|
import type { CreateUserGroupType } from "./types/Types";
|
||||||
|
import GroupUsersSection from "./components/GroupUsersSection";
|
||||||
|
|
||||||
|
const UpdateUserGroup: FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { data: groupData, isLoading } = useGetUserGroup(id || "");
|
||||||
|
const { mutate: updateUserGroup, isPending } = useUpdateUserGroup();
|
||||||
|
|
||||||
|
const group = groupData?.data;
|
||||||
|
|
||||||
|
const formik = useFormik<CreateUserGroupType>({
|
||||||
|
initialValues: {
|
||||||
|
name: "",
|
||||||
|
},
|
||||||
|
validationSchema: Yup.object().shape({
|
||||||
|
name: Yup.string().required("نام گروه الزامی است"),
|
||||||
|
}),
|
||||||
|
onSubmit: (values) => {
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
updateUserGroup(
|
||||||
|
{ id, params: values },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("گروه با موفقیت ویرایش شد");
|
||||||
|
navigate(Pages.customers.groups);
|
||||||
|
},
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
toast.error(extractErrorMessage(error));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (group) {
|
||||||
|
formik.setValues({
|
||||||
|
name: group.name || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [group]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <PageLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return (
|
||||||
|
<div className="w-full mt-4 flex justify-center items-center">
|
||||||
|
<div>گروه یافت نشد</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="flex w-full justify-between items-center">
|
||||||
|
<div className="text-lg font-light">ویرایش گروه مشتری</div>
|
||||||
|
<Button className="px-5 w-fit" onClick={() => formik.handleSubmit()} isloading={isPending}>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<TickCircle className="size-5" color="white" />
|
||||||
|
<div>ثبت تغییرات</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 bg-white rounded-3xl p-6">
|
||||||
|
<Input
|
||||||
|
label="نام گروه"
|
||||||
|
name="name"
|
||||||
|
value={formik.values.name}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
error_text={formik.touched.name && formik.errors.name ? formik.errors.name : ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{id && <GroupUsersSection groupId={id} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UpdateUserGroup;
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import Button from "@/components/Button";
|
||||||
|
import DefaulModal from "@/components/DefaulModal";
|
||||||
|
import Input from "@/components/Input";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { extractErrorMessage } from "@/config/func";
|
||||||
|
import type { ErrorType } from "@/helpers/types";
|
||||||
|
import { useGetUsers } from "@/pages/customers/hooks/useUsersData";
|
||||||
|
import type { User } from "@/pages/customers/types/Types";
|
||||||
|
import { type FC, useMemo, useState } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { useAddUsersToGroup } from "../hooks/useUserGroupData";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
groupId: string;
|
||||||
|
existingUserIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const AddUsersModal: FC<Props> = ({ open, onClose, groupId, existingUserIds }) => {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const { data: usersData, isLoading } = useGetUsers({
|
||||||
|
search: search || undefined,
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: addUsersToGroup, isPending } = useAddUsersToGroup();
|
||||||
|
|
||||||
|
const availableUsers = useMemo(() => {
|
||||||
|
const users =
|
||||||
|
usersData?.data?.map((userRestaurant) => userRestaurant.user) ?? [];
|
||||||
|
return users.filter((user) => !existingUserIds.includes(user.id));
|
||||||
|
}, [usersData?.data, existingUserIds]);
|
||||||
|
|
||||||
|
const handleToggle = (userId: string, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedUserIds((prev) => [...prev, userId]);
|
||||||
|
} else {
|
||||||
|
setSelectedUserIds((prev) => prev.filter((id) => id !== userId));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedUserIds(availableUsers.map((user) => user.id));
|
||||||
|
} else {
|
||||||
|
setSelectedUserIds([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAllSelected = useMemo(() => {
|
||||||
|
return (
|
||||||
|
availableUsers.length > 0 &&
|
||||||
|
availableUsers.every((user) => selectedUserIds.includes(user.id))
|
||||||
|
);
|
||||||
|
}, [availableUsers, selectedUserIds]);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setSearch("");
|
||||||
|
setSelectedUserIds([]);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (selectedUserIds.length === 0) {
|
||||||
|
toast.error("حداقل یک کاربر باید انتخاب شود");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addUsersToGroup(
|
||||||
|
{ groupId, params: { userIds: selectedUserIds } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("کاربران با موفقیت به گروه اضافه شدند");
|
||||||
|
handleClose();
|
||||||
|
},
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
toast.error(extractErrorMessage(error));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserLabel = (user: User) => {
|
||||||
|
const fullName = [user.firstName, user.lastName].filter(Boolean).join(" ").trim();
|
||||||
|
return fullName ? `${fullName} - ${user.phone}` : user.phone;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaulModal
|
||||||
|
open={open}
|
||||||
|
close={handleClose}
|
||||||
|
isHeader
|
||||||
|
title_header="افزودن کاربر به گروه"
|
||||||
|
width={520}
|
||||||
|
>
|
||||||
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
|
<Input
|
||||||
|
variant="search"
|
||||||
|
className="bg-[#EEF0F7]"
|
||||||
|
placeholder="جستجو با نام یا شماره تماس"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="border border-border rounded-xl p-4 max-h-72 overflow-y-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-sm text-gray-500 text-center py-4">در حال بارگذاری...</div>
|
||||||
|
) : availableUsers.length === 0 ? (
|
||||||
|
<div className="text-sm text-gray-500 text-center py-4">کاربری یافت نشد</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} />
|
||||||
|
<label className="text-sm cursor-pointer">همه</label>
|
||||||
|
</div>
|
||||||
|
{availableUsers.map((user) => (
|
||||||
|
<div key={user.id} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedUserIds.includes(user.id)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleToggle(user.id, checked as boolean)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label className="text-sm cursor-pointer">{getUserLabel(user)}</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button className="px-6 w-fit" onClick={handleSubmit} isloading={isPending}>
|
||||||
|
افزودن کاربران
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DefaulModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddUsersModal;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { ColumnType } from "@/components/types/TableTypes";
|
||||||
|
import TrashWithConfrim from "@/components/TrashWithConfrim";
|
||||||
|
import type { User } from "@/pages/customers/types/Types";
|
||||||
|
|
||||||
|
interface GetGroupUserTableColumnsParams {
|
||||||
|
onDelete?: (userId: string) => void;
|
||||||
|
isDeleting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getGroupUserTableColumns = ({
|
||||||
|
onDelete,
|
||||||
|
isDeleting,
|
||||||
|
}: GetGroupUserTableColumnsParams = {}): ColumnType<User>[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "fullName",
|
||||||
|
title: "نام و نام خانوادگی",
|
||||||
|
render: (item: User) => {
|
||||||
|
const fullName = [item.firstName, item.lastName].filter(Boolean).join(" ").trim();
|
||||||
|
return fullName || "بدون نام";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "phone",
|
||||||
|
title: "شماره تماس",
|
||||||
|
render: (item: User) => item.phone || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
title: "",
|
||||||
|
render: (item: User) =>
|
||||||
|
onDelete ? (
|
||||||
|
<TrashWithConfrim
|
||||||
|
onDelete={() => onDelete(item.id)}
|
||||||
|
isloading={isDeleting}
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import Button from "@/components/Button";
|
||||||
|
import Table from "@/components/Table";
|
||||||
|
import { extractErrorMessage } from "@/config/func";
|
||||||
|
import type { ErrorType } from "@/helpers/types";
|
||||||
|
import { Add } from "iconsax-react";
|
||||||
|
import { type FC, useMemo, useState } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import {
|
||||||
|
useGetGroupUsers,
|
||||||
|
useRemoveUserFromGroup,
|
||||||
|
} from "../hooks/useUserGroupData";
|
||||||
|
import AddUsersModal from "./AddUsersModal";
|
||||||
|
import { getGroupUserTableColumns } from "./GroupUserTableColumns";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
groupId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GroupUsersSection: FC<Props> = ({ groupId }) => {
|
||||||
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data: groupUsersData, isLoading } = useGetGroupUsers(groupId);
|
||||||
|
const { mutate: removeUserFromGroup, isPending: isRemoving } =
|
||||||
|
useRemoveUserFromGroup();
|
||||||
|
|
||||||
|
const users = groupUsersData?.data ?? [];
|
||||||
|
const existingUserIds = useMemo(() => users.map((user) => user.id), [users]);
|
||||||
|
|
||||||
|
const columns = getGroupUserTableColumns({
|
||||||
|
onDelete: (userId) => {
|
||||||
|
removeUserFromGroup(
|
||||||
|
{ groupId, userId },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("کاربر از گروه حذف شد");
|
||||||
|
},
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
toast.error(extractErrorMessage(error));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
isDeleting: isRemoving,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="bg-white rounded-3xl p-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<div className="text-sm">کاربران گروه</div>
|
||||||
|
<Button className="w-fit px-5" onClick={() => setIsAddModalOpen(true)}>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Add color="#fff" size={18} />
|
||||||
|
<span>افزودن کاربر</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table columns={columns} data={users} isloading={isLoading} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AddUsersModal
|
||||||
|
open={isAddModalOpen}
|
||||||
|
onClose={() => setIsAddModalOpen(false)}
|
||||||
|
groupId={groupId}
|
||||||
|
existingUserIds={existingUserIds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupUsersSection;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import type { FieldType } from "@/components/Filters";
|
||||||
|
|
||||||
|
export const useUserGroupFiltersFields = (): FieldType[] => {
|
||||||
|
return useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
type: "input",
|
||||||
|
name: "search",
|
||||||
|
placeholder: "جستجو",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { ColumnType } from "@/components/types/TableTypes";
|
||||||
|
import type { UserGroup } from "../types/Types";
|
||||||
|
import TrashWithConfrim from "@/components/TrashWithConfrim";
|
||||||
|
import { Eye } from "iconsax-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Pages } from "@/config/Pages";
|
||||||
|
import { formatOptionalDate } from "@/helpers/func";
|
||||||
|
|
||||||
|
interface GetUserGroupTableColumnsParams {
|
||||||
|
onDelete?: (id: string) => void;
|
||||||
|
isDeleting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getUserGroupTableColumns = ({
|
||||||
|
onDelete,
|
||||||
|
isDeleting,
|
||||||
|
}: GetUserGroupTableColumnsParams = {}): ColumnType<UserGroup>[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
title: "نام گروه",
|
||||||
|
render: (item: UserGroup) => item.name || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "createdAt",
|
||||||
|
title: "تاریخ ایجاد",
|
||||||
|
render: (item: UserGroup) => formatOptionalDate(item.createdAt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
title: "",
|
||||||
|
render: (item: UserGroup) => (
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Link to={Pages.customers.groupsUpdate + item.id}>
|
||||||
|
<Eye size={20} color="#8C90A3" className="cursor-pointer" />
|
||||||
|
</Link>
|
||||||
|
{onDelete && (
|
||||||
|
<TrashWithConfrim
|
||||||
|
onDelete={() => onDelete(item.id)}
|
||||||
|
isloading={isDeleting}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import * as api from "../service/UserGroupService";
|
||||||
|
import type {
|
||||||
|
AddUsersToGroupType,
|
||||||
|
CreateUserGroupType,
|
||||||
|
GetUserGroupsParams,
|
||||||
|
} from "../types/Types";
|
||||||
|
|
||||||
|
export const useGetUserGroups = (params?: GetUserGroupsParams) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["user-groups", params],
|
||||||
|
queryFn: () => api.getUserGroups(params),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCreateUserGroup = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: api.createUserGroup,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["user-groups"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteUserGroup = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: api.deleteUserGroup,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["user-groups"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetUserGroup = (id: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["user-group", id],
|
||||||
|
queryFn: () => api.getUserGroup(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateUserGroup = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, params }: { id: string; params: CreateUserGroupType }) =>
|
||||||
|
api.updateUserGroup(id, params),
|
||||||
|
onSuccess: (_, { id }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["user-groups"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["user-group", id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetGroupUsers = (groupId: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["user-group-users", groupId],
|
||||||
|
queryFn: () => api.getGroupUsers(groupId),
|
||||||
|
enabled: !!groupId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAddUsersToGroup = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
groupId,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
groupId: string;
|
||||||
|
params: AddUsersToGroupType;
|
||||||
|
}) => api.addUsersToGroup(groupId, params),
|
||||||
|
onSuccess: (_, { groupId }) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["user-group-users", groupId],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRemoveUserFromGroup = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
groupId,
|
||||||
|
userId,
|
||||||
|
}: {
|
||||||
|
groupId: string;
|
||||||
|
userId: string;
|
||||||
|
}) => api.removeUserFromGroup(groupId, userId),
|
||||||
|
onSuccess: (_, { groupId }) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["user-group-users", groupId],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import type { FilterValues } from "@/components/Filters";
|
||||||
|
|
||||||
|
const DEFAULT_PAGE = 1;
|
||||||
|
const DEFAULT_LIMIT = 10;
|
||||||
|
|
||||||
|
export const useUserGroupFilters = () => {
|
||||||
|
const [filters, setFilters] = useState<FilterValues>({});
|
||||||
|
const [currentPage, setCurrentPage] = useState(DEFAULT_PAGE);
|
||||||
|
const [limit] = useState(DEFAULT_LIMIT);
|
||||||
|
|
||||||
|
const apiParams = useMemo(() => {
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
page: currentPage,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (filters.search) {
|
||||||
|
params.search = filters.search;
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}, [filters, currentPage, limit]);
|
||||||
|
|
||||||
|
const handleFiltersChange = (newFilters: FilterValues) => {
|
||||||
|
setFilters(newFilters);
|
||||||
|
setCurrentPage(DEFAULT_PAGE);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
filters,
|
||||||
|
currentPage,
|
||||||
|
apiParams,
|
||||||
|
handleFiltersChange,
|
||||||
|
handlePageChange,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import axios from "@/config/axios";
|
||||||
|
import type {
|
||||||
|
AddUsersToGroupType,
|
||||||
|
CreateUserGroupType,
|
||||||
|
GetUserGroupsParams,
|
||||||
|
UserGroupResponse,
|
||||||
|
UserGroupsResponse,
|
||||||
|
UsersInGroupResponse,
|
||||||
|
} from "../types/Types";
|
||||||
|
|
||||||
|
export const getUserGroups = async (
|
||||||
|
params?: GetUserGroupsParams
|
||||||
|
): Promise<UserGroupsResponse> => {
|
||||||
|
const { data } = await axios.get<UserGroupsResponse>("/admin/user-groups", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createUserGroup = async (params: CreateUserGroupType) => {
|
||||||
|
const { data } = await axios.post("/admin/user-groups", params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteUserGroup = async (id: string) => {
|
||||||
|
const { data } = await axios.delete(`/admin/user-groups/${id}`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserGroup = async (id: string): Promise<UserGroupResponse> => {
|
||||||
|
const { data } = await axios.get<UserGroupResponse>(
|
||||||
|
`/admin/user-groups/${id}`
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateUserGroup = async (
|
||||||
|
id: string,
|
||||||
|
params: CreateUserGroupType
|
||||||
|
) => {
|
||||||
|
const { data } = await axios.patch(`/admin/user-groups/${id}`, params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getGroupUsers = async (
|
||||||
|
groupId: string
|
||||||
|
): Promise<UsersInGroupResponse> => {
|
||||||
|
const { data } = await axios.get<UsersInGroupResponse>(
|
||||||
|
`/admin/user-groups/${groupId}/users`
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addUsersToGroup = async (
|
||||||
|
groupId: string,
|
||||||
|
params: AddUsersToGroupType
|
||||||
|
) => {
|
||||||
|
const { data } = await axios.post(
|
||||||
|
`/admin/user-groups/${groupId}/users`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeUserFromGroup = async (groupId: string, userId: string) => {
|
||||||
|
const { data } = await axios.delete(
|
||||||
|
`/admin/user-groups/${groupId}/users/${userId}`
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { User } from "@/pages/customers/types/Types";
|
||||||
|
import type { IResponse } from "@/types/response.types";
|
||||||
|
|
||||||
|
export type UserGroup = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserGroupsResponse = IResponse<UserGroup[]>;
|
||||||
|
export type UserGroupResponse = IResponse<UserGroup>;
|
||||||
|
|
||||||
|
export type CreateUserGroupType = {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetUserGroupsParams = {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
search?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UsersInGroupResponse = IResponse<User[]>;
|
||||||
|
|
||||||
|
export type AddUsersToGroupType = {
|
||||||
|
userIds: string[];
|
||||||
|
};
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import * as api from "../service/UsersService";
|
import * as api from "../service/UsersService";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { CreateUserAddressType, GetUsersParams } from "../types/Types";
|
import type {
|
||||||
|
CreateUserAddressType,
|
||||||
|
GetUsersParams,
|
||||||
|
UpdateUserType,
|
||||||
|
} from "../types/Types";
|
||||||
|
|
||||||
export const useGetUsers = (params?: GetUsersParams) => {
|
export const useGetUsers = (params?: GetUsersParams) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -9,6 +13,14 @@ export const useGetUsers = (params?: GetUsersParams) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useGetUserGroupsForUser = (userId: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["user-groups-for-user", userId],
|
||||||
|
queryFn: () => api.getUserGroups(userId),
|
||||||
|
enabled: !!userId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const useSearchUserByPhone = () => {
|
export const useSearchUserByPhone = () => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: api.searchUserByPhone,
|
mutationFn: api.searchUserByPhone,
|
||||||
@@ -26,6 +38,19 @@ export const useCreateUser = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useUpdateUser = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ userId, params }: { userId: string; params: UpdateUserType }) =>
|
||||||
|
api.updateUser(userId, params),
|
||||||
|
onSuccess: (_, { userId }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["user-groups-for-user", userId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const useCreateUserAddress = () => {
|
export const useCreateUserAddress = () => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import type {
|
|||||||
CreateUserResponse,
|
CreateUserResponse,
|
||||||
CreateUserType,
|
CreateUserType,
|
||||||
GetCustomersResponse,
|
GetCustomersResponse,
|
||||||
|
GetUserGroupsForUserResponse,
|
||||||
GetUsersParams,
|
GetUsersParams,
|
||||||
ImportUsersResponse,
|
ImportUsersResponse,
|
||||||
SearchUserByPhoneResponse,
|
SearchUserByPhoneResponse,
|
||||||
|
UpdateUserResponse,
|
||||||
|
UpdateUserType,
|
||||||
} from "@/pages/customers/types/Types";
|
} from "@/pages/customers/types/Types";
|
||||||
|
|
||||||
export const getUsers = async (params?: GetUsersParams) => {
|
export const getUsers = async (params?: GetUsersParams) => {
|
||||||
@@ -30,6 +33,21 @@ export const createUser = async (params: CreateUserType) => {
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const updateUser = async (userId: string, params: UpdateUserType) => {
|
||||||
|
const { data } = await axios.patch<UpdateUserResponse>(
|
||||||
|
`/admin/users/${userId}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserGroups = async (userId: string) => {
|
||||||
|
const { data } = await axios.get<GetUserGroupsForUserResponse>(
|
||||||
|
`/admin/users/${userId}/groups`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
export const createUserAddress = async (
|
export const createUserAddress = async (
|
||||||
userId: string,
|
userId: string,
|
||||||
params: CreateUserAddressType,
|
params: CreateUserAddressType,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { IResponse } from "@/types/response.types";
|
import type { IResponse } from "@/types/response.types";
|
||||||
import type { RowDataType } from "@/components/types/TableTypes";
|
import type { RowDataType } from "@/components/types/TableTypes";
|
||||||
|
import type { UserGroup } from "@/pages/customers/groups/types/Types";
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -76,6 +77,12 @@ export type CustomerListRow = RowDataType & {
|
|||||||
orderCount: string;
|
orderCount: string;
|
||||||
totalOrderAmount: string;
|
totalOrderAmount: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
birthDateRaw?: string;
|
||||||
|
marriageDateRaw?: string;
|
||||||
|
gender?: boolean;
|
||||||
|
avatarUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ImportUserRowError = {
|
export type ImportUserRowError = {
|
||||||
@@ -105,6 +112,7 @@ export type CreateUserType = {
|
|||||||
marriageDate?: string;
|
marriageDate?: string;
|
||||||
gender?: boolean;
|
gender?: boolean;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
groupIds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateUserResult = {
|
export type CreateUserResult = {
|
||||||
@@ -129,3 +137,17 @@ export type CreateUserAddressType = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type CreateUserAddressResponse = IResponse<UserSavedAddress>;
|
export type CreateUserAddressResponse = IResponse<UserSavedAddress>;
|
||||||
|
|
||||||
|
export type UpdateUserType = {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
birthDate?: string;
|
||||||
|
marriageDate?: string;
|
||||||
|
gender?: boolean;
|
||||||
|
avatarUrl?: string;
|
||||||
|
groupIds?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateUserResponse = IResponse<User>;
|
||||||
|
|
||||||
|
export type GetUserGroupsForUserResponse = IResponse<UserGroup[]>;
|
||||||
|
|||||||
+126
-301
@@ -1,310 +1,135 @@
|
|||||||
import { type FC, useState } from 'react'
|
import Button from "@/components/Button";
|
||||||
import { useFormik } from 'formik'
|
import type { ErrorType } from "@/helpers/types";
|
||||||
import * as Yup from 'yup'
|
import { useFormik } from "formik";
|
||||||
import { TickCircle } from 'iconsax-react'
|
import { TickCircle } from "iconsax-react";
|
||||||
import { toast } from 'react-toastify'
|
import { type FC, useState } from "react";
|
||||||
import Input from '@/components/Input'
|
import { useNavigate } from "react-router-dom";
|
||||||
import Select from '@/components/Select'
|
import { toast } from "react-toastify";
|
||||||
import Textarea from '@/components/Textarea'
|
import * as Yup from "yup";
|
||||||
import Button from '@/components/Button'
|
import { useMultipleUpload } from "../uploader/hooks/useUploaderData";
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import BasicInfoSection from "./components/BasicInfoSection";
|
||||||
import CreateFoodSidebar from './components/CreateFoodSidebar'
|
import CreateFoodSidebar from "./components/CreateFoodSidebar";
|
||||||
import WeekDaysSection from './components/WeekDaysSection'
|
import FoodContentSection from "./components/FoodContentSection";
|
||||||
import MealTimesSection from './components/MealTimesSection'
|
import MealTimesSection from "./components/MealTimesSection";
|
||||||
import type { CreateFoodType } from './types/Types'
|
import ServiceOptionsSection from "./components/ServiceOptionsSection";
|
||||||
import { useCreateFood, useGetCategories } from './hooks/useFoodData'
|
import WeekDaysSection from "./components/WeekDaysSection";
|
||||||
import { useMultipleUpload } from '../uploader/hooks/useUploaderData'
|
import { useCreateFood, useGetCategories } from "./hooks/useFoodData";
|
||||||
import { Pages } from '@/config/Pages'
|
import type { CreateFoodType } from "./types/Types";
|
||||||
import type { ErrorType } from '@/helpers/types'
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const CreateFood: FC = () => {
|
const CreateFood: FC = () => {
|
||||||
|
const { data } = useGetCategories();
|
||||||
|
const [isActive, setIsActive] = useState<boolean>(true);
|
||||||
|
const [isSpecial, setIsSpecial] = useState<boolean>(false);
|
||||||
|
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||||
|
const { mutate: createFood, isPending } = useCreateFood();
|
||||||
|
const { mutate: multipleUpload, isPending: isUploading } = useMultipleUpload();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const categories =
|
||||||
|
data?.data?.map((category) => ({
|
||||||
|
label: category.title,
|
||||||
|
value: category.id,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
const { data } = useGetCategories();
|
const formik = useFormik<CreateFoodType>({
|
||||||
const [isActive, setIsActive] = useState<boolean>(true)
|
initialValues: {
|
||||||
const [isSpecial, setIsSpecial] = useState<boolean>(false)
|
title: "",
|
||||||
const [imageFiles, setImageFiles] = useState<File[]>([])
|
desc: "",
|
||||||
const { mutate: createFood, isPending } = useCreateFood()
|
content: [],
|
||||||
const { mutate: multipleUpload, isPending: isUploading } = useMultipleUpload()
|
categoryId: "",
|
||||||
|
price: 0,
|
||||||
|
discount: 0,
|
||||||
|
prepareTime: 0,
|
||||||
|
weekDays: [0, 1, 2, 3, 4, 5, 6],
|
||||||
|
mealTypes: ["breakfast", "lunch", "dinner"],
|
||||||
|
pickupServe: true,
|
||||||
|
inPlaceServe: true,
|
||||||
|
isActive: true,
|
||||||
|
images: [],
|
||||||
|
isSpecialOffer: false,
|
||||||
|
dailyStock: 0,
|
||||||
|
availableStock: 0,
|
||||||
|
order: 0,
|
||||||
|
},
|
||||||
|
validationSchema: Yup.object().shape({
|
||||||
|
title: Yup.string().required("نام غذا الزامی است"),
|
||||||
|
categoryId: Yup.string().required("دستهبندی الزامی است"),
|
||||||
|
price: Yup.number().required("قیمت الزامی است").min(0, "قیمت باید مثبت باشد"),
|
||||||
|
prepareTime: Yup.number().required("زمان آمادهسازی الزامی است").min(0, "زمان آمادهسازی باید مثبت باشد"),
|
||||||
|
discount: Yup.number().min(0, "تخفیف باید مثبت باشد"),
|
||||||
|
dailyStock: Yup.number().required("موجودی روزانه الزامی است").min(0, "موجودی روزانه باید مثبت باشد"),
|
||||||
|
availableStock: Yup.number().required("موجودی فعلی الزامی است").min(0, "موجودی فعلی باید مثبت باشد"),
|
||||||
|
content: Yup.array().of(Yup.string()),
|
||||||
|
weekDays: Yup.array().of(Yup.number()),
|
||||||
|
mealTypes: Yup.array().of(Yup.string()),
|
||||||
|
}),
|
||||||
|
onSubmit: (values) => {
|
||||||
|
const submitFood = (imageUrls: string[] = []) => {
|
||||||
|
const foodData: CreateFoodType = {
|
||||||
|
...values,
|
||||||
|
isActive,
|
||||||
|
isSpecialOffer: isSpecial,
|
||||||
|
images: imageUrls,
|
||||||
|
content: values.content.filter((item) => item && item.trim().length > 0),
|
||||||
|
};
|
||||||
|
|
||||||
const categories = data?.data?.map(category => ({
|
createFood(foodData, {
|
||||||
label: category.title,
|
onSuccess: () => {
|
||||||
value: category.id
|
toast.success("غذا با موفقیت ایجاد شد");
|
||||||
})) || []
|
navigate(-1);
|
||||||
|
},
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
toast.error(error?.response?.data?.error?.message[0] || "خطا در ایجاد غذا");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const formik = useFormik<CreateFoodType>({
|
if (imageFiles.length > 0) {
|
||||||
initialValues: {
|
multipleUpload(imageFiles, {
|
||||||
title: '',
|
onSuccess: (response) => {
|
||||||
desc: '',
|
const imageUrls = response?.data?.map((item) => item.url) || [];
|
||||||
content: [],
|
submitFood(imageUrls);
|
||||||
categoryId: '',
|
},
|
||||||
price: 0,
|
onError: (error: ErrorType) => {
|
||||||
discount: 0,
|
toast.error(error?.response?.data?.error?.message[0] || "خطا در آپلود تصاویر");
|
||||||
prepareTime: 0,
|
},
|
||||||
weekDays: [0, 1, 2, 3, 4, 5, 6],
|
});
|
||||||
mealTypes: ['breakfast', 'lunch', 'dinner'],
|
} else {
|
||||||
pickupServe: true,
|
submitFood();
|
||||||
inPlaceServe: true,
|
}
|
||||||
isActive: true,
|
},
|
||||||
images: [],
|
});
|
||||||
isSpecialOffer: false,
|
|
||||||
dailyStock: 0,
|
|
||||||
availableStock: 0,
|
|
||||||
order: 0
|
|
||||||
},
|
|
||||||
validationSchema: Yup.object().shape({
|
|
||||||
title: Yup.string().required('نام غذا الزامی است'),
|
|
||||||
categoryId: Yup.string().required('دستهبندی الزامی است'),
|
|
||||||
price: Yup.number().required('قیمت الزامی است').min(0, 'قیمت باید مثبت باشد'),
|
|
||||||
prepareTime: Yup.number().required('زمان آمادهسازی الزامی است').min(0, 'زمان آمادهسازی باید مثبت باشد'),
|
|
||||||
discount: Yup.number().min(0, 'تخفیف باید مثبت باشد'),
|
|
||||||
dailyStock: Yup.number().required('موجودی روزانه الزامی است').min(0, 'موجودی روزانه باید مثبت باشد'),
|
|
||||||
availableStock: Yup.number()
|
|
||||||
.required('موجودی فعلی الزامی است')
|
|
||||||
.min(0, 'موجودی فعلی باید مثبت باشد'),
|
|
||||||
content: Yup.array().of(Yup.string()),
|
|
||||||
weekDays: Yup.array().of(Yup.number()),
|
|
||||||
mealTypes: Yup.array().of(Yup.string())
|
|
||||||
}),
|
|
||||||
onSubmit: (values) => {
|
|
||||||
const submitFood = (imageUrls: string[] = []) => {
|
|
||||||
const foodData: CreateFoodType = {
|
|
||||||
...values,
|
|
||||||
isActive,
|
|
||||||
isSpecialOffer: isSpecial,
|
|
||||||
images: imageUrls,
|
|
||||||
content: values.content.filter(item => item && item.trim().length > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
createFood(foodData, {
|
return (
|
||||||
onSuccess: () => {
|
<div className="w-full mt-4">
|
||||||
toast.success('غذا با موفقیت ایجاد شد')
|
<div className="flex flex-col sm:flex-row w-full justify-between items-start sm:items-center gap-4">
|
||||||
window.location.href = Pages.foods.list
|
<div className="text-lg font-light">غذای جدید</div>
|
||||||
},
|
<div>
|
||||||
onError: (error: ErrorType) => {
|
<Button className="px-5 w-full sm:w-auto" onClick={() => formik.handleSubmit()} isloading={isPending || isUploading}>
|
||||||
toast.error(error?.response?.data?.error?.message[0] || 'خطا در ایجاد غذا')
|
<div className="flex gap-2 items-center">
|
||||||
}
|
<TickCircle className="size-5" color="white" />
|
||||||
})
|
<div>ثبت غذا</div>
|
||||||
}
|
|
||||||
|
|
||||||
if (imageFiles.length > 0) {
|
|
||||||
multipleUpload(imageFiles, {
|
|
||||||
onSuccess: (response) => {
|
|
||||||
const imageUrls = response?.data?.map(item => item.url) || []
|
|
||||||
submitFood(imageUrls)
|
|
||||||
},
|
|
||||||
onError: (error: ErrorType) => {
|
|
||||||
toast.error(error?.response?.data?.error?.message[0] || 'خطا در آپلود تصاویر')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
submitFood()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='w-full mt-4'>
|
|
||||||
<div className='flex flex-col sm:flex-row w-full justify-between items-start sm:items-center gap-4'>
|
|
||||||
<div className='text-lg font-light'>
|
|
||||||
غذای جدید
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
className='px-5 w-full sm:w-auto'
|
|
||||||
onClick={() => formik.handleSubmit()}
|
|
||||||
isloading={isPending || isUploading}
|
|
||||||
>
|
|
||||||
<div className='flex gap-2 items-center'>
|
|
||||||
<TickCircle className='size-5' color='white' />
|
|
||||||
<div>ثبت غذا</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex flex-col lg:flex-row gap-6 mt-6'>
|
|
||||||
<div className='flex-1'>
|
|
||||||
<div className='bg-white py-6 sm:py-8 xl:px-10 px-4 rounded-3xl'>
|
|
||||||
<Input
|
|
||||||
label='نام غذا'
|
|
||||||
name='title'
|
|
||||||
placeholder=''
|
|
||||||
value={formik.values.title}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
error_text={formik.touched.title && formik.errors.title ? formik.errors.title : ''}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className='mt-5'>
|
|
||||||
<Select
|
|
||||||
items={categories}
|
|
||||||
label='دسته بندی'
|
|
||||||
placeholder='انتخاب کنید'
|
|
||||||
name='categoryId'
|
|
||||||
value={formik.values.categoryId}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value
|
|
||||||
formik.setFieldValue('categoryId', value)
|
|
||||||
}}
|
|
||||||
error_text={formik.touched.categoryId && formik.errors.categoryId ? String(formik.errors.categoryId) : ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 mt-5'>
|
|
||||||
<Input
|
|
||||||
name='price'
|
|
||||||
label='قیمت'
|
|
||||||
placeholder='تومان'
|
|
||||||
type='number'
|
|
||||||
seprator={true}
|
|
||||||
value={formik.values.price}
|
|
||||||
onChange={(e) => formik.setFieldValue('price', Number(e.target.value))}
|
|
||||||
error_text={formik.touched.price && formik.errors.price ? formik.errors.price : ''}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
name='discount'
|
|
||||||
label='تخفیف'
|
|
||||||
placeholder='تومان'
|
|
||||||
type='number'
|
|
||||||
seprator={true}
|
|
||||||
value={formik.values.discount}
|
|
||||||
onChange={(e) => formik.setFieldValue('discount', Number(e.target.value))}
|
|
||||||
error_text={formik.touched.discount && formik.errors.discount ? formik.errors.discount : ''}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
name='prepareTime'
|
|
||||||
label='زمان آماده سازی'
|
|
||||||
placeholder='دقیقه'
|
|
||||||
type='number'
|
|
||||||
value={formik.values.prepareTime}
|
|
||||||
onChange={(e) => formik.setFieldValue('prepareTime', Number(e.target.value))}
|
|
||||||
error_text={formik.touched.prepareTime && formik.errors.prepareTime ? formik.errors.prepareTime : ''}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
name='dailyStock'
|
|
||||||
label='موجودی روزانه'
|
|
||||||
placeholder='عدد'
|
|
||||||
type='number'
|
|
||||||
seprator={true}
|
|
||||||
value={formik.values.dailyStock}
|
|
||||||
onChange={(e) => formik.setFieldValue('dailyStock', Number(e.target.value))}
|
|
||||||
error_text={formik.touched.dailyStock && formik.errors.dailyStock ? formik.errors.dailyStock : ''}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
name='availableStock'
|
|
||||||
label='موجودی فعلی'
|
|
||||||
placeholder='عدد'
|
|
||||||
type='number'
|
|
||||||
seprator={true}
|
|
||||||
value={formik.values.availableStock}
|
|
||||||
onChange={(e) => formik.setFieldValue('availableStock', Number(e.target.value))}
|
|
||||||
error_text={formik.touched.availableStock && formik.errors.availableStock ? formik.errors.availableStock : ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mt-5'>
|
|
||||||
<Textarea
|
|
||||||
name='desc'
|
|
||||||
label='توضیحات غذا'
|
|
||||||
placeholder=''
|
|
||||||
value={formik.values.desc}
|
|
||||||
onChange={formik.handleChange}
|
|
||||||
error_text={formik.touched.desc && formik.errors.desc ? formik.errors.desc : ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mt-5'>
|
|
||||||
<Input
|
|
||||||
name='order'
|
|
||||||
label='اولویت'
|
|
||||||
placeholder='عدد'
|
|
||||||
type='number'
|
|
||||||
value={formik.values.order}
|
|
||||||
onChange={(e) => formik.setFieldValue('order', Number(e.target.value))}
|
|
||||||
error_text={formik.touched.order && formik.errors.order ? formik.errors.order : ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mt-5'>
|
|
||||||
<div className='text-sm mb-2'>محتوای غذا</div>
|
|
||||||
<div className='space-y-2'>
|
|
||||||
{formik.values.content.map((item, index) => (
|
|
||||||
<div key={index} className='flex gap-2 items-center'>
|
|
||||||
<Input
|
|
||||||
placeholder='متن محتوا'
|
|
||||||
value={item}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newContent = [...formik.values.content]
|
|
||||||
newContent[index] = e.target.value
|
|
||||||
formik.setFieldValue('content', newContent)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
className='px-3 bg-red-500 hover:bg-red-600 w-auto'
|
|
||||||
onClick={() => {
|
|
||||||
const newContent = formik.values.content.filter((_, i) => i !== index)
|
|
||||||
formik.setFieldValue('content', newContent)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
حذف
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
className='w-full bg-gray-200 text-gray-700 hover:bg-gray-300'
|
|
||||||
onClick={() => {
|
|
||||||
formik.setFieldValue('content', [...formik.values.content, ''])
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
افزودن محتوا
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MealTimesSection formik={formik} />
|
|
||||||
<WeekDaysSection formik={formik} />
|
|
||||||
|
|
||||||
<div className='mt-6'>
|
|
||||||
<div className='text-sm mb-3'>گزینههای سرویس</div>
|
|
||||||
<div className='flex gap-6 flex-wrap'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Checkbox
|
|
||||||
checked={formik.values.pickupServe}
|
|
||||||
onCheckedChange={(checked) => formik.setFieldValue('pickupServe', checked)}
|
|
||||||
/>
|
|
||||||
<label className='text-xs cursor-pointer'> بیرونبر</label>
|
|
||||||
</div>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Checkbox
|
|
||||||
checked={formik.values.inPlaceServe}
|
|
||||||
onCheckedChange={(checked) => formik.setFieldValue('inPlaceServe', checked)}
|
|
||||||
/>
|
|
||||||
<label className='text-xs cursor-pointer'>سرو در محل</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='w-full lg:w-auto'>
|
|
||||||
<CreateFoodSidebar
|
|
||||||
isActive={isActive}
|
|
||||||
isSpecial={isSpecial}
|
|
||||||
onChangeActive={setIsActive}
|
|
||||||
onChangeSpecial={setIsSpecial}
|
|
||||||
onChangeImages={setImageFiles}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
export default CreateFood
|
<div className="flex flex-col lg:flex-row gap-6 mt-6">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="bg-white py-6 sm:py-8 xl:px-10 px-4 rounded-3xl">
|
||||||
|
<BasicInfoSection formik={formik} categories={categories} />
|
||||||
|
<FoodContentSection formik={formik} />
|
||||||
|
<MealTimesSection formik={formik} />
|
||||||
|
<WeekDaysSection formik={formik} />
|
||||||
|
<ServiceOptionsSection formik={formik} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full lg:w-auto">
|
||||||
|
<CreateFoodSidebar isActive={isActive} isSpecial={isSpecial} onChangeActive={setIsActive} onChangeSpecial={setIsSpecial} onChangeImages={setImageFiles} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateFood;
|
||||||
|
|||||||
+29
-1
@@ -3,7 +3,7 @@ import Table from '@/components/Table'
|
|||||||
import Filters from '@/components/Filters'
|
import Filters from '@/components/Filters'
|
||||||
import { Add } from 'iconsax-react'
|
import { Add } from 'iconsax-react'
|
||||||
import Button from '@/components/Button'
|
import Button from '@/components/Button'
|
||||||
import { useGetFoods, useDeleteFood, useSetStockForFood } from './hooks/useFoodData'
|
import { useGetFoods, useDeleteFood, useCloneFood, useSetStockForFood } from './hooks/useFoodData'
|
||||||
import { useFoodFilters } from './hooks/useFoodFilters'
|
import { useFoodFilters } from './hooks/useFoodFilters'
|
||||||
import { getFoodTableColumns } from './components/FoodTableColumns'
|
import { getFoodTableColumns } from './components/FoodTableColumns'
|
||||||
import { useFoodFiltersFields } from './components/FoodFiltersFields'
|
import { useFoodFiltersFields } from './components/FoodFiltersFields'
|
||||||
@@ -20,6 +20,7 @@ const FoodsList: FC = () => {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [stockModalFood, setStockModalFood] = useState<Food | null>(null)
|
const [stockModalFood, setStockModalFood] = useState<Food | null>(null)
|
||||||
const [deleteConfirmFood, setDeleteConfirmFood] = useState<Food | null>(null)
|
const [deleteConfirmFood, setDeleteConfirmFood] = useState<Food | null>(null)
|
||||||
|
const [cloneConfirmFood, setCloneConfirmFood] = useState<Food | null>(null)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
filters,
|
filters,
|
||||||
@@ -31,6 +32,7 @@ const FoodsList: FC = () => {
|
|||||||
|
|
||||||
const { data: foodsData, isLoading } = useGetFoods(apiParams, filters)
|
const { data: foodsData, isLoading } = useGetFoods(apiParams, filters)
|
||||||
const { mutate: deleteFood, isPending: isDeleting } = useDeleteFood()
|
const { mutate: deleteFood, isPending: isDeleting } = useDeleteFood()
|
||||||
|
const { mutate: cloneFood, isPending: isCloning } = useCloneFood()
|
||||||
const { mutate: setStock, isPending: isUpdatingStock } = useSetStockForFood()
|
const { mutate: setStock, isPending: isUpdatingStock } = useSetStockForFood()
|
||||||
|
|
||||||
const foods = foodsData?.data || []
|
const foods = foodsData?.data || []
|
||||||
@@ -49,6 +51,10 @@ const FoodsList: FC = () => {
|
|||||||
label: 'بروزرسانی موجودی',
|
label: 'بروزرسانی موجودی',
|
||||||
onClick: () => setStockModalFood(item),
|
onClick: () => setStockModalFood(item),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'دابلیکیت کردن',
|
||||||
|
onClick: () => setCloneConfirmFood(item),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'حذف',
|
label: 'حذف',
|
||||||
onClick: () => setDeleteConfirmFood(item),
|
onClick: () => setDeleteConfirmFood(item),
|
||||||
@@ -85,6 +91,20 @@ const FoodsList: FC = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCloneConfirm = () => {
|
||||||
|
if (!cloneConfirmFood) return
|
||||||
|
|
||||||
|
cloneFood(cloneConfirmFood.id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('غذا با موفقیت دابلیکیت شد')
|
||||||
|
setCloneConfirmFood(null)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractErrorMessage(error))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-5'>
|
<div className='mt-5'>
|
||||||
<div className='flex justify-between items-center'>
|
<div className='flex justify-between items-center'>
|
||||||
@@ -138,6 +158,14 @@ const FoodsList: FC = () => {
|
|||||||
isloading={isDeleting}
|
isloading={isDeleting}
|
||||||
label={deleteConfirmFood ? `آیا از حذف «${deleteConfirmFood.title}» اطمینان دارید؟` : undefined}
|
label={deleteConfirmFood ? `آیا از حذف «${deleteConfirmFood.title}» اطمینان دارید؟` : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ModalConfrim
|
||||||
|
isOpen={!!cloneConfirmFood}
|
||||||
|
close={() => setCloneConfirmFood(null)}
|
||||||
|
onConfrim={handleCloneConfirm}
|
||||||
|
isloading={isCloning}
|
||||||
|
label={cloneConfirmFood ? `آیا از دابلیکیت کردن «${cloneConfirmFood.title}» اطمینان دارید؟` : undefined}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+99
-104
@@ -1,47 +1,47 @@
|
|||||||
import { type FC, useState, useEffect } from 'react'
|
import Button from "@/components/Button";
|
||||||
import { useFormik } from 'formik'
|
import { extractErrorMessage } from "@/config/func";
|
||||||
import * as Yup from 'yup'
|
import type { ErrorType } from "@/helpers/types";
|
||||||
import { TickCircle } from 'iconsax-react'
|
import { useFormik } from "formik";
|
||||||
import { toast } from 'react-toastify'
|
import { TickCircle } from "iconsax-react";
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { type FC, useEffect, useState } from "react";
|
||||||
import Button from '@/components/Button'
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import CreateFoodSidebar from './components/CreateFoodSidebar'
|
import { toast } from "react-toastify";
|
||||||
import BasicInfoSection from './components/BasicInfoSection'
|
import * as Yup from "yup";
|
||||||
import FoodContentSection from './components/FoodContentSection'
|
import { useMultipleUpload } from "../uploader/hooks/useUploaderData";
|
||||||
import MealTimesSection from './components/MealTimesSection'
|
import BasicInfoSection from "./components/BasicInfoSection";
|
||||||
import WeekDaysSection from './components/WeekDaysSection'
|
import CreateFoodSidebar from "./components/CreateFoodSidebar";
|
||||||
import ServiceOptionsSection from './components/ServiceOptionsSection'
|
import FoodContentSection from "./components/FoodContentSection";
|
||||||
import type { CreateFoodType } from './types/Types'
|
import MealTimesSection from "./components/MealTimesSection";
|
||||||
import { useUpdateFood, useGetFoodDetails, useGetCategories } from './hooks/useFoodData'
|
import ServiceOptionsSection from "./components/ServiceOptionsSection";
|
||||||
import { useMultipleUpload } from '../uploader/hooks/useUploaderData'
|
import WeekDaysSection from "./components/WeekDaysSection";
|
||||||
import { Pages } from '@/config/Pages'
|
import { useGetCategories, useGetFoodDetails, useUpdateFood } from "./hooks/useFoodData";
|
||||||
import type { ErrorType } from '@/helpers/types'
|
import type { CreateFoodType } from "./types/Types";
|
||||||
import { extractErrorMessage } from '@/config/func'
|
|
||||||
|
|
||||||
const UpdateFood: FC = () => {
|
const UpdateFood: FC = () => {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>();
|
||||||
const { data: foodData, isLoading } = useGetFoodDetails(id || '')
|
const { data: foodData, isLoading } = useGetFoodDetails(id || "");
|
||||||
const { data: categoriesData } = useGetCategories()
|
const { data: categoriesData } = useGetCategories();
|
||||||
const [isActive, setIsActive] = useState<boolean>(true)
|
const [isActive, setIsActive] = useState<boolean>(true);
|
||||||
const [isSpecial, setIsSpecial] = useState<boolean>(false)
|
const [isSpecial, setIsSpecial] = useState<boolean>(false);
|
||||||
const [imageFiles, setImageFiles] = useState<File[]>([])
|
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||||
const [existingImages, setExistingImages] = useState<string[]>([])
|
const [existingImages, setExistingImages] = useState<string[]>([]);
|
||||||
const { mutate: updateFood, isPending } = useUpdateFood()
|
const { mutate: updateFood, isPending } = useUpdateFood();
|
||||||
const { mutate: multipleUpload, isPending: isUploading } = useMultipleUpload()
|
const { mutate: multipleUpload, isPending: isUploading } = useMultipleUpload();
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate();
|
||||||
const categories = categoriesData?.data?.map(category => ({
|
const categories =
|
||||||
label: category.title,
|
categoriesData?.data?.map((category) => ({
|
||||||
value: category.id
|
label: category.title,
|
||||||
})) || []
|
value: category.id,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
const food = foodData?.data
|
const food = foodData?.data;
|
||||||
|
|
||||||
const formik = useFormik<CreateFoodType>({
|
const formik = useFormik<CreateFoodType>({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
title: '',
|
title: "",
|
||||||
desc: '',
|
desc: "",
|
||||||
content: [],
|
content: [],
|
||||||
categoryId: '',
|
categoryId: "",
|
||||||
price: 0,
|
price: 0,
|
||||||
discount: 0,
|
discount: 0,
|
||||||
prepareTime: 0,
|
prepareTime: 0,
|
||||||
@@ -54,72 +54,73 @@ const UpdateFood: FC = () => {
|
|||||||
isSpecialOffer: false,
|
isSpecialOffer: false,
|
||||||
dailyStock: 0,
|
dailyStock: 0,
|
||||||
availableStock: 0,
|
availableStock: 0,
|
||||||
order: 0
|
order: 0,
|
||||||
},
|
},
|
||||||
validationSchema: Yup.object().shape({
|
validationSchema: Yup.object().shape({
|
||||||
title: Yup.string().required('نام غذا الزامی است'),
|
title: Yup.string().required("نام غذا الزامی است"),
|
||||||
categoryId: Yup.string().required('دستهبندی الزامی است'),
|
categoryId: Yup.string().required("دستهبندی الزامی است"),
|
||||||
price: Yup.number().required('قیمت الزامی است').min(0, 'قیمت باید مثبت باشد'),
|
price: Yup.number().required("قیمت الزامی است").min(0, "قیمت باید مثبت باشد"),
|
||||||
prepareTime: Yup.number().required('زمان آمادهسازی الزامی است').min(0, 'زمان آمادهسازی باید مثبت باشد'),
|
prepareTime: Yup.number().required("زمان آمادهسازی الزامی است").min(0, "زمان آمادهسازی باید مثبت باشد"),
|
||||||
discount: Yup.number().min(0, 'تخفیف باید مثبت باشد'),
|
discount: Yup.number().min(0, "تخفیف باید مثبت باشد"),
|
||||||
dailyStock: Yup.number().required('موجودی روزانه الزامی است').min(0, 'موجودی روزانه باید مثبت باشد'),
|
dailyStock: Yup.number().required("موجودی روزانه الزامی است").min(0, "موجودی روزانه باید مثبت باشد"),
|
||||||
availableStock: Yup.number()
|
availableStock: Yup.number().required("موجودی فعلی الزامی است").min(0, "موجودی فعلی باید مثبت باشد"),
|
||||||
.required('موجودی فعلی الزامی است')
|
|
||||||
.min(0, 'موجودی فعلی باید مثبت باشد'),
|
|
||||||
content: Yup.array().of(Yup.string()),
|
content: Yup.array().of(Yup.string()),
|
||||||
weekDays: Yup.array().of(Yup.number()),
|
weekDays: Yup.array().of(Yup.number()),
|
||||||
mealTypes: Yup.array().of(Yup.string())
|
mealTypes: Yup.array().of(Yup.string()),
|
||||||
}),
|
}),
|
||||||
onSubmit: (values) => {
|
onSubmit: (values) => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
toast.error('شناسه غذا یافت نشد')
|
toast.error("شناسه غذا یافت نشد");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitFood = (newImageUrls: string[] = []) => {
|
const submitFood = (newImageUrls: string[] = []) => {
|
||||||
const allImages = [...values.images, ...newImageUrls]
|
const allImages = [...values.images, ...newImageUrls];
|
||||||
const foodData: CreateFoodType = {
|
const foodData: CreateFoodType = {
|
||||||
...values,
|
...values,
|
||||||
isActive,
|
isActive,
|
||||||
isSpecialOffer: isSpecial,
|
isSpecialOffer: isSpecial,
|
||||||
images: allImages,
|
images: allImages,
|
||||||
content: values.content.filter(item => item && item.trim().length > 0)
|
content: values.content.filter((item) => item && item.trim().length > 0),
|
||||||
}
|
};
|
||||||
|
|
||||||
updateFood({ id, params: foodData }, {
|
updateFood(
|
||||||
onSuccess: () => {
|
{ id, params: foodData },
|
||||||
toast.success('غذا با موفقیت بهروزرسانی شد')
|
{
|
||||||
navigate(Pages.foods.list)
|
onSuccess: () => {
|
||||||
|
toast.success("غذا با موفقیت بهروزرسانی شد");
|
||||||
|
navigate(-1);
|
||||||
|
},
|
||||||
|
onError: (error: ErrorType) => {
|
||||||
|
toast.error(extractErrorMessage(error));
|
||||||
|
},
|
||||||
},
|
},
|
||||||
onError: (error: ErrorType) => {
|
);
|
||||||
toast.error(extractErrorMessage(error))
|
};
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (imageFiles.length > 0) {
|
if (imageFiles.length > 0) {
|
||||||
multipleUpload(imageFiles, {
|
multipleUpload(imageFiles, {
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
const imageUrls = response?.data?.map(item => item.url) || []
|
const imageUrls = response?.data?.map((item) => item.url) || [];
|
||||||
submitFood(imageUrls)
|
submitFood(imageUrls);
|
||||||
},
|
},
|
||||||
onError: (error: ErrorType) => {
|
onError: (error: ErrorType) => {
|
||||||
toast.error(error?.response?.data?.error?.message[0] || 'خطا در آپلود تصاویر')
|
toast.error(error?.response?.data?.error?.message[0] || "خطا در آپلود تصاویر");
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
submitFood()
|
submitFood();
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (food) {
|
if (food) {
|
||||||
formik.setValues({
|
formik.setValues({
|
||||||
title: food.title || '',
|
title: food.title || "",
|
||||||
desc: food.desc || '',
|
desc: food.desc || "",
|
||||||
content: food.content || [],
|
content: food.content || [],
|
||||||
categoryId: food.category?.id || '',
|
categoryId: food.category?.id || "",
|
||||||
price: food.price || 0,
|
price: food.price || 0,
|
||||||
discount: food.discount || 0,
|
discount: food.discount || 0,
|
||||||
prepareTime: food.prepareTime || 0,
|
prepareTime: food.prepareTime || 0,
|
||||||
@@ -132,59 +133,53 @@ const UpdateFood: FC = () => {
|
|||||||
isSpecialOffer: food.isSpecialOffer || false,
|
isSpecialOffer: food.isSpecialOffer || false,
|
||||||
dailyStock: food.inventory?.totalStock || 0,
|
dailyStock: food.inventory?.totalStock || 0,
|
||||||
availableStock: food.inventory?.availableStock || 0,
|
availableStock: food.inventory?.availableStock || 0,
|
||||||
order: food.order || 0
|
order: food.order || 0,
|
||||||
})
|
});
|
||||||
setIsActive(food.isActive || false)
|
setIsActive(food.isActive || false);
|
||||||
setIsSpecial(food.isSpecialOffer || false)
|
setIsSpecial(food.isSpecialOffer || false);
|
||||||
setExistingImages(food.images || [])
|
setExistingImages(food.images || []);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [food])
|
}, [food]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
formik.setFieldValue('images', existingImages)
|
formik.setFieldValue("images", existingImages);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [existingImages])
|
}, [existingImages]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className='w-full mt-4 flex justify-center items-center'>
|
<div className="w-full mt-4 flex justify-center items-center">
|
||||||
<div>در حال بارگذاری...</div>
|
<div>در حال بارگذاری...</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!food) {
|
if (!food) {
|
||||||
return (
|
return (
|
||||||
<div className='w-full mt-4 flex justify-center items-center'>
|
<div className="w-full mt-4 flex justify-center items-center">
|
||||||
<div>غذا یافت نشد</div>
|
<div>غذا یافت نشد</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='w-full mt-4'>
|
<div className="w-full mt-4">
|
||||||
<div className='flex flex-col sm:flex-row w-full justify-between items-start sm:items-center gap-4'>
|
<div className="flex flex-col sm:flex-row w-full justify-between items-start sm:items-center gap-4">
|
||||||
<div className='text-lg font-light'>
|
<div className="text-lg font-light">ویرایش غذا</div>
|
||||||
ویرایش غذا
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button className="px-5 w-full sm:w-auto" onClick={() => formik.handleSubmit()} isloading={isPending || isUploading}>
|
||||||
className='px-5 w-full sm:w-auto'
|
<div className="flex gap-2 items-center">
|
||||||
onClick={() => formik.handleSubmit()}
|
<TickCircle className="size-5" color="white" />
|
||||||
isloading={isPending || isUploading}
|
|
||||||
>
|
|
||||||
<div className='flex gap-2 items-center'>
|
|
||||||
<TickCircle className='size-5' color='white' />
|
|
||||||
<div>ذخیره تغییرات</div>
|
<div>ذخیره تغییرات</div>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex flex-col lg:flex-row gap-6 mt-6'>
|
<div className="flex flex-col lg:flex-row gap-6 mt-6">
|
||||||
<div className='flex-1'>
|
<div className="flex-1">
|
||||||
<div className='bg-white py-6 sm:py-8 xl:px-10 px-4 rounded-3xl'>
|
<div className="bg-white py-6 sm:py-8 xl:px-10 px-4 rounded-3xl">
|
||||||
<BasicInfoSection formik={formik} categories={categories} />
|
<BasicInfoSection formik={formik} categories={categories} />
|
||||||
<FoodContentSection formik={formik} />
|
<FoodContentSection formik={formik} />
|
||||||
<MealTimesSection formik={formik} />
|
<MealTimesSection formik={formik} />
|
||||||
@@ -193,7 +188,7 @@ const UpdateFood: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='w-full lg:w-auto'>
|
<div className="w-full lg:w-auto">
|
||||||
<CreateFoodSidebar
|
<CreateFoodSidebar
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
isSpecial={isSpecial}
|
isSpecial={isSpecial}
|
||||||
@@ -206,7 +201,7 @@ const UpdateFood: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default UpdateFood
|
export default UpdateFood;
|
||||||
|
|||||||
@@ -1,16 +1,53 @@
|
|||||||
import { type FC } from 'react'
|
import { type FC } from 'react'
|
||||||
import type { FormikProps } from 'formik'
|
import type { FormikProps } from 'formik'
|
||||||
|
import { Magicpen } from 'iconsax-react'
|
||||||
import Input from '@/components/Input'
|
import Input from '@/components/Input'
|
||||||
import Select from '@/components/Select'
|
import Select from '@/components/Select'
|
||||||
import Textarea from '@/components/Textarea'
|
import Textarea from '@/components/Textarea'
|
||||||
|
import Button from '@/components/Button'
|
||||||
|
import { extractErrorMessage } from '@/config/func'
|
||||||
|
import { toast } from 'react-toastify'
|
||||||
import type { CreateFoodType } from '../types/Types'
|
import type { CreateFoodType } from '../types/Types'
|
||||||
|
import { useGenerateFoodDescription } from '../hooks/useFoodData'
|
||||||
|
|
||||||
type BasicInfoSectionProps = {
|
type BasicInfoSectionProps = {
|
||||||
formik: FormikProps<CreateFoodType>
|
formik: FormikProps<CreateFoodType>
|
||||||
categories: Array<{ label: string; value: string }>
|
categories: Array<{ label: string; value: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parseDescription = (data: { answer?: string } | undefined): string => {
|
||||||
|
return data?.answer?.trim() || ''
|
||||||
|
}
|
||||||
|
|
||||||
const BasicInfoSection: FC<BasicInfoSectionProps> = ({ formik, categories }) => {
|
const BasicInfoSection: FC<BasicInfoSectionProps> = ({ formik, categories }) => {
|
||||||
|
const { mutate: generateDescription, isPending: isGeneratingDesc } = useGenerateFoodDescription()
|
||||||
|
|
||||||
|
const handleGenerateDescription = () => {
|
||||||
|
const foodName = formik.values.title.trim()
|
||||||
|
if (!foodName) {
|
||||||
|
toast.error('لطفاً ابتدا نام کامل غذا را وارد کنید')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
generateDescription(
|
||||||
|
{ foodName },
|
||||||
|
{
|
||||||
|
onSuccess: (response) => {
|
||||||
|
const description = parseDescription(response.data)
|
||||||
|
if (!description) {
|
||||||
|
toast.error('توضیحی از هوش مصنوعی دریافت نشد')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
formik.setFieldValue('desc', description)
|
||||||
|
toast.success('توضیحات با موفقیت تولید شد')
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractErrorMessage(error))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
@@ -94,9 +131,22 @@ const BasicInfoSection: FC<BasicInfoSectionProps> = ({ formik, categories }) =>
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-5'>
|
<div className='mt-5'>
|
||||||
|
<div className='flex items-center justify-between mb-1'>
|
||||||
|
<div className='text-sm'>توضیحات غذا</div>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
className='w-auto h-8 px-3 bg-transparent text-primary border border-primary text-xs gap-1.5'
|
||||||
|
onClick={handleGenerateDescription}
|
||||||
|
isloading={isGeneratingDesc}
|
||||||
|
colorLoading='black'
|
||||||
|
disabled={!formik.values.title.trim() || isGeneratingDesc}
|
||||||
|
>
|
||||||
|
<Magicpen size={16} color='currentColor' />
|
||||||
|
تولید با AI
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
name='desc'
|
name='desc'
|
||||||
label='توضیحات غذا'
|
|
||||||
placeholder=''
|
placeholder=''
|
||||||
value={formik.values.desc}
|
value={formik.values.desc}
|
||||||
onChange={formik.handleChange}
|
onChange={formik.handleChange}
|
||||||
@@ -120,4 +170,3 @@ const BasicInfoSection: FC<BasicInfoSectionProps> = ({ formik, categories }) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default BasicInfoSection
|
export default BasicInfoSection
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,70 @@
|
|||||||
import { type FC } from 'react'
|
import { type FC } from 'react'
|
||||||
import type { FormikProps } from 'formik'
|
import type { FormikProps } from 'formik'
|
||||||
|
import { Magicpen } from 'iconsax-react'
|
||||||
import Input from '@/components/Input'
|
import Input from '@/components/Input'
|
||||||
import Button from '@/components/Button'
|
import Button from '@/components/Button'
|
||||||
|
import { extractErrorMessage } from '@/config/func'
|
||||||
|
import { toast } from 'react-toastify'
|
||||||
import type { CreateFoodType } from '../types/Types'
|
import type { CreateFoodType } from '../types/Types'
|
||||||
|
import { useGenerateFoodContent } from '../hooks/useFoodData'
|
||||||
|
|
||||||
type FoodContentSectionProps = {
|
type FoodContentSectionProps = {
|
||||||
formik: FormikProps<CreateFoodType>
|
formik: FormikProps<CreateFoodType>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parseContent = (data: { answer?: string } | undefined): string[] => {
|
||||||
|
if (!data?.answer) return []
|
||||||
|
return data.answer
|
||||||
|
.split('،')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
const FoodContentSection: FC<FoodContentSectionProps> = ({ formik }) => {
|
const FoodContentSection: FC<FoodContentSectionProps> = ({ formik }) => {
|
||||||
|
const { mutate: generateContent, isPending: isGeneratingContent } = useGenerateFoodContent()
|
||||||
|
|
||||||
|
const handleGenerateContent = () => {
|
||||||
|
const foodName = formik.values.title.trim()
|
||||||
|
if (!foodName) {
|
||||||
|
toast.error('لطفاً ابتدا نام کامل غذا را وارد کنید')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
generateContent(
|
||||||
|
{ foodName },
|
||||||
|
{
|
||||||
|
onSuccess: (response) => {
|
||||||
|
const content = parseContent(response.data)
|
||||||
|
if (!content.length) {
|
||||||
|
toast.error('محتوایی از هوش مصنوعی دریافت نشد')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
formik.setFieldValue('content', content)
|
||||||
|
toast.success('محتوای غذا با موفقیت تولید شد')
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractErrorMessage(error))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-5'>
|
<div className='mt-5'>
|
||||||
<div className='text-sm mb-2'>محتوای غذا</div>
|
<div className='flex items-center justify-between mb-2'>
|
||||||
|
<div className='text-sm'>محتوای غذا</div>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
className='w-auto h-8 px-3 bg-transparent text-primary border border-primary text-xs gap-1.5'
|
||||||
|
onClick={handleGenerateContent}
|
||||||
|
isloading={isGeneratingContent}
|
||||||
|
colorLoading='black'
|
||||||
|
disabled={!formik.values.title.trim() || isGeneratingContent}
|
||||||
|
>
|
||||||
|
<Magicpen size={16} color='currentColor' />
|
||||||
|
تولید با AI
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
{formik.values.content.map((item, index) => (
|
{formik.values.content.map((item, index) => (
|
||||||
<div key={index} className='flex gap-2 items-center'>
|
<div key={index} className='flex gap-2 items-center'>
|
||||||
@@ -51,4 +103,4 @@ const FoodContentSection: FC<FoodContentSectionProps> = ({ formik }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FoodContentSection
|
export default FoodContentSection
|
||||||
|
|||||||
@@ -55,6 +55,16 @@ export const useDeleteFood = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useCloneFood = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: api.cloneFood,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["foods"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const useCreateCategory = () => {
|
export const useCreateCategory = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -103,3 +113,15 @@ export const useSetStockForFood = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useGenerateFoodDescription = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: api.generateFoodDescription,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGenerateFoodContent = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: api.generateFoodContent,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import axios from "@/config/axios";
|
|||||||
import type {
|
import type {
|
||||||
CreateCategoryType,
|
CreateCategoryType,
|
||||||
CreateFoodType,
|
CreateFoodType,
|
||||||
|
GenerateFoodAiParams,
|
||||||
|
GenerateFoodContentResponse,
|
||||||
|
GenerateFoodDescriptionResponse,
|
||||||
GetCategoriesResponseType,
|
GetCategoriesResponseType,
|
||||||
GetFoodDetailsResponseType,
|
GetFoodDetailsResponseType,
|
||||||
GetFoodsParams,
|
GetFoodsParams,
|
||||||
@@ -55,6 +58,11 @@ export const deleteFood = async (id: string) => {
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const cloneFood = async (id: string) => {
|
||||||
|
const { data } = await axios.post(`/admin/foods/${id}/clone`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
export const createCategory = async (params: CreateCategoryType) => {
|
export const createCategory = async (params: CreateCategoryType) => {
|
||||||
const { data } = await axios.post(`/admin/categories`, params);
|
const { data } = await axios.post(`/admin/categories`, params);
|
||||||
return data;
|
return data;
|
||||||
@@ -85,3 +93,23 @@ export const setStockForFood = async (foodId: string, params: SetStockType) => {
|
|||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const generateFoodDescription = async (
|
||||||
|
params: GenerateFoodAiParams,
|
||||||
|
): Promise<GenerateFoodDescriptionResponse> => {
|
||||||
|
const { data } = await axios.post<GenerateFoodDescriptionResponse>(
|
||||||
|
`/admin/ai/food-description`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateFoodContent = async (
|
||||||
|
params: GenerateFoodAiParams,
|
||||||
|
): Promise<GenerateFoodContentResponse> => {
|
||||||
|
const { data } = await axios.post<GenerateFoodContentResponse>(
|
||||||
|
`/admin/ai/food-content`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|||||||
@@ -155,3 +155,18 @@ export type SetStockType = {
|
|||||||
totalStock: number;
|
totalStock: number;
|
||||||
availableStock: number;
|
availableStock: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GenerateFoodAiParams = {
|
||||||
|
foodName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerateFoodAiResult = {
|
||||||
|
answer: string;
|
||||||
|
consumedTokens?: number;
|
||||||
|
cost?: number;
|
||||||
|
promptTokens?: number;
|
||||||
|
completionTokens?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerateFoodDescriptionResponse = IResponse<GenerateFoodAiResult>;
|
||||||
|
export type GenerateFoodContentResponse = IResponse<GenerateFoodAiResult>;
|
||||||
|
|||||||
+18
-23
@@ -4,7 +4,6 @@ import Button from '@/components/Button'
|
|||||||
import { useCreateOrderForm } from './hooks/useCreateOrderForm'
|
import { useCreateOrderForm } from './hooks/useCreateOrderForm'
|
||||||
import CreateOrderCustomerSection from './components/CreateOrderCustomerSection'
|
import CreateOrderCustomerSection from './components/CreateOrderCustomerSection'
|
||||||
import CreateOrderItemsSection from './components/CreateOrderItemsSection'
|
import CreateOrderItemsSection from './components/CreateOrderItemsSection'
|
||||||
import CreateOrderDeliverySection from './components/CreateOrderDeliverySection'
|
|
||||||
import CreateOrderSidebar from './components/CreateOrderSidebar'
|
import CreateOrderSidebar from './components/CreateOrderSidebar'
|
||||||
|
|
||||||
const CreateOrder: FC = () => {
|
const CreateOrder: FC = () => {
|
||||||
@@ -35,8 +34,8 @@ const CreateOrder: FC = () => {
|
|||||||
} = useCreateOrderForm()
|
} = useCreateOrderForm()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-5'>
|
<div className='flex flex-col h-[calc(100vh-113px)] xl:overflow-hidden overflow-y-auto -mb-20'>
|
||||||
<div className='flex justify-between items-center'>
|
<div className='flex justify-between items-center flex-shrink-0 mb-3'>
|
||||||
<h1 className='text-lg font-light'>ثبت سفارش جدید</h1>
|
<h1 className='text-lg font-light'>ثبت سفارش جدید</h1>
|
||||||
<Button
|
<Button
|
||||||
className='w-fit px-6'
|
className='w-fit px-6'
|
||||||
@@ -50,27 +49,30 @@ const CreateOrder: FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex flex-col xl:flex-row gap-7 mt-9'>
|
<CreateOrderCustomerSection
|
||||||
<div className='flex-1 min-w-0 flex flex-col gap-8'>
|
formik={formik}
|
||||||
<CreateOrderCustomerSection
|
onUserFound={handleUserFound}
|
||||||
formik={formik}
|
onUserNotFound={handleUserNotFound}
|
||||||
onUserFound={handleUserFound}
|
onClearCustomerData={clearCustomerData}
|
||||||
onUserNotFound={handleUserNotFound}
|
/>
|
||||||
onClearCustomerData={clearCustomerData}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
<div className='flex-1 min-h-0 grid grid-cols-1 xl:grid-cols-[1fr_minmax(0,320px)] gap-4 mt-3 items-stretch'>
|
||||||
|
<div className='min-h-0 h-full overflow-hidden flex flex-col'>
|
||||||
<CreateOrderItemsSection
|
<CreateOrderItemsSection
|
||||||
formik={formik}
|
formik={formik}
|
||||||
foods={foods}
|
foods={foods}
|
||||||
onAddFood={addFoodToOrder}
|
onAddFood={addFoodToOrder}
|
||||||
onUpdateQuantity={updateItemQuantity}
|
|
||||||
onRemoveItem={removeItem}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<CreateOrderDeliverySection
|
<div className='min-h-0 h-full flex flex-col overflow-hidden'>
|
||||||
|
<CreateOrderSidebar
|
||||||
formik={formik}
|
formik={formik}
|
||||||
|
orderSummary={orderSummary}
|
||||||
deliveryMethods={deliveryMethods}
|
deliveryMethods={deliveryMethods}
|
||||||
cashPaymentMethod={cashPaymentMethod}
|
cashPaymentMethod={cashPaymentMethod}
|
||||||
|
selectedDeliveryMethod={selectedDeliveryMethod}
|
||||||
|
selectedPaymentMethod={selectedPaymentMethod}
|
||||||
isCourierDelivery={isCourierDelivery}
|
isCourierDelivery={isCourierDelivery}
|
||||||
isCarDelivery={isCarDelivery}
|
isCarDelivery={isCarDelivery}
|
||||||
isDineIn={isDineIn}
|
isDineIn={isDineIn}
|
||||||
@@ -80,15 +82,8 @@ const CreateOrder: FC = () => {
|
|||||||
onSelectManualAddress={selectManualAddress}
|
onSelectManualAddress={selectManualAddress}
|
||||||
onAddressFieldChange={handleAddressFieldChange}
|
onAddressFieldChange={handleAddressFieldChange}
|
||||||
onLocationSelect={handleLocationSelect}
|
onLocationSelect={handleLocationSelect}
|
||||||
/>
|
onUpdateQuantity={updateItemQuantity}
|
||||||
</div>
|
onRemoveItem={removeItem}
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { DocumentDownload } from 'iconsax-react';
|
|||||||
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 Button from '@/components/Button';
|
||||||
|
import PageLoading from '@/components/PageLoading';
|
||||||
|
import { usePermissions } from '@/hooks/usePermissions';
|
||||||
|
import { PermissionEnum } from '@/pages/roles/enum/Enum';
|
||||||
import { useGetDailyOrderReport } from './hooks/useDailyOrderReportData';
|
import { useGetDailyOrderReport } from './hooks/useDailyOrderReportData';
|
||||||
import { useDailyOrderReportFilters } from './hooks/useDailyOrderReportFilters';
|
import { useDailyOrderReportFilters } from './hooks/useDailyOrderReportFilters';
|
||||||
import { getDailyOrderReportTableColumns } from './components/DailyOrderReportTableColumns';
|
import { getDailyOrderReportTableColumns } from './components/DailyOrderReportTableColumns';
|
||||||
@@ -15,6 +18,7 @@ import type { RowDataType } from '@/components/types/TableTypes';
|
|||||||
|
|
||||||
const DailyOrderReport: FC = () => {
|
const DailyOrderReport: FC = () => {
|
||||||
const { t } = useTranslation('global');
|
const { t } = useTranslation('global');
|
||||||
|
const { checkPermission, isLoading: isPermissionsLoading } = usePermissions();
|
||||||
const { filters, apiParams, handleFiltersChange } = useDailyOrderReportFilters();
|
const { filters, apiParams, handleFiltersChange } = useDailyOrderReportFilters();
|
||||||
const { data: reportData, isLoading } = useGetDailyOrderReport(apiParams);
|
const { data: reportData, isLoading } = useGetDailyOrderReport(apiParams);
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
@@ -43,6 +47,18 @@ const DailyOrderReport: FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isPermissionsLoading) {
|
||||||
|
return <PageLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkPermission(PermissionEnum.VIEW_ORDER_REPORTS)) {
|
||||||
|
return (
|
||||||
|
<div className='mt-5 text-sm text-gray-500'>
|
||||||
|
شما دسترسی مشاهده این گزارش را ندارید.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-5'>
|
<div className='mt-5'>
|
||||||
<div className='flex justify-between items-center'>
|
<div className='flex justify-between items-center'>
|
||||||
|
|||||||
+327
-146
@@ -3,75 +3,76 @@ import {
|
|||||||
useGetOrderById,
|
useGetOrderById,
|
||||||
useVerifyPayment,
|
useVerifyPayment,
|
||||||
useChangeOrderStatus,
|
useChangeOrderStatus,
|
||||||
useRefundPayment
|
useRefundPayment,
|
||||||
|
useAddPayment,
|
||||||
|
useUpdateOrderFees,
|
||||||
|
useAddOrderItem,
|
||||||
|
useUpdateOrderItemQuantity,
|
||||||
|
useRemoveOrderItem,
|
||||||
|
useSendPleaseComeSms,
|
||||||
} from './hooks/useOrderData'
|
} from './hooks/useOrderData'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { ArrowRight2 } from 'iconsax-react'
|
||||||
import { formatFaNumber } from '@/helpers/func'
|
import { formatFaNumber } from '@/helpers/func'
|
||||||
|
import { Pages } from '@/config/Pages'
|
||||||
import { OrderStatus, PaymentStatusEnum } from './enum/Enum'
|
import { OrderStatus, PaymentStatusEnum } from './enum/Enum'
|
||||||
import { PaymentMethodEnum } from '@/pages/paymentMethods/enum/Enum'
|
import { PaymentMethodEnum } from '@/pages/paymentMethods/enum/Enum'
|
||||||
import { DeliveryMethodEnum } from '@/pages/shipmentMethod/enum/Enum'
|
import { DeliveryMethodEnum } from '@/pages/shipmentMethod/enum/Enum'
|
||||||
|
import Button from '@/components/Button'
|
||||||
import ModalConfrim from '@/components/ModalConfrim'
|
import ModalConfrim from '@/components/ModalConfrim'
|
||||||
import CustomerInfo from './components/CustomerInfo'
|
import CustomerInfo from './components/CustomerInfo'
|
||||||
import OrderItemsDetails from './components/OrderItemsDetails'
|
import OrderItemsDetails from './components/OrderItemsDetails'
|
||||||
|
import PaymentsList from './components/PaymentsList'
|
||||||
import PaymentInfo from './components/PaymentInfo'
|
import PaymentInfo from './components/PaymentInfo'
|
||||||
import PaymentVerificationBox from './components/PaymentVerificationBox'
|
|
||||||
import OrderActions from './components/OrderActions'
|
import OrderActions from './components/OrderActions'
|
||||||
|
import UpdateOrderFeesModal from './components/UpdateOrderFeesModal'
|
||||||
|
import AddOrderItemModal from './components/AddOrderItemModal'
|
||||||
|
import RefundPaymentModal from './components/RefundPaymentModal'
|
||||||
|
import AddPaymentModal from './components/AddPaymentModal'
|
||||||
|
import SendPleaseComeSmsModal from './components/SendPleaseComeSmsModal'
|
||||||
import { toast } from 'react-toastify'
|
import { toast } from 'react-toastify'
|
||||||
import { extractErrorMessage } from '@/config/func'
|
import { extractErrorMessage } from '@/config/func'
|
||||||
import { printOrderReceipt } from './print/orderReceiptPrint'
|
import { printOrderReceipt } from './print/orderReceiptPrint'
|
||||||
|
import type { UpdateOrderFeesType } from './types/Types'
|
||||||
|
|
||||||
const OrderDetails: FC = () => {
|
const OrderDetails: FC = () => {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
const { data: orderData, isLoading } = useGetOrderById(id!)
|
const { data: orderData, isLoading } = useGetOrderById(id!)
|
||||||
const { mutate: verifyPayment, isPending: isVerifyingPayment } = useVerifyPayment()
|
const { mutate: verifyPayment, isPending: isVerifyingPayment } = useVerifyPayment()
|
||||||
const { mutate: changeOrderStatus, isPending: isChangingStatus } = useChangeOrderStatus()
|
const { mutate: changeOrderStatus, isPending: isChangingStatus } = useChangeOrderStatus()
|
||||||
const { mutate: refundPayment, isPending: isRefunding } = useRefundPayment()
|
const { mutate: refundPayment, isPending: isRefunding } = useRefundPayment()
|
||||||
|
const { mutate: addPayment, isPending: isAddingPayment } = useAddPayment()
|
||||||
|
const { mutate: updateOrderFees, isPending: isUpdatingFees } = useUpdateOrderFees()
|
||||||
|
const { mutate: addOrderItem, isPending: isAddingItem } = useAddOrderItem()
|
||||||
|
const { mutate: updateOrderItemQuantity, isPending: isUpdatingItemQuantity } = useUpdateOrderItemQuantity()
|
||||||
|
const { mutate: removeOrderItem, isPending: isRemovingItem } = useRemoveOrderItem()
|
||||||
|
const { mutate: sendPleaseComeSms, isPending: isSendingPleaseComeSms } = useSendPleaseComeSms()
|
||||||
|
|
||||||
const [showCancelModal, setShowCancelModal] = useState(false)
|
const [showCancelModal, setShowCancelModal] = useState(false)
|
||||||
const [showRefundModal, setShowRefundModal] = useState(false)
|
const [showPleaseComeSmsModal, setShowPleaseComeSmsModal] = useState(false)
|
||||||
|
const [refundModal, setRefundModal] = useState<{ defaultAmount: number; maxAmount: number } | null>(null)
|
||||||
|
const [showAddPaymentModal, setShowAddPaymentModal] = useState(false)
|
||||||
|
const [showEditFeesModal, setShowEditFeesModal] = useState(false)
|
||||||
|
const [showAddItemModal, setShowAddItemModal] = useState(false)
|
||||||
|
const [itemIdToRemove, setItemIdToRemove] = useState<string | null>(null)
|
||||||
|
const [verifyingPaymentId, setVerifyingPaymentId] = useState<string | null>(null)
|
||||||
|
|
||||||
const order = orderData?.data
|
const order = orderData?.data
|
||||||
|
|
||||||
const getStatusColor = (status: string): string => {
|
const getStatusColor = (status: string): string => {
|
||||||
const colorMap: Record<string, string> = {
|
const colorMap: Record<string, string> = {
|
||||||
'pendingPayment': '#FFEECC',
|
pendingPayment: '#FFEECC',
|
||||||
'paid': '#E3F2FD',
|
paid: '#E3F2FD',
|
||||||
'preparing': '#FFF3E0',
|
preparing: '#FFF3E0',
|
||||||
'ready': '#E3F2FD',
|
ready: '#E3F2FD',
|
||||||
'shipped': '#E3F2FD',
|
shipped: '#E3F2FD',
|
||||||
'completed': '#E8F5E9',
|
completed: '#E8F5E9',
|
||||||
'canceled': '#FFEBEE',
|
canceled: '#FFEBEE',
|
||||||
}
|
}
|
||||||
return colorMap[status] || '#FFEECC'
|
return colorMap[status] || '#FFEECC'
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPaymentStatusColor = (status: string): string => {
|
|
||||||
const colorMap: Record<string, string> = {
|
|
||||||
[PaymentStatusEnum.Paid]: 'bg-green-500',
|
|
||||||
[PaymentStatusEnum.Pending]: 'bg-yellow-500',
|
|
||||||
[PaymentStatusEnum.Failed]: 'bg-red-500',
|
|
||||||
}
|
|
||||||
return colorMap[status] || 'bg-gray-500'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPaymentStatusTextColor = (status: string): string => {
|
|
||||||
const colorMap: Record<string, string> = {
|
|
||||||
[PaymentStatusEnum.Paid]: 'text-green-500',
|
|
||||||
[PaymentStatusEnum.Pending]: 'text-yellow-500',
|
|
||||||
[PaymentStatusEnum.Failed]: 'text-red-500',
|
|
||||||
}
|
|
||||||
return colorMap[status] || 'text-gray-500'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPaymentStatusText = (status: string): string => {
|
|
||||||
const textMap: Record<string, string> = {
|
|
||||||
[PaymentStatusEnum.Paid]: 'پرداخت شده',
|
|
||||||
[PaymentStatusEnum.Pending]: 'در انتظار پرداخت',
|
|
||||||
[PaymentStatusEnum.Failed]: 'پرداخت ناموفق',
|
|
||||||
}
|
|
||||||
return textMap[status] || status
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className='mt-5 flex items-center justify-center h-64'>
|
<div className='mt-5 flex items-center justify-center h-64'>
|
||||||
@@ -88,157 +89,293 @@ const OrderDetails: FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// منطق نمایش دکمهها
|
|
||||||
const isCashPayment = order.paymentMethod?.method === PaymentMethodEnum.Cash
|
const isCashPayment = order.paymentMethod?.method === PaymentMethodEnum.Cash
|
||||||
const isCreditCardPayment = order.paymentMethod?.method === PaymentMethodEnum.CreditCard
|
const isOrderPaid = Number(order.paidAmount ?? 0) > 0 || order.payments?.some(
|
||||||
const isPendingPayment = order.status === OrderStatus.PENDING_PAYMENT
|
(payment) => payment.status === PaymentStatusEnum.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
|
||||||
// بررسی وضعیت پرداخت از payments array یا paymentStatus
|
const isCompleted = order.status === OrderStatus.COMPLETED
|
||||||
// ابتدا از paymentStatus استفاده میکنیم، اگر نبود از اولین payment در payments array استفاده میکنیم
|
const isPendingPayment = order.status === OrderStatus.NEW
|
||||||
const paymentStatus = order.paymentStatus || order.payments?.[0]?.status
|
const canEditFees = !isCanceled && !isCompleted
|
||||||
// بررسی وضعیت pending (هم enum و هم string literal)
|
const canEditItems = !isCanceled && !isCompleted
|
||||||
const isPaymentPending = paymentStatus === PaymentStatusEnum.Pending || paymentStatus === 'pending'
|
const canAddPayment = !isCanceled && Number(order.balance ?? 0) > 0
|
||||||
|
const isUpdatingItem = isAddingItem || isUpdatingItemQuantity || isRemovingItem
|
||||||
|
const canCompleteOrder = [
|
||||||
|
OrderStatus.DELIVERED_TO_WAITER,
|
||||||
|
OrderStatus.DELIVERED_TO_RECEPTIONIST,
|
||||||
|
OrderStatus.SHIPPED,
|
||||||
|
].includes(order.status as OrderStatus)
|
||||||
|
const canSendPleaseComeSms = Boolean(order.user?.phone)
|
||||||
const isCourierDelivery = order.deliveryMethod?.method === DeliveryMethodEnum.DeliveryCourier
|
const isCourierDelivery = order.deliveryMethod?.method === DeliveryMethodEnum.DeliveryCourier
|
||||||
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
|
|
||||||
|
|
||||||
// نمایش دکمه ارسال به آشپزخانه
|
|
||||||
// برای pendingPayment با پرداخت نقدی یا برای paid
|
|
||||||
const showSendToKitchenButton = (isPendingPayment && isCashPayment) || isOrderPaid
|
const showSendToKitchenButton = (isPendingPayment && isCashPayment) || isOrderPaid
|
||||||
|
|
||||||
// نمایش دکمه کنسل
|
|
||||||
// همیشه نمایش داده میشود مگر اینکه سفارش قبلاً کنسل شده باشد
|
|
||||||
const showCancelButton = !isCanceled
|
const showCancelButton = !isCanceled
|
||||||
|
|
||||||
const handleVerifyPayment = () => {
|
const handleVerifyPayment = (paymentId: string) => {
|
||||||
const paymentId = order.paymentId || order.payments?.[0]?.id
|
setVerifyingPaymentId(paymentId)
|
||||||
if (paymentId) {
|
verifyPayment(paymentId, {
|
||||||
verifyPayment(paymentId, {
|
onSuccess: () => {
|
||||||
onSuccess: () => {
|
setVerifyingPaymentId(null)
|
||||||
// Success handled by query invalidation
|
},
|
||||||
},
|
onError: (error) => {
|
||||||
onError: (error) => {
|
setVerifyingPaymentId(null)
|
||||||
toast.error(extractErrorMessage(error))
|
toast.error(extractErrorMessage(error))
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSendToKitchen = () => {
|
const handleSendToKitchen = () => {
|
||||||
changeOrderStatus({
|
changeOrderStatus(
|
||||||
orderId: order.id,
|
{
|
||||||
status: OrderStatus.PREPARING
|
orderId: order.id,
|
||||||
}, {
|
status: OrderStatus.PREPARING,
|
||||||
onError: (error) => {
|
},
|
||||||
toast.error(extractErrorMessage(error))
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractErrorMessage(error))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeliverToCourier = () => {
|
const handleDeliverToCourier = () => {
|
||||||
changeOrderStatus({
|
changeOrderStatus(
|
||||||
orderId: order.id,
|
{
|
||||||
status: OrderStatus.SHIPPED
|
orderId: order.id,
|
||||||
}, {
|
status: OrderStatus.SHIPPED,
|
||||||
onError: (error) => {
|
},
|
||||||
toast.error(extractErrorMessage(error))
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractErrorMessage(error))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeliverToWaiter = () => {
|
const handleDeliverToWaiter = () => {
|
||||||
changeOrderStatus({
|
changeOrderStatus(
|
||||||
orderId: order.id,
|
{
|
||||||
status: OrderStatus.DELIVERED_TO_WAITER
|
orderId: order.id,
|
||||||
}, {
|
status: OrderStatus.DELIVERED_TO_WAITER,
|
||||||
onError: (error) => {
|
},
|
||||||
toast.error(extractErrorMessage(error))
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractErrorMessage(error))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeliverToReceptionist = () => {
|
const handleDeliverToReceptionist = () => {
|
||||||
changeOrderStatus({
|
changeOrderStatus(
|
||||||
orderId: order.id,
|
{
|
||||||
status: OrderStatus.DELIVERED_TO_RECEPTIONIST
|
orderId: order.id,
|
||||||
}, {
|
status: OrderStatus.DELIVERED_TO_RECEPTIONIST,
|
||||||
onError: (error) => {
|
},
|
||||||
toast.error(extractErrorMessage(error))
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractErrorMessage(error))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancelOrder = (description?: string) => {
|
const handleCancelOrder = (description?: string) => {
|
||||||
changeOrderStatus({
|
changeOrderStatus(
|
||||||
orderId: order.id,
|
{
|
||||||
status: OrderStatus.CANCELED,
|
orderId: order.id,
|
||||||
params: description ? { description } : undefined
|
status: OrderStatus.CANCELED,
|
||||||
}, {
|
params: description ? { description } : undefined,
|
||||||
onError: (error) => {
|
},
|
||||||
toast.error(extractErrorMessage(error))
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractErrorMessage(error))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
setShowCancelModal(false)
|
setShowCancelModal(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRefund = (text?: string) => {
|
const handleCompleteOrder = () => {
|
||||||
if (!text) return
|
changeOrderStatus(
|
||||||
refundPayment({
|
{
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
params: { description: text }
|
status: OrderStatus.COMPLETED,
|
||||||
}, {
|
},
|
||||||
onError: (error) => {
|
{
|
||||||
toast.error(extractErrorMessage(error))
|
onError: (error) => {
|
||||||
|
toast.error(extractErrorMessage(error))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
setShowRefundModal(false)
|
}
|
||||||
|
|
||||||
|
const handleRefund = (amount: number, description: string) => {
|
||||||
|
refundPayment(
|
||||||
|
{
|
||||||
|
orderId: order.id,
|
||||||
|
params: { amount, description },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('بازگشت وجه با موفقیت انجام شد')
|
||||||
|
setRefundModal(null)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractErrorMessage(error))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddPayment = (amount: number, description: string) => {
|
||||||
|
addPayment(
|
||||||
|
{
|
||||||
|
orderId: order.id,
|
||||||
|
params: {
|
||||||
|
amount,
|
||||||
|
...(description ? { description } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('پرداخت با موفقیت ثبت شد')
|
||||||
|
setShowAddPaymentModal(false)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractErrorMessage(error))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePrintOrder = () => {
|
const handlePrintOrder = () => {
|
||||||
printOrderReceipt(order)
|
printOrderReceipt(order)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleUpdateOrderFees = (params: UpdateOrderFeesType) => {
|
||||||
|
updateOrderFees(
|
||||||
|
{ orderId: order.id, params },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('هزینههای سفارش بهروزرسانی شد')
|
||||||
|
setShowEditFeesModal(false)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractErrorMessage(error))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddOrderItem = (foodId: string, quantity: number) => {
|
||||||
|
addOrderItem(
|
||||||
|
{ orderId: order.id, params: { foodId, quantity } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('آیتم به سفارش اضافه شد')
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractErrorMessage(error))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateItemQuantity = (itemId: string, quantity: number) => {
|
||||||
|
if (quantity < 1) return
|
||||||
|
updateOrderItemQuantity(
|
||||||
|
{ orderId: order.id, itemId, params: { quantity } },
|
||||||
|
{
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractErrorMessage(error))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmRemoveItem = () => {
|
||||||
|
if (!itemIdToRemove) return
|
||||||
|
removeOrderItem(
|
||||||
|
{ orderId: order.id, itemId: itemIdToRemove },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('آیتم از سفارش حذف شد')
|
||||||
|
setItemIdToRemove(null)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractErrorMessage(error))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmPleaseComeSms = () => {
|
||||||
|
sendPleaseComeSms(order.id, {
|
||||||
|
onSuccess: (response) => {
|
||||||
|
toast.success(response.message || 'پیامک در صف ارسال قرار گرفت')
|
||||||
|
setShowPleaseComeSmsModal(false)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractErrorMessage(error))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-5'>
|
<div className='mt-5 pb-8'>
|
||||||
<div className='flex justify-between items-center'>
|
<div className='flex justify-between items-center'>
|
||||||
<h1 className='text-lg font-light'>
|
<h1 className='text-lg font-light'>
|
||||||
سفارش {formatFaNumber(order.orderNumber)}
|
سفارش {formatFaNumber(order.orderNumber)}
|
||||||
</h1>
|
</h1>
|
||||||
{/* <div>
|
<Button
|
||||||
<Select
|
className='w-fit px-6 bg-transparent text-primary border-primary border'
|
||||||
items={statusItems}
|
onClick={() => navigate(Pages.orders.list)}
|
||||||
value={order.status}
|
>
|
||||||
placeholder={t(`order_status.${order.status}`)}
|
<div className='flex gap-2 items-center'>
|
||||||
className='w-[200px]'
|
<ArrowRight2 color='#000' size={18} />
|
||||||
style={{ backgroundColor: getStatusColor(order.status) }}
|
<span>بازگشت</span>
|
||||||
/>
|
</div>
|
||||||
</div> */}
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex gap-7 mt-9'>
|
<div className='flex flex-col lg:flex-row gap-5 lg:gap-7 mt-6 sm:mt-9'>
|
||||||
<div className='flex-1 space-y-8'>
|
<div className='flex-1 min-w-0 space-y-5 sm:space-y-8 order-2 lg:order-1'>
|
||||||
<CustomerInfo order={order} />
|
<CustomerInfo order={order} />
|
||||||
<OrderItemsDetails order={order} getStatusColor={getStatusColor} />
|
<OrderItemsDetails
|
||||||
|
order={order}
|
||||||
|
getStatusColor={getStatusColor}
|
||||||
|
canEditItems={canEditItems}
|
||||||
|
isUpdatingItem={isUpdatingItem}
|
||||||
|
onAddItem={() => setShowAddItemModal(true)}
|
||||||
|
onUpdateQuantity={handleUpdateItemQuantity}
|
||||||
|
onRemoveItem={(itemId) => setItemIdToRemove(itemId)}
|
||||||
|
/>
|
||||||
|
<PaymentsList
|
||||||
|
order={order}
|
||||||
|
isVerifyingPayment={isVerifyingPayment}
|
||||||
|
verifyingPaymentId={verifyingPaymentId}
|
||||||
|
onVerifyPayment={handleVerifyPayment}
|
||||||
|
canAddPayment={canAddPayment}
|
||||||
|
onAddPayment={() => setShowAddPaymentModal(true)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className='w-full lg:w-[330px] shrink-0 order-1 lg:order-2'>
|
||||||
<PaymentInfo
|
<PaymentInfo
|
||||||
order={order}
|
order={order}
|
||||||
getPaymentStatusColor={getPaymentStatusColor}
|
canEditFees={canEditFees}
|
||||||
getPaymentStatusTextColor={getPaymentStatusTextColor}
|
onEditFees={() => setShowEditFeesModal(true)}
|
||||||
getPaymentStatusText={getPaymentStatusText}
|
isRefunding={isRefunding}
|
||||||
|
onRefund={(defaultAmount, maxAmount) =>
|
||||||
|
setRefundModal({ defaultAmount, maxAmount })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{showVerifyPaymentBox && (
|
|
||||||
<PaymentVerificationBox
|
|
||||||
order={order}
|
|
||||||
isVerifyingPayment={isVerifyingPayment}
|
|
||||||
onVerifyPayment={handleVerifyPayment}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<OrderActions
|
<OrderActions
|
||||||
onPrint={handlePrintOrder}
|
onPrint={handlePrintOrder}
|
||||||
showSendToKitchenButton={showSendToKitchenButton}
|
showSendToKitchenButton={showSendToKitchenButton}
|
||||||
@@ -248,17 +385,37 @@ const OrderDetails: FC = () => {
|
|||||||
isDineInOrCarDelivery={isDineInOrCarDelivery}
|
isDineInOrCarDelivery={isDineInOrCarDelivery}
|
||||||
isCustomerPickup={isCustomerPickup}
|
isCustomerPickup={isCustomerPickup}
|
||||||
isCanceled={isCanceled}
|
isCanceled={isCanceled}
|
||||||
|
canCompleteOrder={canCompleteOrder}
|
||||||
|
canSendPleaseComeSms={canSendPleaseComeSms}
|
||||||
isChangingStatus={isChangingStatus}
|
isChangingStatus={isChangingStatus}
|
||||||
|
isSendingPleaseComeSms={isSendingPleaseComeSms}
|
||||||
onSendToKitchen={handleSendToKitchen}
|
onSendToKitchen={handleSendToKitchen}
|
||||||
onDeliverToCourier={handleDeliverToCourier}
|
onDeliverToCourier={handleDeliverToCourier}
|
||||||
onDeliverToWaiter={handleDeliverToWaiter}
|
onDeliverToWaiter={handleDeliverToWaiter}
|
||||||
onDeliverToReceptionist={handleDeliverToReceptionist}
|
onDeliverToReceptionist={handleDeliverToReceptionist}
|
||||||
|
onCompleteOrder={handleCompleteOrder}
|
||||||
onCancelOrder={() => setShowCancelModal(true)}
|
onCancelOrder={() => setShowCancelModal(true)}
|
||||||
|
onSendPleaseComeSms={() => setShowPleaseComeSmsModal(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* مدال کنسل سفارش */}
|
<UpdateOrderFeesModal
|
||||||
|
isOpen={showEditFeesModal}
|
||||||
|
onClose={() => setShowEditFeesModal(false)}
|
||||||
|
order={order}
|
||||||
|
isLoading={isUpdatingFees}
|
||||||
|
onSubmit={handleUpdateOrderFees}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddOrderItemModal
|
||||||
|
isOpen={showAddItemModal}
|
||||||
|
onClose={() => setShowAddItemModal(false)}
|
||||||
|
existingItems={order.items}
|
||||||
|
isLoading={isAddingItem}
|
||||||
|
onAdd={handleAddOrderItem}
|
||||||
|
/>
|
||||||
|
|
||||||
<ModalConfrim
|
<ModalConfrim
|
||||||
isOpen={showCancelModal}
|
isOpen={showCancelModal}
|
||||||
close={() => setShowCancelModal(false)}
|
close={() => setShowCancelModal(false)}
|
||||||
@@ -268,17 +425,41 @@ const OrderDetails: FC = () => {
|
|||||||
isHasDescription
|
isHasDescription
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* مدال ریفاند */}
|
<RefundPaymentModal
|
||||||
|
isOpen={!!refundModal}
|
||||||
|
onClose={() => setRefundModal(null)}
|
||||||
|
defaultAmount={refundModal?.defaultAmount ?? 0}
|
||||||
|
maxAmount={refundModal?.maxAmount ?? 0}
|
||||||
|
isLoading={isRefunding}
|
||||||
|
onSubmit={handleRefund}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddPaymentModal
|
||||||
|
isOpen={showAddPaymentModal}
|
||||||
|
onClose={() => setShowAddPaymentModal(false)}
|
||||||
|
defaultAmount={Number(order.balance ?? 0)}
|
||||||
|
isLoading={isAddingPayment}
|
||||||
|
onSubmit={handleAddPayment}
|
||||||
|
/>
|
||||||
|
|
||||||
<ModalConfrim
|
<ModalConfrim
|
||||||
isOpen={showRefundModal}
|
isOpen={!!itemIdToRemove}
|
||||||
close={() => setShowRefundModal(false)}
|
close={() => setItemIdToRemove(null)}
|
||||||
onConfrim={handleRefund}
|
onConfrim={handleConfirmRemoveItem}
|
||||||
isloading={isRefunding}
|
isloading={isRemovingItem}
|
||||||
label='آیا از ریفاند این سفارش مطمئن هستید؟'
|
label='آیا از حذف این آیتم مطمئن هستید؟'
|
||||||
isHasDescription
|
/>
|
||||||
|
|
||||||
|
<SendPleaseComeSmsModal
|
||||||
|
isOpen={showPleaseComeSmsModal}
|
||||||
|
onClose={() => setShowPleaseComeSmsModal(false)}
|
||||||
|
isLoading={isSendingPleaseComeSms}
|
||||||
|
customerName={order.user?.firstName || ''}
|
||||||
|
restaurantName={order.restaurant?.name || ''}
|
||||||
|
onConfirm={handleConfirmPleaseComeSms}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default OrderDetails
|
export default OrderDetails
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { DocumentDownload } from 'iconsax-react';
|
|||||||
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 Button from '@/components/Button';
|
||||||
|
import PageLoading from '@/components/PageLoading';
|
||||||
|
import { usePermissions } from '@/hooks/usePermissions';
|
||||||
|
import { PermissionEnum } from '@/pages/roles/enum/Enum';
|
||||||
import { useGetFoodOrderReport } from './hooks/useFoodOrderReportData';
|
import { useGetFoodOrderReport } from './hooks/useFoodOrderReportData';
|
||||||
import { useFoodOrderReportFilters } from './hooks/useFoodOrderReportFilters';
|
import { useFoodOrderReportFilters } from './hooks/useFoodOrderReportFilters';
|
||||||
import { getFoodOrderReportTableColumns } from './components/FoodOrderReportTableColumns';
|
import { getFoodOrderReportTableColumns } from './components/FoodOrderReportTableColumns';
|
||||||
@@ -15,6 +18,7 @@ import type { RowDataType } from '@/components/types/TableTypes';
|
|||||||
|
|
||||||
const FoodOrderReport: FC = () => {
|
const FoodOrderReport: FC = () => {
|
||||||
const { t } = useTranslation('global');
|
const { t } = useTranslation('global');
|
||||||
|
const { checkPermission, isLoading: isPermissionsLoading } = usePermissions();
|
||||||
const { filters, apiParams, handleFiltersChange } = useFoodOrderReportFilters();
|
const { filters, apiParams, handleFiltersChange } = useFoodOrderReportFilters();
|
||||||
const { data: reportData, isLoading } = useGetFoodOrderReport(apiParams);
|
const { data: reportData, isLoading } = useGetFoodOrderReport(apiParams);
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
@@ -43,6 +47,18 @@ const FoodOrderReport: FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isPermissionsLoading) {
|
||||||
|
return <PageLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkPermission(PermissionEnum.VIEW_ORDER_REPORTS)) {
|
||||||
|
return (
|
||||||
|
<div className='mt-5 text-sm text-gray-500'>
|
||||||
|
شما دسترسی مشاهده این گزارش را ندارید.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-5'>
|
<div className='mt-5'>
|
||||||
<div className='flex justify-between items-center'>
|
<div className='flex justify-between items-center'>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type FC } from 'react'
|
import { type FC, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Add } from 'iconsax-react'
|
import { Add } from 'iconsax-react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
@@ -6,12 +6,15 @@ import Table from '@/components/Table'
|
|||||||
import Filters from '@/components/Filters'
|
import Filters from '@/components/Filters'
|
||||||
import Button from '@/components/Button'
|
import Button from '@/components/Button'
|
||||||
import { Pages } from '@/config/Pages'
|
import { Pages } from '@/config/Pages'
|
||||||
import { useGetOrders } from './hooks/useOrderData'
|
import { useGetOrders, useSendPleaseComeSms } from './hooks/useOrderData'
|
||||||
import { useOrderFilters } from './hooks/useOrderFilters'
|
import { useOrderFilters } from './hooks/useOrderFilters'
|
||||||
import { getOrderTableColumns } from './components/OrderTableColumns'
|
import { getOrderTableColumns } from './components/OrderTableColumns'
|
||||||
import { useOrderFiltersFields } from './components/OrderFiltersFields'
|
import { useOrderFiltersFields } from './components/OrderFiltersFields'
|
||||||
|
import SendPleaseComeSmsModal from './components/SendPleaseComeSmsModal'
|
||||||
import type { Order } from './types/Types'
|
import type { Order } from './types/Types'
|
||||||
import type { RowDataType } from '@/components/types/TableTypes'
|
import type { RowDataType } from '@/components/types/TableTypes'
|
||||||
|
import { toast } from 'react-toastify'
|
||||||
|
import { extractErrorMessage } from '@/config/func'
|
||||||
|
|
||||||
const OrdersList: FC = () => {
|
const OrdersList: FC = () => {
|
||||||
const { t } = useTranslation('global')
|
const { t } = useTranslation('global')
|
||||||
@@ -21,16 +24,34 @@ const OrdersList: FC = () => {
|
|||||||
apiParams,
|
apiParams,
|
||||||
handleFiltersChange,
|
handleFiltersChange,
|
||||||
handlePageChange,
|
handlePageChange,
|
||||||
limit,
|
|
||||||
} = useOrderFilters()
|
} = useOrderFilters()
|
||||||
|
|
||||||
const { data: ordersData, isLoading } = useGetOrders(apiParams)
|
const { data: ordersData, isLoading } = useGetOrders(apiParams)
|
||||||
|
const { mutate: sendPleaseComeSms, isPending: isSendingSms } = useSendPleaseComeSms()
|
||||||
|
const [smsOrder, setSmsOrder] = useState<Order | null>(null)
|
||||||
|
|
||||||
const orders = ordersData?.data || []
|
const orders = ordersData?.data || []
|
||||||
const columns = getOrderTableColumns({ t })
|
const columns = getOrderTableColumns({
|
||||||
|
t,
|
||||||
|
onSendPleaseComeSms: (order) => setSmsOrder(order),
|
||||||
|
})
|
||||||
const filterFields = useOrderFiltersFields()
|
const filterFields = useOrderFiltersFields()
|
||||||
|
|
||||||
const totalPages = Math.ceil(orders.length / limit) || 1
|
const totalPages = ordersData?.meta?.totalPages || 1
|
||||||
|
|
||||||
|
const handleConfirmSendSms = () => {
|
||||||
|
if (!smsOrder) return
|
||||||
|
|
||||||
|
sendPleaseComeSms(smsOrder.id, {
|
||||||
|
onSuccess: (response) => {
|
||||||
|
toast.success(response.message || 'پیامک در صف ارسال قرار گرفت')
|
||||||
|
setSmsOrder(null)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractErrorMessage(error))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-5'>
|
<div className='mt-5'>
|
||||||
@@ -65,8 +86,17 @@ const OrdersList: FC = () => {
|
|||||||
onPageChange: handlePageChange,
|
onPageChange: handlePageChange,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SendPleaseComeSmsModal
|
||||||
|
isOpen={!!smsOrder}
|
||||||
|
onClose={() => setSmsOrder(null)}
|
||||||
|
isLoading={isSendingSms}
|
||||||
|
customerName={smsOrder?.user?.firstName || ''}
|
||||||
|
restaurantName={smsOrder?.restaurant?.name || ''}
|
||||||
|
onConfirm={handleConfirmSendSms}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default OrdersList
|
export default OrdersList
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { type FC, useMemo, useState } from 'react'
|
||||||
|
import DefaulModal from '@/components/DefaulModal'
|
||||||
|
import Input from '@/components/Input'
|
||||||
|
import { Add } from 'iconsax-react'
|
||||||
|
import { useGetFoods } from '@/pages/food/hooks/useFoodData'
|
||||||
|
import { formatPrice } from '@/helpers/func'
|
||||||
|
import { getFoodUnitPrice } from '../hooks/useCreateOrderForm'
|
||||||
|
import type { OrderItem } from '../types/Types'
|
||||||
|
|
||||||
|
interface AddOrderItemModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
existingItems: OrderItem[]
|
||||||
|
isLoading?: boolean
|
||||||
|
onAdd: (foodId: string, quantity: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddOrderItemModal: FC<AddOrderItemModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
existingItems,
|
||||||
|
isLoading,
|
||||||
|
onAdd,
|
||||||
|
}) => {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null)
|
||||||
|
const { data: foodsData } = useGetFoods({ limit: 500 })
|
||||||
|
const foods = foodsData?.data ?? []
|
||||||
|
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const categoryMap = new Map<string, string>()
|
||||||
|
foods.forEach((food) => {
|
||||||
|
if (food.category?.id) {
|
||||||
|
categoryMap.set(food.category.id, food.category.title)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return Array.from(categoryMap.entries())
|
||||||
|
.map(([id, title]) => ({ id, title }))
|
||||||
|
.sort((a, b) => a.title.localeCompare(b.title, 'fa'))
|
||||||
|
}, [foods])
|
||||||
|
|
||||||
|
const filteredFoods = useMemo(() => {
|
||||||
|
let result = foods
|
||||||
|
if (selectedCategoryId) {
|
||||||
|
result = result.filter((food) => food.category?.id === selectedCategoryId)
|
||||||
|
}
|
||||||
|
const query = search.trim().toLowerCase()
|
||||||
|
if (query) {
|
||||||
|
result = result.filter((food) => food.title.toLowerCase().includes(query))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}, [foods, search, selectedCategoryId])
|
||||||
|
|
||||||
|
const existingQtyByFoodId = useMemo(() => {
|
||||||
|
const map = new Map<string, number>()
|
||||||
|
existingItems.forEach((item) => {
|
||||||
|
map.set(item.food.id, item.quantity)
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
}, [existingItems])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaulModal
|
||||||
|
open={isOpen}
|
||||||
|
close={onClose}
|
||||||
|
isHeader
|
||||||
|
title_header='افزودن آیتم'
|
||||||
|
width={480}
|
||||||
|
>
|
||||||
|
<div className='mt-4 flex flex-col max-h-[60vh]'>
|
||||||
|
<Input
|
||||||
|
variant='search'
|
||||||
|
className='bg-[#EEF0F7]'
|
||||||
|
placeholder='جستجوی غذا...'
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{categories.length > 0 && (
|
||||||
|
<div className='mt-2 flex gap-1.5 overflow-x-auto flex-shrink-0 pb-0.5'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => setSelectedCategoryId(null)}
|
||||||
|
className={`flex-shrink-0 px-2.5 h-7 rounded-full text-[11px] transition-colors ${
|
||||||
|
selectedCategoryId === null
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-[#EEF0F7] text-description hover:bg-[#E4E7F0]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
همه
|
||||||
|
</button>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category.id}
|
||||||
|
type='button'
|
||||||
|
onClick={() => setSelectedCategoryId(category.id)}
|
||||||
|
className={`flex-shrink-0 px-2.5 h-7 rounded-full text-[11px] transition-colors whitespace-nowrap ${
|
||||||
|
selectedCategoryId === category.id
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-[#EEF0F7] text-description hover:bg-[#E4E7F0]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{category.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='mt-3 flex-1 min-h-0 overflow-y-auto space-y-1'>
|
||||||
|
{filteredFoods.length === 0 ? (
|
||||||
|
<div className='text-center py-8 text-xs text-description'>
|
||||||
|
غذایی یافت نشد
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredFoods.map((food) => {
|
||||||
|
const existingQty = existingQtyByFoodId.get(food.id)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={food.id}
|
||||||
|
className='flex items-center gap-2 p-1.5 rounded-lg hover:bg-[#F8F9FC] transition-colors'
|
||||||
|
>
|
||||||
|
{food.images?.[0] ? (
|
||||||
|
<img
|
||||||
|
src={food.images[0]}
|
||||||
|
alt={food.title}
|
||||||
|
className='w-9 h-9 rounded-lg object-cover flex-shrink-0'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='w-9 h-9 rounded-lg bg-gray-100 flex-shrink-0' />
|
||||||
|
)}
|
||||||
|
<div className='flex-1 min-w-0'>
|
||||||
|
<div className='text-xs truncate'>{food.title}</div>
|
||||||
|
<div className='text-[11px] text-description'>
|
||||||
|
{formatPrice(getFoodUnitPrice(food))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => onAdd(food.id, 1)}
|
||||||
|
className={`flex items-center gap-1 px-2.5 h-7 rounded-lg text-[11px] transition-colors flex-shrink-0 disabled:opacity-50 ${
|
||||||
|
existingQty
|
||||||
|
? 'bg-primary/10 text-primary'
|
||||||
|
: 'bg-primary text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Add size={12} color={existingQty ? '#000' : '#fff'} />
|
||||||
|
{existingQty ? `×${existingQty}` : 'افزودن'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DefaulModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddOrderItemModal
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { type FC, useEffect, useState } from 'react'
|
||||||
|
import DefaulModal from '@/components/DefaulModal'
|
||||||
|
import Button from '@/components/Button'
|
||||||
|
import Input from '@/components/Input'
|
||||||
|
import Textarea from '@/components/Textarea'
|
||||||
|
import Select from '@/components/Select'
|
||||||
|
import { PaymentMethodEnum } from '@/pages/paymentMethods/enum/Enum'
|
||||||
|
|
||||||
|
interface AddPaymentModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
defaultAmount: number
|
||||||
|
isLoading?: boolean
|
||||||
|
onSubmit: (amount: number, description: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddPaymentModal: FC<AddPaymentModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
defaultAmount,
|
||||||
|
isLoading,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
const [amount, setAmount] = useState(String(defaultAmount))
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
setAmount(String(Math.max(0, defaultAmount)))
|
||||||
|
setDescription('')
|
||||||
|
setError('')
|
||||||
|
}, [isOpen, defaultAmount])
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const parsed = Number(amount)
|
||||||
|
if (!parsed || parsed <= 0) {
|
||||||
|
setError('مبلغ پرداخت باید بیشتر از صفر باشد')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onSubmit(parsed, description.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaulModal
|
||||||
|
open={isOpen}
|
||||||
|
close={onClose}
|
||||||
|
isHeader
|
||||||
|
title_header='افزودن پرداخت'
|
||||||
|
width={420}
|
||||||
|
>
|
||||||
|
<div className='mt-6 space-y-4'>
|
||||||
|
<Select
|
||||||
|
label='روش پرداخت'
|
||||||
|
value={PaymentMethodEnum.Cash}
|
||||||
|
items={[{ value: PaymentMethodEnum.Cash, label: 'نقدی' }]}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label='مبلغ پرداخت (تومان)'
|
||||||
|
type='number'
|
||||||
|
inputMode='numeric'
|
||||||
|
min={1}
|
||||||
|
seprator
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAmount(e.target.value)
|
||||||
|
setError('')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
placeholder='توضیحات (اختیاری)'
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDescription(e.target.value)
|
||||||
|
setError('')
|
||||||
|
}}
|
||||||
|
className='bg-transparent border border-gray-500 rounded-xl w-full p-2 min-h-[80px]'
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <p className='text-xs text-red-500 text-center'>{error}</p>}
|
||||||
|
|
||||||
|
<div className='flex gap-4 justify-center pt-4'>
|
||||||
|
<Button
|
||||||
|
label='تایید'
|
||||||
|
onClick={handleSubmit}
|
||||||
|
isloading={isLoading}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label='انصراف'
|
||||||
|
className='bg-transparent text-black border border-primary'
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DefaulModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddPaymentModal
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { type FC, useState } from 'react'
|
import { type FC, useState } from 'react'
|
||||||
import { SearchNormal, TickCircle, UserAdd } from 'iconsax-react'
|
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 Button from '@/components/Button'
|
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()
|
||||||
@@ -76,92 +79,170 @@ const CreateOrderCustomerSection: FC<CreateOrderCustomerSectionProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isCustomerLocked = searchState !== 'not_found'
|
const isCustomerLocked = searchState !== 'not_found'
|
||||||
|
const isNameDisabled = searchState === 'idle'
|
||||||
|
const customerName = [formik.values.firstName, formik.values.lastName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
const phoneError =
|
||||||
|
formik.touched.userPhone && formik.errors.userPhone
|
||||||
|
? formik.errors.userPhone
|
||||||
|
: ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='w-full bg-white rounded-4xl p-8'>
|
<>
|
||||||
<div className='text-lg font-light'>اطلاعات مشتری</div>
|
<div className='w-full bg-white rounded-4xl px-5 py-4 flex-shrink-0'>
|
||||||
|
<div className='flex items-center justify-between gap-3'>
|
||||||
<div className='flex flex-col sm:flex-row gap-3 mt-6 sm:items-end'>
|
<div className='flex items-center gap-2'>
|
||||||
<div className='flex-1'>
|
<div
|
||||||
<Input
|
className={clx(
|
||||||
label='شماره تلفن'
|
'w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 transition-colors',
|
||||||
name='userPhone'
|
searchState === 'found' ? 'bg-green-50' : 'bg-[#EEF0F7]',
|
||||||
placeholder='۰۹۱۲۱۲۳۴۵۶۷'
|
)}
|
||||||
value={formik.values.userPhone}
|
>
|
||||||
onChange={handlePhoneChange}
|
{searchState === 'found' ? (
|
||||||
onKeyDown={(e) => {
|
<TickCircle size={18} color='#16A34A' />
|
||||||
if (e.key === 'Enter') {
|
) : searchState === 'not_found' ? (
|
||||||
e.preventDefault()
|
<UserAdd size={18} color='#8C90A3' />
|
||||||
handleSearch()
|
) : (
|
||||||
}
|
<Profile size={18} color='#8C90A3' />
|
||||||
}}
|
)}
|
||||||
error_text={
|
</div>
|
||||||
formik.touched.userPhone && formik.errors.userPhone
|
<div>
|
||||||
? formik.errors.userPhone
|
<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'>
|
||||||
/>
|
اختیاری
|
||||||
</div>
|
</span>
|
||||||
<Button
|
</div>
|
||||||
type='button'
|
{searchState === 'found' && customerName && (
|
||||||
className='w-full sm:w-fit px-6'
|
<div className='text-[11px] text-green-600 mt-0.5'>
|
||||||
isloading={isSearching}
|
{customerName}
|
||||||
onClick={handleSearch}
|
</div>
|
||||||
>
|
)}
|
||||||
<div className='flex gap-2 items-center'>
|
{searchState === 'not_found' && (
|
||||||
<SearchNormal size={18} color='#fff' />
|
<div className='text-[11px] text-description mt-0.5'>
|
||||||
<span>جستجو</span>
|
مشتری جدید
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{searchState === 'idle' && (
|
||||||
|
<div className='text-[11px] text-description mt-0.5'>
|
||||||
|
برای این سفارش وارد کردن اطلاعات مشتری الزامی نیست
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
<Button
|
||||||
|
type='button'
|
||||||
|
className='w-fit px-4 h-10'
|
||||||
|
onClick={() => setIsCustomerModalOpen(true)}
|
||||||
|
>
|
||||||
|
{searchState === 'idle' ? 'افزودن مشتری' : 'ویرایش مشتری'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{searchState === 'found' && (
|
<DefaulModal
|
||||||
<div className='mt-4 flex items-center gap-2 text-xs text-green-600 bg-green-50 rounded-xl px-4 py-3'>
|
open={isCustomerModalOpen}
|
||||||
<TickCircle size={16} color='#16A34A' />
|
close={() => setIsCustomerModalOpen(false)}
|
||||||
<span>مشتری یافت شد و اطلاعات تکمیل شد</span>
|
isHeader={true}
|
||||||
</div>
|
title_header='اطلاعات مشتری'
|
||||||
)}
|
width={700}
|
||||||
|
>
|
||||||
|
<div className='p-2'>
|
||||||
|
<div className='text-xs text-description mb-4'>
|
||||||
|
اطلاعات مشتری اختیاری است و در صورت نیاز میتوانید ثبت کنید.
|
||||||
|
</div>
|
||||||
|
|
||||||
{searchState === 'not_found' && (
|
<div className='grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-[1.2fr_1fr_1fr] gap-3 xl:items-end'>
|
||||||
<div className='mt-4 flex items-center gap-2 text-xs text-description bg-[#F8F9FC] rounded-xl px-4 py-3'>
|
<div className='w-full'>
|
||||||
<UserAdd size={16} color='#8C90A3' />
|
<label className='text-xs text-description'>شماره تلفن</label>
|
||||||
<span>مشتری یافت نشد. نام و نام خانوادگی را وارد کنید.</span>
|
<div className='flex mt-1'>
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-6'>
|
<Input
|
||||||
<Input
|
label='نام'
|
||||||
label='نام'
|
name='firstName'
|
||||||
name='firstName'
|
placeholder={isNameDisabled ? 'پس از جستجو' : 'نام'}
|
||||||
value={formik.values.firstName}
|
value={formik.values.firstName}
|
||||||
onChange={formik.handleChange}
|
onChange={formik.handleChange}
|
||||||
readOnly={isCustomerLocked}
|
readOnly={isCustomerLocked}
|
||||||
error_text={
|
disabled={isNameDisabled}
|
||||||
formik.touched.firstName && formik.errors.firstName
|
className={isNameDisabled ? 'bg-[#F8F9FC] border-0 text-description cursor-not-allowed' : ''}
|
||||||
? formik.errors.firstName
|
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' && (
|
<Input
|
||||||
<div className='mt-4 text-xs text-description'>
|
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>
|
</div>
|
||||||
)}
|
</DefaulModal>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import { type FC, useState } from 'react'
|
||||||
|
import type { FormikProps } from 'formik'
|
||||||
|
import { Edit2, Location } from 'iconsax-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import Button from '@/components/Button'
|
||||||
|
import Input from '@/components/Input'
|
||||||
|
import RadioGroup from '@/components/RadioGroup'
|
||||||
|
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 CreateOrderDeliveryModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
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 CreateOrderDeliveryModal: FC<CreateOrderDeliveryModalProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
formik,
|
||||||
|
deliveryMethods,
|
||||||
|
cashPaymentMethod,
|
||||||
|
isCourierDelivery,
|
||||||
|
isCarDelivery,
|
||||||
|
isDineIn,
|
||||||
|
customerAddresses,
|
||||||
|
selectedAddressId,
|
||||||
|
onSelectSavedAddress,
|
||||||
|
onSelectManualAddress,
|
||||||
|
onAddressFieldChange,
|
||||||
|
onLocationSelect,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('global')
|
||||||
|
const [isAddressModalOpen, setIsAddressModalOpen] = useState(false)
|
||||||
|
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
|
||||||
|
const hasAddress = Boolean(formik.values.userAddress.address?.trim())
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
formik.setFieldTouched('deliveryMethodId', true, false)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DefaulModal
|
||||||
|
open={open}
|
||||||
|
close={onClose}
|
||||||
|
isHeader={true}
|
||||||
|
title_header='تحویل و پرداخت'
|
||||||
|
width={560}
|
||||||
|
>
|
||||||
|
<div className='p-1 max-h-[70vh] overflow-y-auto'>
|
||||||
|
<div>
|
||||||
|
<div className='text-sm mb-3'>روش تحویل</div>
|
||||||
|
<RadioGroup
|
||||||
|
items={deliveryMethods.map((method) => ({
|
||||||
|
label:
|
||||||
|
method.deliveryFee > 0
|
||||||
|
? `${getDeliveryLabel(method.method)} (${formatPrice(method.deliveryFee)})`
|
||||||
|
: getDeliveryLabel(method.method),
|
||||||
|
value: method.id,
|
||||||
|
}))}
|
||||||
|
selected={formik.values.deliveryMethodId}
|
||||||
|
onChange={(value) => {
|
||||||
|
formik.setFieldValue('deliveryMethodId', value)
|
||||||
|
formik.setFieldTouched('deliveryMethodId', true, false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{formik.touched.deliveryMethodId && formik.errors.deliveryMethodId && (
|
||||||
|
<div className='text-xs text-red-500 mt-2'>
|
||||||
|
{formik.errors.deliveryMethodId}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-6'>
|
||||||
|
<div className='text-sm mb-3'>روش پرداخت</div>
|
||||||
|
{cashPaymentMethod ? (
|
||||||
|
<RadioGroup
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: cashPaymentMethod.description
|
||||||
|
? `پرداخت نقدی (${cashPaymentMethod.description})`
|
||||||
|
: 'پرداخت نقدی',
|
||||||
|
value: cashPaymentMethod.id,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
selected={formik.values.paymentMethodId}
|
||||||
|
onChange={(value) => formik.setFieldValue('paymentMethodId', value)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='text-xs text-red-500'>
|
||||||
|
روش پرداخت نقدی فعال یافت نشد
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDineIn && (
|
||||||
|
<div className='mt-6'>
|
||||||
|
<Input
|
||||||
|
label='شماره میز'
|
||||||
|
name='tableNumber'
|
||||||
|
placeholder='مثال: ۱۲'
|
||||||
|
value={formik.values.tableNumber}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCourierDelivery && (
|
||||||
|
<div className='mt-6 pt-5 border-t border-border border-dashed'>
|
||||||
|
<div className='text-sm mb-3'>آدرس تحویل</div>
|
||||||
|
|
||||||
|
{customerAddresses.length > 0 && (
|
||||||
|
<CreateOrderAddressSelector
|
||||||
|
addresses={customerAddresses}
|
||||||
|
selectedAddressId={selectedAddressId}
|
||||||
|
onSelectAddress={onSelectSavedAddress}
|
||||||
|
onSelectManual={onSelectManualAddress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasAddress ? (
|
||||||
|
<div className='flex items-start gap-2 p-3 bg-[#F8F9FC] rounded-xl'>
|
||||||
|
<Location size={16} color='#8C90A3' className='flex-shrink-0 mt-0.5' />
|
||||||
|
<div className='flex-1 min-w-0 text-xs'>
|
||||||
|
{formik.values.userAddress.address}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => setIsAddressModalOpen(true)}
|
||||||
|
className='flex-shrink-0'
|
||||||
|
>
|
||||||
|
<Edit2 size={16} color='#000' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
className='w-full'
|
||||||
|
onClick={() => setIsAddressModalOpen(true)}
|
||||||
|
>
|
||||||
|
ثبت آدرس تحویل
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCarDelivery && (
|
||||||
|
<div className='mt-6 pt-5 border-t border-border border-dashed'>
|
||||||
|
<div className='text-sm mb-3'>اطلاعات خودرو</div>
|
||||||
|
<div className='grid grid-cols-2 gap-4'>
|
||||||
|
<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='mt-6'>
|
||||||
|
<Textarea
|
||||||
|
label='توضیحات سفارش'
|
||||||
|
name='description'
|
||||||
|
placeholder='توضیحات اضافی...'
|
||||||
|
value={formik.values.description}
|
||||||
|
onChange={formik.handleChange}
|
||||||
|
isNotRequired
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-6 flex justify-end gap-3 sticky bottom-0 bg-white pt-3'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={onClose}
|
||||||
|
className='h-10 px-6 rounded-xl border border-border text-sm hover:bg-[#F8F9FC] transition-colors'
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
<Button type='button' className='w-fit px-6' onClick={handleConfirm}>
|
||||||
|
تأیید
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DefaulModal>
|
||||||
|
|
||||||
|
<DefaulModal
|
||||||
|
open={isAddressModalOpen}
|
||||||
|
close={() => setIsAddressModalOpen(false)}
|
||||||
|
isHeader={true}
|
||||||
|
title_header='آدرس تحویل'
|
||||||
|
width={600}
|
||||||
|
>
|
||||||
|
<div className='p-2'>
|
||||||
|
<CreateOrderAddressSelector
|
||||||
|
addresses={customerAddresses}
|
||||||
|
selectedAddressId={selectedAddressId}
|
||||||
|
onSelectAddress={onSelectSavedAddress}
|
||||||
|
onSelectManual={onSelectManualAddress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className='grid grid-cols-2 gap-3 mt-4'>
|
||||||
|
<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-4'>
|
||||||
|
<Textarea
|
||||||
|
label='آدرس کامل'
|
||||||
|
name='userAddress.address'
|
||||||
|
value={formik.values.userAddress.address}
|
||||||
|
onChange={onAddressFieldChange}
|
||||||
|
readOnly={isAddressReadOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-4 flex justify-end'>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
className='w-fit px-6'
|
||||||
|
onClick={() => setIsAddressModalOpen(false)}
|
||||||
|
>
|
||||||
|
تأیید
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DefaulModal>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateOrderDeliveryModal
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { type FC, useMemo, useState } from 'react'
|
import { type FC, useMemo, useState } from 'react'
|
||||||
import type { FormikProps } from 'formik'
|
import type { FormikProps } from 'formik'
|
||||||
import { Add, Minus, Trash } from 'iconsax-react'
|
import { Add } from 'iconsax-react'
|
||||||
import Input from '@/components/Input'
|
import Input from '@/components/Input'
|
||||||
import type { Food } from '@/pages/food/types/Types'
|
import type { Food } from '@/pages/food/types/Types'
|
||||||
import { formatFaNumber, formatPrice } from '@/helpers/func'
|
import { formatPrice } from '@/helpers/func'
|
||||||
import { getFoodUnitPrice } from '../hooks/useCreateOrderForm'
|
import { getFoodUnitPrice } from '../hooks/useCreateOrderForm'
|
||||||
import type { CreateOrderFormValues } from '../hooks/useCreateOrderForm'
|
import type { CreateOrderFormValues } from '../hooks/useCreateOrderForm'
|
||||||
|
|
||||||
@@ -11,43 +11,45 @@ interface CreateOrderItemsSectionProps {
|
|||||||
formik: FormikProps<CreateOrderFormValues>
|
formik: FormikProps<CreateOrderFormValues>
|
||||||
foods: Food[]
|
foods: Food[]
|
||||||
onAddFood: (foodId: string) => void
|
onAddFood: (foodId: string) => void
|
||||||
onUpdateQuantity: (foodId: string, quantity: number) => void
|
|
||||||
onRemoveItem: (foodId: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CreateOrderItemsSection: FC<CreateOrderItemsSectionProps> = ({
|
const CreateOrderItemsSection: FC<CreateOrderItemsSectionProps> = ({
|
||||||
formik,
|
formik,
|
||||||
foods,
|
foods,
|
||||||
onAddFood,
|
onAddFood,
|
||||||
onUpdateQuantity,
|
|
||||||
onRemoveItem,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null)
|
||||||
|
|
||||||
const foodsMap = useMemo(
|
const categories = useMemo(() => {
|
||||||
() => new Map(foods.map((food) => [food.id, food])),
|
const categoryMap = new Map<string, string>()
|
||||||
[foods]
|
foods.forEach((food) => {
|
||||||
)
|
if (food.category?.id) {
|
||||||
|
categoryMap.set(food.category.id, food.category.title)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return Array.from(categoryMap.entries())
|
||||||
|
.map(([id, title]) => ({ id, title }))
|
||||||
|
.sort((a, b) => a.title.localeCompare(b.title, 'fa'))
|
||||||
|
}, [foods])
|
||||||
|
|
||||||
const filteredFoods = useMemo(() => {
|
const filteredFoods = useMemo(() => {
|
||||||
|
let result = foods
|
||||||
|
if (selectedCategoryId) {
|
||||||
|
result = result.filter((food) => food.category?.id === selectedCategoryId)
|
||||||
|
}
|
||||||
const query = search.trim().toLowerCase()
|
const query = search.trim().toLowerCase()
|
||||||
if (!query) return foods
|
if (query) {
|
||||||
return foods.filter((food) => food.title.toLowerCase().includes(query))
|
result = result.filter((food) => food.title.toLowerCase().includes(query))
|
||||||
}, [foods, search])
|
}
|
||||||
|
return result
|
||||||
const selectedItems = formik.values.items
|
}, [foods, search, selectedCategoryId])
|
||||||
.map((item) => {
|
|
||||||
const food = foodsMap.get(item.foodId)
|
|
||||||
if (!food) return null
|
|
||||||
return { ...item, food }
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='w-full bg-white rounded-4xl p-8'>
|
<div className='w-full h-full bg-white rounded-4xl p-5 flex flex-col min-h-0'>
|
||||||
<div className='text-lg font-light'>آیتمهای سفارش</div>
|
<div className='text-sm font-light flex-shrink-0'>انتخاب غذا</div>
|
||||||
|
|
||||||
<div className='mt-6 border border-border rounded-xl p-4'>
|
<div className='mt-3 flex-1 min-h-0 flex flex-col border border-border rounded-xl p-3'>
|
||||||
<Input
|
<Input
|
||||||
variant='search'
|
variant='search'
|
||||||
className='bg-[#EEF0F7]'
|
className='bg-[#EEF0F7]'
|
||||||
@@ -56,9 +58,39 @@ const CreateOrderItemsSection: FC<CreateOrderItemsSectionProps> = ({
|
|||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className='mt-3 space-y-2 max-h-56 overflow-y-auto'>
|
{categories.length > 0 && (
|
||||||
|
<div className='mt-2 flex gap-1.5 overflow-x-auto flex-shrink-0 pb-0.5'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => setSelectedCategoryId(null)}
|
||||||
|
className={`flex-shrink-0 px-2.5 h-7 rounded-full text-[11px] transition-colors ${
|
||||||
|
selectedCategoryId === null
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-[#EEF0F7] text-description hover:bg-[#E4E7F0]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
همه
|
||||||
|
</button>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category.id}
|
||||||
|
type='button'
|
||||||
|
onClick={() => setSelectedCategoryId(category.id)}
|
||||||
|
className={`flex-shrink-0 px-2.5 h-7 rounded-full text-[11px] transition-colors whitespace-nowrap ${
|
||||||
|
selectedCategoryId === category.id
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-[#EEF0F7] text-description hover:bg-[#E4E7F0]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{category.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='mt-2 flex-1 min-h-0 overflow-y-auto space-y-1'>
|
||||||
{filteredFoods.length === 0 ? (
|
{filteredFoods.length === 0 ? (
|
||||||
<div className='text-center py-6 text-xs text-description'>
|
<div className='text-center py-8 text-xs text-description'>
|
||||||
غذایی یافت نشد
|
غذایی یافت نشد
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -66,37 +98,41 @@ const CreateOrderItemsSection: FC<CreateOrderItemsSectionProps> = ({
|
|||||||
const inCart = formik.values.items.some(
|
const inCart = formik.values.items.some(
|
||||||
(item) => item.foodId === food.id
|
(item) => item.foodId === food.id
|
||||||
)
|
)
|
||||||
|
const cartQty = formik.values.items.find(
|
||||||
|
(item) => item.foodId === food.id
|
||||||
|
)?.quantity
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={food.id}
|
key={food.id}
|
||||||
className='flex items-center gap-3 p-2 rounded-xl hover:bg-[#F8F9FC] transition-colors'
|
className='flex items-center gap-2 p-1.5 rounded-lg hover:bg-[#F8F9FC] transition-colors'
|
||||||
>
|
>
|
||||||
{food.images?.[0] ? (
|
{food.images?.[0] ? (
|
||||||
<img
|
<img
|
||||||
src={food.images[0]}
|
src={food.images[0]}
|
||||||
alt={food.title}
|
alt={food.title}
|
||||||
className='w-11 h-11 rounded-lg object-cover flex-shrink-0'
|
className='w-9 h-9 rounded-lg object-cover flex-shrink-0'
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className='w-11 h-11 rounded-lg bg-gray-100 flex-shrink-0' />
|
<div className='w-9 h-9 rounded-lg bg-gray-100 flex-shrink-0' />
|
||||||
)}
|
)}
|
||||||
<div className='flex-1 min-w-0'>
|
<div className='flex-1 min-w-0'>
|
||||||
<div className='text-sm truncate'>{food.title}</div>
|
<div className='text-xs truncate'>{food.title}</div>
|
||||||
<div className='text-xs text-description mt-0.5'>
|
<div className='text-[11px] text-description'>
|
||||||
{formatPrice(getFoodUnitPrice(food))}
|
{formatPrice(getFoodUnitPrice(food))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
onClick={() => onAddFood(food.id)}
|
onClick={() => onAddFood(food.id)}
|
||||||
className={`flex items-center gap-1 px-3 h-8 rounded-lg text-xs transition-colors ${
|
className={`flex items-center gap-1 px-2.5 h-7 rounded-lg text-[11px] transition-colors flex-shrink-0 ${
|
||||||
inCart
|
inCart
|
||||||
? 'bg-primary/10 text-primary'
|
? 'bg-primary/10 text-primary'
|
||||||
: 'bg-primary text-white'
|
: 'bg-primary text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Add size={14} color={inCart ? '#000' : '#fff'} />
|
<Add size={12} color={inCart ? '#000' : '#fff'} />
|
||||||
{inCart ? 'افزودن' : 'انتخاب'}
|
{inCart ? `×${cartQty}` : 'انتخاب'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -105,85 +141,9 @@ const CreateOrderItemsSection: FC<CreateOrderItemsSectionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-6'>
|
{formik.touched.items && formik.errors.items && typeof formik.errors.items === 'string' && (
|
||||||
<div className='text-sm mb-3'>سبد سفارش</div>
|
<div className='text-xs text-red-500 mt-2 flex-shrink-0'>{formik.errors.items}</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,131 +1,421 @@
|
|||||||
import { formatFaNumber, formatPrice } from "@/helpers/func";
|
import { formatFaNumber, formatPrice } from '@/helpers/func'
|
||||||
import { PaymentMethodEnum } from "@/pages/paymentMethods/enum/Enum";
|
import { PaymentMethodEnum } from '@/pages/paymentMethods/enum/Enum'
|
||||||
import { DeliveryMethodEnum } from "@/pages/shipmentMethod/enum/Enum";
|
import { DeliveryMethodEnum } from '@/pages/shipmentMethod/enum/Enum'
|
||||||
import type { FormikProps } from "formik";
|
import type { FormikProps } from 'formik'
|
||||||
import { ShoppingCart, User } from "iconsax-react";
|
import {
|
||||||
import { type FC } from "react";
|
Add,
|
||||||
import type { CreateOrderFormValues, RestaurantPaymentMethod, RestaurantShipmentMethod } from "../hooks/useCreateOrderForm";
|
ArrowLeft2,
|
||||||
|
Car,
|
||||||
|
Location,
|
||||||
|
Minus,
|
||||||
|
Moneys,
|
||||||
|
ShoppingCart,
|
||||||
|
Trash,
|
||||||
|
Truck,
|
||||||
|
} from 'iconsax-react'
|
||||||
|
import { type FC, useState } from 'react'
|
||||||
|
import type { CreateOrderFormValues, RestaurantPaymentMethod, RestaurantShipmentMethod } from '../hooks/useCreateOrderForm'
|
||||||
|
import type { UserSavedAddress } from '@/pages/customers/types/Types'
|
||||||
|
import CreateOrderDeliveryModal from './CreateOrderDeliveryModal'
|
||||||
|
|
||||||
interface OrderSummaryData {
|
interface OrderSummaryData {
|
||||||
lineItems: Array<{
|
lineItems: Array<{
|
||||||
foodId: string;
|
foodId: string
|
||||||
title: string;
|
title: string
|
||||||
image: string;
|
image: string
|
||||||
quantity: number;
|
quantity: number
|
||||||
unitPrice: number;
|
unitPrice: number
|
||||||
totalPrice: number;
|
totalPrice: number
|
||||||
} | null>;
|
} | null>
|
||||||
subTotal: number;
|
subTotal: number
|
||||||
deliveryFee: number;
|
deliveryFee: number
|
||||||
total: number;
|
packingFee: number
|
||||||
totalItems: number;
|
discountAmount: number
|
||||||
|
total: number
|
||||||
|
totalItems: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateOrderSidebarProps {
|
interface CreateOrderSidebarProps {
|
||||||
formik: FormikProps<CreateOrderFormValues>;
|
formik: FormikProps<CreateOrderFormValues>
|
||||||
orderSummary: OrderSummaryData;
|
orderSummary: OrderSummaryData
|
||||||
selectedDeliveryMethod?: RestaurantShipmentMethod;
|
deliveryMethods: RestaurantShipmentMethod[]
|
||||||
selectedPaymentMethod?: RestaurantPaymentMethod;
|
cashPaymentMethod?: RestaurantPaymentMethod
|
||||||
|
selectedDeliveryMethod?: RestaurantShipmentMethod
|
||||||
|
selectedPaymentMethod?: 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
|
||||||
|
onUpdateQuantity: (foodId: string, quantity: number) => void
|
||||||
|
onRemoveItem: (foodId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDeliveryMethodText = (method?: string): string => {
|
const getDeliveryMethodText = (method?: string): string => {
|
||||||
const methodMap: Record<string, string> = {
|
const methodMap: Record<string, string> = {
|
||||||
[DeliveryMethodEnum.DineIn]: "سرو در رستوران",
|
[DeliveryMethodEnum.DineIn]: 'سرو در رستوران',
|
||||||
[DeliveryMethodEnum.CustomerPickup]: "دریافت از محل",
|
[DeliveryMethodEnum.CustomerPickup]: 'دریافت از محل',
|
||||||
[DeliveryMethodEnum.DeliveryCar]: "تحویل به خودرو",
|
[DeliveryMethodEnum.DeliveryCar]: 'تحویل به خودرو',
|
||||||
[DeliveryMethodEnum.DeliveryCourier]: "بیرونبر",
|
[DeliveryMethodEnum.DeliveryCourier]: 'بیرونبر',
|
||||||
};
|
}
|
||||||
return method ? methodMap[method] || method : "-";
|
return method ? methodMap[method] || method : 'انتخاب نشده'
|
||||||
};
|
}
|
||||||
|
|
||||||
const getPaymentMethodText = (method?: string): string => {
|
const getPaymentMethodText = (method?: string): string => {
|
||||||
const methodMap: Record<string, string> = {
|
const methodMap: Record<string, string> = {
|
||||||
[PaymentMethodEnum.Cash]: "پرداخت نقدی",
|
[PaymentMethodEnum.Cash]: 'پرداخت نقدی',
|
||||||
[PaymentMethodEnum.Online]: "پرداخت آنلاین",
|
[PaymentMethodEnum.Online]: 'پرداخت آنلاین',
|
||||||
[PaymentMethodEnum.CreditCard]: "کارت به کارت",
|
[PaymentMethodEnum.CreditCard]: 'کارت به کارت',
|
||||||
[PaymentMethodEnum.Wallet]: "کیف پول",
|
[PaymentMethodEnum.Wallet]: 'کیف پول',
|
||||||
};
|
}
|
||||||
return method ? methodMap[method] || method : "-";
|
return method ? methodMap[method] || method : 'انتخاب نشده'
|
||||||
};
|
}
|
||||||
|
|
||||||
const CreateOrderSidebar: FC<CreateOrderSidebarProps> = ({ formik, orderSummary, selectedDeliveryMethod, selectedPaymentMethod }) => {
|
const CreateOrderSidebar: FC<CreateOrderSidebarProps> = ({
|
||||||
const customerName = [formik.values.firstName, formik.values.lastName].filter(Boolean).join(" ");
|
formik,
|
||||||
|
orderSummary,
|
||||||
|
deliveryMethods,
|
||||||
|
cashPaymentMethod,
|
||||||
|
selectedDeliveryMethod,
|
||||||
|
selectedPaymentMethod,
|
||||||
|
isCourierDelivery,
|
||||||
|
isCarDelivery,
|
||||||
|
isDineIn,
|
||||||
|
customerAddresses,
|
||||||
|
selectedAddressId,
|
||||||
|
onSelectSavedAddress,
|
||||||
|
onSelectManualAddress,
|
||||||
|
onAddressFieldChange,
|
||||||
|
onLocationSelect,
|
||||||
|
onUpdateQuantity,
|
||||||
|
onRemoveItem,
|
||||||
|
}) => {
|
||||||
|
const [isDeliveryModalOpen, setIsDeliveryModalOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
const customerName = [formik.values.firstName, formik.values.lastName].filter(Boolean).join(' ')
|
||||||
<div className="space-y-6">
|
const deliveryText = getDeliveryMethodText(selectedDeliveryMethod?.method)
|
||||||
<div className="bg-white rounded-4xl p-8">
|
const paymentText = getPaymentMethodText(selectedPaymentMethod?.method)
|
||||||
<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">
|
const deliveryFeeText =
|
||||||
<div className="flex justify-between items-center border-b border-border border-dashed pb-3">
|
selectedDeliveryMethod && selectedDeliveryMethod.deliveryFee > 0
|
||||||
<span className="text-description">مشتری</span>
|
? formatPrice(selectedDeliveryMethod.deliveryFee)
|
||||||
<span>{customerName || "-"}</span>
|
: null
|
||||||
</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">
|
const getDeliveryExtraInfo = (): { text: string; warning?: boolean } | null => {
|
||||||
<div className="flex items-center gap-2 text-lg font-light">
|
if (isCourierDelivery) {
|
||||||
<ShoppingCart size={20} color="#000" />
|
const address = formik.values.userAddress.address?.trim()
|
||||||
آیتمها
|
if (!address) return { text: 'آدرس تحویل ثبت نشده', warning: true }
|
||||||
{orderSummary.totalItems > 0 && <span className="text-xs text-description">({formatFaNumber(orderSummary.totalItems)})</span>}
|
return { text: address }
|
||||||
</div>
|
}
|
||||||
|
if (isCarDelivery) {
|
||||||
|
const { carModel, plateNumber } = formik.values.carAddress
|
||||||
|
if (!carModel && !plateNumber) return { text: 'اطلاعات خودرو وارد نشده', warning: true }
|
||||||
|
return { text: [carModel, plateNumber].filter(Boolean).join(' · ') }
|
||||||
|
}
|
||||||
|
if (isDineIn && formik.values.tableNumber) {
|
||||||
|
return { text: `میز ${formik.values.tableNumber}` }
|
||||||
|
}
|
||||||
|
if (formik.values.description?.trim()) {
|
||||||
|
return { text: formik.values.description }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
{orderSummary.lineItems.length === 0 ? (
|
const extraInfo = getDeliveryExtraInfo()
|
||||||
<div className="mt-6 text-center text-xs text-description py-6">سبد خالی است</div>
|
const isDeliverySelected = Boolean(selectedDeliveryMethod)
|
||||||
) : (
|
const isPaymentSelected = Boolean(selectedPaymentMethod)
|
||||||
<div className="mt-6 space-y-3">
|
|
||||||
{orderSummary.lineItems.map((item) => {
|
return (
|
||||||
if (!item) return null;
|
<>
|
||||||
return (
|
<div className='bg-white rounded-4xl p-4 flex-1 min-h-0 flex flex-col h-full'>
|
||||||
<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 items-center justify-between flex-shrink-0'>
|
||||||
<div className="flex-1">
|
<div className='flex items-center gap-2 text-sm font-light'>
|
||||||
<div>{item.title}</div>
|
<ShoppingCart size={18} color='#000' />
|
||||||
<div className="text-description mt-1">
|
خلاصه سفارش
|
||||||
{formatFaNumber(item.quantity)} × {formatPrice(item.unitPrice)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{orderSummary.totalItems > 0 && (
|
||||||
<div>{formatPrice(item.totalPrice)}</div>
|
<span className='text-[11px] text-description bg-[#F8F9FC] rounded-full px-2.5 py-0.5'>
|
||||||
|
{formatFaNumber(orderSummary.totalItems)} آیتم
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-6 space-y-3 text-[13px] font-light">
|
{(customerName || formik.values.userPhone) && (
|
||||||
<div className="flex justify-between items-center">
|
<div className='mt-2 p-2.5 bg-[#F8F9FC] rounded-xl flex-shrink-0'>
|
||||||
<span className="text-description">جمع آیتمها</span>
|
<div className='text-[11px] font-medium truncate'>
|
||||||
<span>{formatPrice(orderSummary.subTotal)}</span>
|
{customerName || 'مشتری'}
|
||||||
</div>
|
</div>
|
||||||
{orderSummary.deliveryFee > 0 && (
|
{formik.values.userPhone && (
|
||||||
<div className="flex justify-between items-center">
|
<div className='text-[10px] text-description mt-0.5'>
|
||||||
<span className="text-description">هزینه ارسال</span>
|
{formik.values.userPhone}
|
||||||
<span>{formatPrice(orderSummary.deliveryFee)}</span>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => setIsDeliveryModalOpen(true)}
|
||||||
|
className={`mt-2 w-full text-right p-2.5 rounded-xl border transition-colors flex-shrink-0 ${
|
||||||
|
isDeliverySelected && isPaymentSelected
|
||||||
|
? 'border-border hover:border-primary/40 hover:bg-[#F8F9FC]'
|
||||||
|
: 'border-dashed border-primary/50 bg-primary/5 hover:bg-primary/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className='flex items-center justify-between gap-2'>
|
||||||
|
<span className='text-xs font-medium'>تحویل و پرداخت</span>
|
||||||
|
<span className='text-[11px] text-primary flex items-center gap-1 flex-shrink-0'>
|
||||||
|
{isDeliverySelected && isPaymentSelected ? 'ویرایش' : 'انتخاب'}
|
||||||
|
<ArrowLeft2 size={12} color='#000' />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isDeliverySelected || !isPaymentSelected ? (
|
||||||
|
<p className='mt-1.5 text-[11px] text-description'>
|
||||||
|
برای تعیین روش تحویل و پرداخت کلیک کنید
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className='mt-1.5 space-y-1.5'>
|
||||||
|
<div className='flex items-center gap-2 text-xs'>
|
||||||
|
<Truck size={15} color='#8C90A3' className='flex-shrink-0' />
|
||||||
|
<span
|
||||||
|
className={`flex-1 truncate ${
|
||||||
|
!isDeliverySelected ? 'text-description italic' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{deliveryText}
|
||||||
|
</span>
|
||||||
|
{deliveryFeeText && (
|
||||||
|
<span className='text-description text-[11px] flex-shrink-0'>
|
||||||
|
{deliveryFeeText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2 text-xs'>
|
||||||
|
<Moneys size={15} color='#8C90A3' className='flex-shrink-0' />
|
||||||
|
<span
|
||||||
|
className={`truncate ${
|
||||||
|
!isPaymentSelected ? 'text-description italic' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{paymentText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{extraInfo && (
|
||||||
|
<div
|
||||||
|
className={`flex items-start gap-2 text-[11px] pt-1 border-t border-border border-dashed ${
|
||||||
|
extraInfo.warning ? 'text-red-500' : 'text-description'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isCourierDelivery ? (
|
||||||
|
<Location size={14} className='flex-shrink-0 mt-0.5' color={extraInfo.warning ? '#EF4444' : '#8C90A3'} />
|
||||||
|
) : isCarDelivery ? (
|
||||||
|
<Car size={14} className='flex-shrink-0 mt-0.5' color={extraInfo.warning ? '#EF4444' : '#8C90A3'} />
|
||||||
|
) : null}
|
||||||
|
<span className='line-clamp-2'>{extraInfo.text}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<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-1.5 flex-shrink-0'>سبد سفارش</div>
|
||||||
|
|
||||||
|
<div className='flex-1 min-h-0 overflow-y-auto relative'>
|
||||||
|
{orderSummary.lineItems.length === 0 ? (
|
||||||
|
<div className='h-full flex flex-col items-center justify-center text-center py-6'>
|
||||||
|
<ShoppingCart size={32} color='#D0D0D0' />
|
||||||
|
<div className='text-xs text-description mt-2'>
|
||||||
|
هنوز آیتمی انتخاب نشده
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='space-y-2 pb-2'>
|
||||||
|
{orderSummary.lineItems.map((item) => {
|
||||||
|
if (!item) return null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.foodId}
|
||||||
|
className='rounded-xl bg-[#F8F9FC] overflow-hidden'
|
||||||
|
>
|
||||||
|
<div className='flex items-start gap-2.5 p-2.5'>
|
||||||
|
{item.image ? (
|
||||||
|
<img
|
||||||
|
src={item.image}
|
||||||
|
alt={item.title}
|
||||||
|
className='w-11 h-11 rounded-lg object-cover flex-shrink-0'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='w-11 h-11 rounded-lg bg-gray-200 flex-shrink-0' />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='flex-1 min-w-0'>
|
||||||
|
<div className='flex items-start gap-1.5'>
|
||||||
|
<div className='flex-1 min-w-0 text-xs font-medium leading-5 line-clamp-2'>
|
||||||
|
{item.title}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => onRemoveItem(item.foodId)}
|
||||||
|
className='flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center hover:bg-red-50 transition-colors'
|
||||||
|
aria-label='حذف آیتم'
|
||||||
|
>
|
||||||
|
<Trash size={15} color='#EF4444' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className='mt-1 text-[11px] text-description tabular-nums'>
|
||||||
|
{item.quantity > 1
|
||||||
|
? `${formatFaNumber(item.quantity)} × ${formatPrice(item.unitPrice)}`
|
||||||
|
: formatPrice(item.unitPrice)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between gap-3 px-2.5 py-2 border-t border-[#ECEEF5] bg-white/60'>
|
||||||
|
<div className='flex items-center gap-0.5 rounded-lg bg-white border border-border p-0.5 flex-shrink-0'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() =>
|
||||||
|
onUpdateQuantity(
|
||||||
|
item.foodId,
|
||||||
|
item.quantity - 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className='w-7 h-7 rounded-md flex items-center justify-center hover:bg-[#F8F9FC] transition-colors'
|
||||||
|
>
|
||||||
|
<Minus size={13} color='#000' />
|
||||||
|
</button>
|
||||||
|
<span className='w-6 text-center text-xs tabular-nums'>
|
||||||
|
{formatFaNumber(item.quantity)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() =>
|
||||||
|
onUpdateQuantity(
|
||||||
|
item.foodId,
|
||||||
|
item.quantity + 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className='w-7 h-7 rounded-md flex items-center justify-center hover:bg-[#F8F9FC] transition-colors'
|
||||||
|
>
|
||||||
|
<Add size={13} color='#000' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className='text-xs font-semibold whitespace-nowrap tabular-nums flex-shrink-0'>
|
||||||
|
{formatPrice(item.totalPrice)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{orderSummary.lineItems.length > 3 && (
|
||||||
|
<div className='pointer-events-none absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-white to-transparent' />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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'>
|
||||||
|
<span className='text-description'>جمع آیتمها</span>
|
||||||
|
<span>{formatPrice(orderSummary.subTotal)}</span>
|
||||||
|
</div>
|
||||||
|
{orderSummary.deliveryFee > 0 && (
|
||||||
|
<div className='flex justify-between'>
|
||||||
|
<span className='text-description'>هزینه ارسال</span>
|
||||||
|
<span>{formatPrice(orderSummary.deliveryFee)}</span>
|
||||||
|
</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'>
|
||||||
|
<span>جمع کل</span>
|
||||||
|
<span>{formatPrice(orderSummary.total)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</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;
|
<CreateOrderDeliveryModal
|
||||||
|
open={isDeliveryModalOpen}
|
||||||
|
onClose={() => setIsDeliveryModalOpen(false)}
|
||||||
|
formik={formik}
|
||||||
|
deliveryMethods={deliveryMethods}
|
||||||
|
cashPaymentMethod={cashPaymentMethod}
|
||||||
|
isCourierDelivery={isCourierDelivery}
|
||||||
|
isCarDelivery={isCarDelivery}
|
||||||
|
isDineIn={isDineIn}
|
||||||
|
customerAddresses={customerAddresses}
|
||||||
|
selectedAddressId={selectedAddressId}
|
||||||
|
onSelectSavedAddress={onSelectSavedAddress}
|
||||||
|
onSelectManualAddress={onSelectManualAddress}
|
||||||
|
onAddressFieldChange={onAddressFieldChange}
|
||||||
|
onLocationSelect={onLocationSelect}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateOrderSidebar
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Location, User, Call, Cake, Heart } from 'iconsax-react'
|
import { Location, User, Call } from 'iconsax-react'
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import type { Order } from '../types/Types'
|
import type { Order } from '../types/Types'
|
||||||
import { formatOptionalDate, formatFaNumber } from '@/helpers/func'
|
import { formatFaNumber } from '@/helpers/func'
|
||||||
|
|
||||||
interface CustomerInfoProps {
|
interface CustomerInfoProps {
|
||||||
order: Order
|
order: Order
|
||||||
@@ -14,7 +14,7 @@ const CustomerInfo: FC<CustomerInfoProps> = ({ order }) => {
|
|||||||
: '-'
|
: '-'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='w-full bg-white rounded-4xl p-8'>
|
<div className='w-full bg-white rounded-4xl p-4 sm:p-6 lg:p-8'>
|
||||||
<div className='flex justify-between items-center'>
|
<div className='flex justify-between items-center'>
|
||||||
<div className='text-lg font-light flex items-center gap-2'>
|
<div className='text-lg font-light flex items-center gap-2'>
|
||||||
<User color='#000' size={20} />
|
<User color='#000' size={20} />
|
||||||
@@ -22,7 +22,7 @@ const CustomerInfo: FC<CustomerInfoProps> = ({ order }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-8 flex flex-wrap gap-y-7 gap-20 text-[13px] font-light border-b border-dashed border-border pb-8'>
|
<div className='mt-6 sm:mt-8 flex flex-wrap gap-x-8 gap-y-4 sm:gap-x-12 sm:gap-y-7 text-[13px] font-light border-b border-dashed border-border pb-6 sm:pb-8'>
|
||||||
<div className='flex gap-2'>
|
<div className='flex gap-2'>
|
||||||
<div className='text-description'>
|
<div className='text-description'>
|
||||||
شماره سفارش:
|
شماره سفارش:
|
||||||
@@ -53,46 +53,7 @@ const CustomerInfo: FC<CustomerInfoProps> = ({ order }) => {
|
|||||||
{order.user?.phone || '-'}
|
{order.user?.phone || '-'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{order.user?.birthDate && (
|
|
||||||
<div className='flex gap-2'>
|
|
||||||
<div className='text-description'>تاریخ تولد:</div>
|
|
||||||
<div className='flex items-center gap-1'>
|
|
||||||
<Cake size={14} />
|
|
||||||
{formatOptionalDate(order.user.birthDate, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit'
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{order.user?.marriageDate && (
|
|
||||||
<div className='flex gap-2'>
|
|
||||||
<div className='text-description'>تاریخ ازدواج:</div>
|
|
||||||
<div className='flex items-center gap-1'>
|
|
||||||
<Heart size={14} />
|
|
||||||
{formatOptionalDate(order.user.marriageDate, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit'
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='flex gap-2'>
|
|
||||||
<div className='text-description'>جنسیت:</div>
|
|
||||||
<div>{order.user?.gender ? 'مرد' : 'زن'}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex gap-2'>
|
|
||||||
<div className='text-description'>وضعیت:</div>
|
|
||||||
<div className={`px-2 py-1 rounded text-xs ${order.user?.isActive ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
|
||||||
{order.user?.isActive ? 'فعال' : 'غیرفعال'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{order.user?.referrer && (
|
{order.user?.referrer && (
|
||||||
<div className='flex gap-2'>
|
<div className='flex gap-2'>
|
||||||
@@ -103,13 +64,13 @@ const CustomerInfo: FC<CustomerInfoProps> = ({ order }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{order.userAddress && (
|
{order.userAddress && (
|
||||||
<div className='mt-8 space-y-4 text-[13px] font-light'>
|
<div className='mt-6 sm:mt-8 space-y-4 text-[13px] font-light'>
|
||||||
<div className='flex gap-2 items-start'>
|
<div className='flex flex-col sm:flex-row gap-1 sm:gap-2 items-start'>
|
||||||
<div className='text-description flex items-center gap-1'>
|
<div className='text-description flex items-center gap-1 shrink-0'>
|
||||||
<Location size={14} />
|
<Location size={14} />
|
||||||
آدرس کامل:
|
آدرس کامل:
|
||||||
</div>
|
</div>
|
||||||
<div className='flex-1'>{fullAddress}</div>
|
<div className='flex-1 min-w-0 break-words'>{fullAddress}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{order.userAddress.fullName && (
|
{order.userAddress.fullName && (
|
||||||
|
|||||||
@@ -1,127 +1,162 @@
|
|||||||
import Button from '@/components/Button'
|
import Button from "@/components/Button";
|
||||||
import { type FC, useState, useEffect } from 'react'
|
import { type FC, useState, useEffect } from "react";
|
||||||
|
|
||||||
interface OrderActionsProps {
|
interface OrderActionsProps {
|
||||||
onPrint: () => void
|
onPrint: () => void;
|
||||||
showSendToKitchenButton: boolean
|
showSendToKitchenButton: boolean;
|
||||||
showCancelButton: boolean
|
showCancelButton: boolean;
|
||||||
isPreparing: boolean
|
isPreparing: boolean;
|
||||||
isCourierDelivery: boolean
|
isCourierDelivery: boolean;
|
||||||
isDineInOrCarDelivery: boolean
|
isDineInOrCarDelivery: boolean;
|
||||||
isCustomerPickup: boolean
|
isCustomerPickup: boolean;
|
||||||
isCanceled: boolean
|
isCanceled: boolean;
|
||||||
isChangingStatus: boolean
|
canCompleteOrder: boolean;
|
||||||
onSendToKitchen: () => void
|
canSendPleaseComeSms: boolean;
|
||||||
onDeliverToCourier: () => void
|
isChangingStatus: boolean;
|
||||||
onDeliverToWaiter: () => void
|
isSendingPleaseComeSms?: boolean;
|
||||||
onDeliverToReceptionist: () => void
|
onSendToKitchen: () => void;
|
||||||
onCancelOrder: () => void
|
onDeliverToCourier: () => void;
|
||||||
|
onDeliverToWaiter: () => void;
|
||||||
|
onDeliverToReceptionist: () => void;
|
||||||
|
onCompleteOrder: () => void;
|
||||||
|
onCancelOrder: () => void;
|
||||||
|
onSendPleaseComeSms: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OrderActions: FC<OrderActionsProps> = ({
|
const OrderActions: FC<OrderActionsProps> = ({
|
||||||
onPrint,
|
onPrint,
|
||||||
showSendToKitchenButton,
|
showSendToKitchenButton,
|
||||||
showCancelButton,
|
showCancelButton,
|
||||||
isPreparing,
|
isPreparing,
|
||||||
isCourierDelivery,
|
isCourierDelivery,
|
||||||
isDineInOrCarDelivery,
|
isDineInOrCarDelivery,
|
||||||
isCustomerPickup,
|
isCustomerPickup,
|
||||||
isCanceled,
|
isCanceled,
|
||||||
isChangingStatus,
|
canCompleteOrder,
|
||||||
onSendToKitchen,
|
canSendPleaseComeSms,
|
||||||
onDeliverToCourier,
|
isChangingStatus,
|
||||||
onDeliverToWaiter,
|
isSendingPleaseComeSms,
|
||||||
onDeliverToReceptionist,
|
onSendToKitchen,
|
||||||
onCancelOrder
|
onDeliverToCourier,
|
||||||
|
onDeliverToWaiter,
|
||||||
|
onDeliverToReceptionist,
|
||||||
|
onCompleteOrder,
|
||||||
|
onCancelOrder,
|
||||||
|
onSendPleaseComeSms,
|
||||||
}) => {
|
}) => {
|
||||||
const [loadingAction, setLoadingAction] = useState<string | null>(null)
|
const [loadingAction, setLoadingAction] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isChangingStatus) {
|
if (!isChangingStatus) {
|
||||||
setLoadingAction(null)
|
setLoadingAction(null);
|
||||||
}
|
|
||||||
}, [isChangingStatus])
|
|
||||||
|
|
||||||
const handleSendToKitchen = () => {
|
|
||||||
setLoadingAction('sendToKitchen')
|
|
||||||
onSendToKitchen()
|
|
||||||
}
|
}
|
||||||
|
}, [isChangingStatus]);
|
||||||
|
|
||||||
const handleDeliverToCourier = () => {
|
const handleSendToKitchen = () => {
|
||||||
setLoadingAction('deliverToCourier')
|
setLoadingAction("sendToKitchen");
|
||||||
onDeliverToCourier()
|
onSendToKitchen();
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDeliverToWaiter = () => {
|
const handleDeliverToCourier = () => {
|
||||||
setLoadingAction('deliverToWaiter')
|
setLoadingAction("deliverToCourier");
|
||||||
onDeliverToWaiter()
|
onDeliverToCourier();
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDeliverToReceptionist = () => {
|
const handleDeliverToWaiter = () => {
|
||||||
setLoadingAction('deliverToReceptionist')
|
setLoadingAction("deliverToWaiter");
|
||||||
onDeliverToReceptionist()
|
onDeliverToWaiter();
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleCancelOrder = () => {
|
const handleDeliverToReceptionist = () => {
|
||||||
setLoadingAction('cancelOrder')
|
setLoadingAction("deliverToReceptionist");
|
||||||
onCancelOrder()
|
onDeliverToReceptionist();
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
const handleCancelOrder = () => {
|
||||||
<div className='mt-6 flex flex-col gap-3'>
|
setLoadingAction("cancelOrder");
|
||||||
<Button
|
onCancelOrder();
|
||||||
label='چاپ'
|
};
|
||||||
onClick={onPrint}
|
|
||||||
className='bg-slate-700 hover:bg-slate-800'
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!isCanceled && showSendToKitchenButton && (
|
const handleCompleteOrder = () => {
|
||||||
<Button
|
setLoadingAction("completeOrder");
|
||||||
label='ارسال به آشپزخانه'
|
onCompleteOrder();
|
||||||
onClick={handleSendToKitchen}
|
};
|
||||||
isloading={isChangingStatus && loadingAction === 'sendToKitchen'}
|
|
||||||
className='bg-orange-600 hover:bg-orange-700'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isCanceled && isPreparing && isCourierDelivery && (
|
return (
|
||||||
<Button
|
<div className="mt-5 sm:mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-1 gap-3">
|
||||||
label='تحویل به پیک'
|
<div className="flex gap-3">
|
||||||
onClick={handleDeliverToCourier}
|
<Button
|
||||||
isloading={isChangingStatus && loadingAction === 'deliverToCourier'}
|
label="چاپ"
|
||||||
className='bg-blue-600 hover:bg-blue-700'
|
onClick={onPrint}
|
||||||
/>
|
className="border bg-white border-slate-700 text-slate-700 hover:bg-slate-100"
|
||||||
)}
|
/>
|
||||||
|
|
||||||
{!isCanceled && isPreparing && isDineInOrCarDelivery && (
|
{canSendPleaseComeSms && (
|
||||||
<Button
|
<Button
|
||||||
label='تحویل به گارسون'
|
label="ارسال پیام"
|
||||||
onClick={handleDeliverToWaiter}
|
onClick={onSendPleaseComeSms}
|
||||||
isloading={isChangingStatus && loadingAction === 'deliverToWaiter'}
|
isloading={isSendingPleaseComeSms}
|
||||||
className='bg-purple-600 hover:bg-purple-700'
|
className="border bg-white border-teal-600 text-teal-600 hover:bg-teal-100"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{!isCanceled && showCancelButton && (
|
||||||
|
<Button
|
||||||
|
label="لغو سفارش"
|
||||||
|
onClick={handleCancelOrder}
|
||||||
|
isloading={isChangingStatus && loadingAction === "cancelOrder"}
|
||||||
|
className="bg-transparent border border-red-500 text-red-500 hover:bg-red-100 "
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{!isCanceled && isPreparing && isCustomerPickup && (
|
{!isCanceled && showSendToKitchenButton && (
|
||||||
<Button
|
<Button
|
||||||
label='تحویل به رسپشنیست'
|
label="ارسال به آشپزخانه"
|
||||||
onClick={handleDeliverToReceptionist}
|
onClick={handleSendToKitchen}
|
||||||
isloading={isChangingStatus && loadingAction === 'deliverToReceptionist'}
|
isloading={isChangingStatus && loadingAction === "sendToKitchen"}
|
||||||
className='bg-indigo-600 hover:bg-indigo-700'
|
className="bg-orange-600 hover:bg-orange-700"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isCanceled && showCancelButton && (
|
{!isCanceled && isPreparing && isCourierDelivery && (
|
||||||
<Button
|
<Button
|
||||||
label='لغو سفارش'
|
label="تحویل به پیک"
|
||||||
onClick={handleCancelOrder}
|
onClick={handleDeliverToCourier}
|
||||||
isloading={isChangingStatus && loadingAction === 'cancelOrder'}
|
isloading={isChangingStatus && loadingAction === "deliverToCourier"}
|
||||||
className='bg-transparent border border-red-500 text-red-500 hover:bg-red-50'
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default OrderActions
|
{!isCanceled && isPreparing && isDineInOrCarDelivery && (
|
||||||
|
<Button
|
||||||
|
label="تحویل به گارسون"
|
||||||
|
onClick={handleDeliverToWaiter}
|
||||||
|
isloading={isChangingStatus && loadingAction === "deliverToWaiter"}
|
||||||
|
className="bg-purple-600 hover:bg-purple-700"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isCanceled && isPreparing && isCustomerPickup && (
|
||||||
|
<Button
|
||||||
|
label="تحویل به رسپشنیست"
|
||||||
|
onClick={handleDeliverToReceptionist}
|
||||||
|
isloading={
|
||||||
|
isChangingStatus && loadingAction === "deliverToReceptionist"
|
||||||
|
}
|
||||||
|
className="bg-indigo-600 hover:bg-indigo-700"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isCanceled && canCompleteOrder && (
|
||||||
|
<Button
|
||||||
|
label="اتمام سفارش"
|
||||||
|
onClick={handleCompleteOrder}
|
||||||
|
isloading={isChangingStatus && loadingAction === "completeOrder"}
|
||||||
|
className="bg-green-600 hover:bg-green-700"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OrderActions;
|
||||||
|
|||||||
@@ -15,11 +15,9 @@ export const useOrderFiltersFields = (): FieldType[] => {
|
|||||||
placeholder: 'وضعیت سفارش',
|
placeholder: 'وضعیت سفارش',
|
||||||
defaultValue: [],
|
defaultValue: [],
|
||||||
options: [
|
options: [
|
||||||
{ label: 'در انتظار پرداخت', value: OrderStatus.PENDING_PAYMENT },
|
{ label: 'در انتظار پرداخت', value: OrderStatus.NEW },
|
||||||
{ label: 'پرداخت شده', value: OrderStatus.PAID },
|
{ label: 'در حال آماده سازی', value: OrderStatus.PREPARING },
|
||||||
{ label: 'در حال آماده سازی', value: OrderStatus.PREPARING },
|
{ label: 'تحویل به پیشخدمت', value: OrderStatus.DELIVERED_TO_WAITER },
|
||||||
{ label: 'آماده', value: OrderStatus.READY },
|
|
||||||
{ label: 'تحویل به پیشخدمت', value: OrderStatus.DELIVERED_TO_WAITER },
|
|
||||||
{ label: 'تحویل به پذیرش', value: OrderStatus.DELIVERED_TO_RECEPTIONIST },
|
{ label: 'تحویل به پذیرش', value: OrderStatus.DELIVERED_TO_RECEPTIONIST },
|
||||||
{ label: 'ارسال شده', value: OrderStatus.SHIPPED },
|
{ label: 'ارسال شده', value: OrderStatus.SHIPPED },
|
||||||
{ label: 'تکمیل شده', value: OrderStatus.COMPLETED },
|
{ label: 'تکمیل شده', value: OrderStatus.COMPLETED },
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import Table from '@/components/Table'
|
import Table from '@/components/Table'
|
||||||
import { ShoppingCart, Calendar, Clock, Tag } from 'iconsax-react'
|
import Status from '@/components/Status'
|
||||||
|
import Button from '@/components/Button'
|
||||||
|
import { ShoppingCart, Calendar, Clock, Tag, Add, Minus, Trash } from 'iconsax-react'
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import type { ColumnType, RowDataType } from '@/components/types/TableTypes'
|
import type { ColumnType, RowDataType } from '@/components/types/TableTypes'
|
||||||
import type { Order, OrderItem as OrderItemType } from '../types/Types'
|
import type { Order, OrderItem as OrderItemType } from '../types/Types'
|
||||||
import { formatOptionalDate, formatPrice, formatFaNumber } from '@/helpers/func'
|
import { formatOptionalDate, formatPrice, formatFaNumber } from '@/helpers/func'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import OrderSummary from './OrderSummary'
|
|
||||||
|
|
||||||
interface OrderItemTable extends RowDataType {
|
interface OrderItemTable extends RowDataType {
|
||||||
|
id: string
|
||||||
image: string
|
image: string
|
||||||
title: string
|
title: string
|
||||||
quantity: number
|
quantity: number
|
||||||
@@ -19,9 +21,35 @@ interface OrderItemTable extends RowDataType {
|
|||||||
interface OrderItemsDetailsProps {
|
interface OrderItemsDetailsProps {
|
||||||
order: Order
|
order: Order
|
||||||
getStatusColor: (status: string) => string
|
getStatusColor: (status: string) => string
|
||||||
|
canEditItems?: boolean
|
||||||
|
isUpdatingItem?: boolean
|
||||||
|
onAddItem?: () => void
|
||||||
|
onUpdateQuantity?: (itemId: string, quantity: number) => void
|
||||||
|
onRemoveItem?: (itemId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const OrderItemsDetails: FC<OrderItemsDetailsProps> = ({ order, getStatusColor }) => {
|
const getStatusVariant = (status: string): 'success' | 'error' | 'warning' | 'info' | 'pending' => {
|
||||||
|
const variantMap: Record<string, 'success' | 'error' | 'warning' | 'info' | 'pending'> = {
|
||||||
|
'new': 'pending',
|
||||||
|
'preparing': 'warning',
|
||||||
|
'deliveredToWaiter': 'info',
|
||||||
|
'deliveredToReceptionist': 'info',
|
||||||
|
'shipped': 'info',
|
||||||
|
'completed': 'success',
|
||||||
|
'canceled': 'error',
|
||||||
|
}
|
||||||
|
return variantMap[status] || 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
const OrderItemsDetails: FC<OrderItemsDetailsProps> = ({
|
||||||
|
order,
|
||||||
|
getStatusColor,
|
||||||
|
canEditItems = false,
|
||||||
|
isUpdatingItem = false,
|
||||||
|
onAddItem,
|
||||||
|
onUpdateQuantity,
|
||||||
|
onRemoveItem,
|
||||||
|
}) => {
|
||||||
const { t } = useTranslation('global')
|
const { t } = useTranslation('global')
|
||||||
|
|
||||||
const orderItems: OrderItemTable[] = order.items.map((item: OrderItemType) => ({
|
const orderItems: OrderItemTable[] = order.items.map((item: OrderItemType) => ({
|
||||||
@@ -63,7 +91,32 @@ const OrderItemsDetails: FC<OrderItemsDetailsProps> = ({ order, getStatusColor }
|
|||||||
title: 'تعداد',
|
title: 'تعداد',
|
||||||
key: 'quantity',
|
key: 'quantity',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (item) => formatFaNumber(item.quantity)
|
render: (item) =>
|
||||||
|
canEditItems && onUpdateQuantity ? (
|
||||||
|
<div className='inline-flex items-center gap-1'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
disabled={isUpdatingItem || item.quantity <= 1}
|
||||||
|
onClick={() => onUpdateQuantity(item.id, item.quantity - 1)}
|
||||||
|
className='w-7 h-7 rounded-md flex items-center justify-center hover:bg-[#F8F9FC] transition-colors disabled:opacity-40'
|
||||||
|
>
|
||||||
|
<Minus size={13} color='#000' />
|
||||||
|
</button>
|
||||||
|
<span className='w-6 text-center text-xs tabular-nums'>
|
||||||
|
{formatFaNumber(item.quantity)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
disabled={isUpdatingItem}
|
||||||
|
onClick={() => onUpdateQuantity(item.id, item.quantity + 1)}
|
||||||
|
className='w-7 h-7 rounded-md flex items-center justify-center hover:bg-[#F8F9FC] transition-colors disabled:opacity-40'
|
||||||
|
>
|
||||||
|
<Add size={13} color='#000' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
formatFaNumber(item.quantity)
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'قیمت واحد',
|
title: 'قیمت واحد',
|
||||||
@@ -81,17 +134,46 @@ const OrderItemsDetails: FC<OrderItemsDetailsProps> = ({ order, getStatusColor }
|
|||||||
title: 'مبلغ کل',
|
title: 'مبلغ کل',
|
||||||
key: 'amount',
|
key: 'amount',
|
||||||
align: 'right'
|
align: 'right'
|
||||||
}
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (canEditItems && onRemoveItem) {
|
||||||
|
columns.push({
|
||||||
|
title: 'عملیات',
|
||||||
|
key: 'id',
|
||||||
|
align: 'center',
|
||||||
|
render: (item) => (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
disabled={isUpdatingItem || order.items.length <= 1}
|
||||||
|
onClick={() => onRemoveItem(item.id)}
|
||||||
|
className='w-8 h-8 rounded-md flex items-center justify-center hover:bg-red-50 transition-colors disabled:opacity-40 mx-auto'
|
||||||
|
title='حذف آیتم'
|
||||||
|
>
|
||||||
|
<Trash size={16} color='#EF4444' />
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='w-full bg-white rounded-4xl p-8'>
|
<div className='w-full bg-white rounded-4xl p-4 sm:p-6 lg:p-8'>
|
||||||
<div className='text-lg font-light mb-6 flex items-center gap-2'>
|
<div className='mb-5 sm:mb-6 flex flex-wrap items-center justify-between gap-3'>
|
||||||
<ShoppingCart color='#000' size={20} />
|
<div className='text-lg font-light flex items-center gap-2'>
|
||||||
جزییات سفارش
|
<ShoppingCart color='#000' size={20} />
|
||||||
|
جزییات سفارش
|
||||||
|
</div>
|
||||||
|
{canEditItems && onAddItem && (
|
||||||
|
<Button
|
||||||
|
label='افزودن آیتم'
|
||||||
|
onClick={onAddItem}
|
||||||
|
className='w-full sm:w-fit px-4 h-9 text-xs'
|
||||||
|
disabled={isUpdatingItem}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mb-6 flex flex-wrap gap-4 text-[13px] font-light'>
|
<div className='mb-5 sm:mb-6 flex flex-wrap gap-x-6 gap-y-3 sm:gap-4 text-[13px] font-light'>
|
||||||
<div className='flex gap-2'>
|
<div className='flex gap-2'>
|
||||||
<div className='text-description'>زمان سفارش:</div>
|
<div className='text-description'>زمان سفارش:</div>
|
||||||
<div className='flex items-center gap-1'>
|
<div className='flex items-center gap-1'>
|
||||||
@@ -119,6 +201,14 @@ const OrderItemsDetails: FC<OrderItemsDetailsProps> = ({ order, getStatusColor }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className='flex gap-2 items-center'>
|
||||||
|
<div className='text-description'>وضعیت سفارش:</div>
|
||||||
|
<Status
|
||||||
|
variant={getStatusVariant(order.status)}
|
||||||
|
label={t(`order_status.${order.status}`)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='flex gap-2'>
|
<div className='flex gap-2'>
|
||||||
<div className='text-description'>نوع تحویل:</div>
|
<div className='text-description'>نوع تحویل:</div>
|
||||||
<div>{order.deliveryMethod?.description || '-'}</div>
|
<div>{order.deliveryMethod?.description || '-'}</div>
|
||||||
@@ -146,7 +236,6 @@ const OrderItemsDetails: FC<OrderItemsDetailsProps> = ({ order, getStatusColor }
|
|||||||
className='mt-0'
|
className='mt-0'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<OrderSummary />
|
|
||||||
|
|
||||||
{order.couponDetail && (
|
{order.couponDetail && (
|
||||||
<div className='mt-6 pt-6 border-t border-dashed border-border'>
|
<div className='mt-6 pt-6 border-t border-dashed border-border'>
|
||||||
@@ -187,15 +276,15 @@ const OrderItemsDetails: FC<OrderItemsDetailsProps> = ({ order, getStatusColor }
|
|||||||
</div>
|
</div>
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
{order.history.map((historyItem, index) => (
|
{order.history.map((historyItem, index) => (
|
||||||
<div key={index} className='flex justify-between items-center text-[13px] bg-gray-50 p-3 rounded-lg'>
|
<div key={index} className='flex flex-col sm:flex-row sm:justify-between sm:items-center gap-1 sm:gap-3 text-[13px] bg-gray-50 p-3 rounded-lg'>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2 min-w-0'>
|
||||||
<div
|
<div
|
||||||
className='size-2 rounded-full'
|
className='size-2 rounded-full shrink-0'
|
||||||
style={{ backgroundColor: getStatusColor(historyItem.status) }}
|
style={{ backgroundColor: getStatusColor(historyItem.status) }}
|
||||||
></div>
|
></div>
|
||||||
<span>{t(`order_status.${historyItem.status}`) || historyItem.status}</span>
|
<span className='truncate'>{t(`order_status.${historyItem.status}`) || historyItem.status}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='text-description'>
|
<div className='text-description shrink-0 pr-4 sm:pr-0'>
|
||||||
{formatOptionalDate(historyItem.changedAt, {
|
{formatOptionalDate(historyItem.changedAt, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
@@ -214,4 +303,3 @@ const OrderItemsDetails: FC<OrderItemsDetailsProps> = ({ order, getStatusColor }
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default OrderItemsDetails
|
export default OrderItemsDetails
|
||||||
|
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
// import type { FC } from 'react'
|
|
||||||
// import type { Order } from '../types/Types'
|
|
||||||
// import { formatPrice, formatFaNumber } from '@/helpers/func'
|
|
||||||
|
|
||||||
// interface OrderSummaryProps {
|
|
||||||
// order: Order
|
|
||||||
// variant?: 'default' | 'compact'
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const OrderSummary: FC<OrderSummaryProps> = ({ order, variant = 'default' }) => {
|
|
||||||
// const isCompact = variant === 'compact'
|
|
||||||
|
|
||||||
// return (
|
|
||||||
// <div className={`space-y-3 ${isCompact ? '' : 'text-[13px]'}`}>
|
|
||||||
// <div className={`flex justify-between items-center ${isCompact ? 'border-b border-border border-dashed pb-4' : 'mt-4 border-b border-border border-dashed pb-4'}`}>
|
|
||||||
// <div className='text-description'>جمع آیتمها</div>
|
|
||||||
// <div>{formatPrice(order.subTotal)}</div>
|
|
||||||
// </div>
|
|
||||||
|
|
||||||
// {order.totalDiscount > 0 && (
|
|
||||||
// <div className='space-y-3'>
|
|
||||||
// {order.itemsDiscount > 0 && (
|
|
||||||
// <div className='flex justify-between items-center'>
|
|
||||||
// <div className='text-description font-medium'>تخفیفها:</div>
|
|
||||||
// <div className='text-green-600 font-medium'>-{formatPrice(order.itemsDiscount)}</div>
|
|
||||||
// </div>
|
|
||||||
// )}
|
|
||||||
|
|
||||||
// {order.couponDiscount > 0 && (
|
|
||||||
// <div className='flex justify-between items-center'>
|
|
||||||
// <div className='text-description font-medium'>تخفیف کوپن:</div>
|
|
||||||
// <div className='text-green-600 font-medium'>-{formatPrice(order.couponDiscount)}</div>
|
|
||||||
// </div>
|
|
||||||
// )}
|
|
||||||
|
|
||||||
// <div className='border-t border-border border-dashed pt-3 mt-2'>
|
|
||||||
// <div className='flex justify-between items-center'>
|
|
||||||
// <div className='font-bold'>جمع تخفیفها:</div>
|
|
||||||
// <div className='font-bold text-green-600'>-{formatPrice(order.totalDiscount)}</div>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// )}
|
|
||||||
|
|
||||||
// {order.deliveryFee > 0 && (
|
|
||||||
// <div className='flex justify-between items-center pt-2'>
|
|
||||||
// <div className='text-description'>هزینه ارسال:</div>
|
|
||||||
// <div>{formatPrice(order.deliveryFee)}</div>
|
|
||||||
// </div>
|
|
||||||
// )}
|
|
||||||
|
|
||||||
// {order.tax > 0 && (
|
|
||||||
// <div className='flex justify-between items-center pt-2'>
|
|
||||||
// <div className='text-description'>مالیات:</div>
|
|
||||||
// <div>{formatPrice(order.tax)}</div>
|
|
||||||
// </div>
|
|
||||||
// )}
|
|
||||||
|
|
||||||
// {isCompact && (
|
|
||||||
// <>
|
|
||||||
// {order.deliveryMethod?.minOrderPrice && (
|
|
||||||
// <div className='flex justify-between items-center border-b border-border border-dashed pb-4'>
|
|
||||||
// <div className='text-description'>حداقل سفارش:</div>
|
|
||||||
// <div>{formatPrice(order.deliveryMethod.minOrderPrice)}</div>
|
|
||||||
// </div>
|
|
||||||
// )}
|
|
||||||
|
|
||||||
// <div className='flex justify-between items-center border-b border-border border-dashed pb-4'>
|
|
||||||
// <div className='text-description'>تعداد آیتمها:</div>
|
|
||||||
// <div>{formatFaNumber(order.totalItems)}</div>
|
|
||||||
// </div>
|
|
||||||
// </>
|
|
||||||
// )}
|
|
||||||
|
|
||||||
// <div className={`flex font-bold justify-between items-center ${isCompact ? '' : 'h-14 border-t-2 border-gray-300 pt-3 mt-3'}`}>
|
|
||||||
// <div>جمع کل:</div>
|
|
||||||
// <div className={`${isCompact ? 'text-base' : 'text-lg'}`}>{formatPrice(order.total)}</div>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
|
|
||||||
// export default OrderSummary
|
|
||||||
|
|
||||||
|
|
||||||
const OrderSummary = () => {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default OrderSummary
|
|
||||||
@@ -1,105 +1,189 @@
|
|||||||
import { Eye } from 'iconsax-react'
|
import { Eye, MessageText } from "iconsax-react";
|
||||||
import type { ColumnType } from '@/components/types/TableTypes'
|
import type { ColumnType } from "@/components/types/TableTypes";
|
||||||
import type { Order } from '../types/Types'
|
import type { Order } from "../types/Types";
|
||||||
import { formatPrice, formatOptionalDate } from '@/helpers/func'
|
import { formatPrice, formatOptionalDate } from "@/helpers/func";
|
||||||
import Status from '@/components/Status'
|
import Status from "@/components/Status";
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from "react-router-dom";
|
||||||
import { Pages } from '@/config/Pages'
|
import { Pages } from "@/config/Pages";
|
||||||
import { formatFaNumber } from '@/helpers/func'
|
import { formatFaNumber } from "@/helpers/func";
|
||||||
import type { TFunction } from 'i18next'
|
import type { TFunction } from "i18next";
|
||||||
|
|
||||||
interface GetOrderTableColumnsParams {
|
interface GetOrderTableColumnsParams {
|
||||||
onDelete?: (id: string) => void
|
onDelete?: (id: string) => void;
|
||||||
isDeleting?: boolean
|
isDeleting?: boolean;
|
||||||
t: TFunction
|
onSendPleaseComeSms?: (order: Order) => void;
|
||||||
|
t: TFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusVariant = (status: string): 'success' | 'error' | 'warning' | 'info' | 'pending' => {
|
const getStatusVariant = (
|
||||||
const variantMap: Record<string, 'success' | 'error' | 'warning' | 'info' | 'pending'> = {
|
status: string,
|
||||||
'pendingPayment': 'pending',
|
): "success" | "error" | "warning" | "info" | "pending" => {
|
||||||
'paid': 'info',
|
const variantMap: Record<
|
||||||
'preparing': 'warning',
|
string,
|
||||||
'ready': 'info',
|
"success" | "error" | "warning" | "info" | "pending"
|
||||||
'shipped': 'info',
|
> = {
|
||||||
'completed': 'success',
|
new: "pending",
|
||||||
'canceled': 'error',
|
preparing: "warning",
|
||||||
}
|
shipped: "info",
|
||||||
return variantMap[status] || 'pending'
|
completed: "success",
|
||||||
}
|
canceled: "error",
|
||||||
|
};
|
||||||
|
return variantMap[status] || "pending";
|
||||||
|
};
|
||||||
|
|
||||||
export const getOrderTableColumns = ({ t }: GetOrderTableColumnsParams): ColumnType<Order>[] => {
|
const getShiftAdminName = (item: Order): string => {
|
||||||
return [
|
const admin = item.cashShift?.admin;
|
||||||
{
|
if (!admin) return "-";
|
||||||
key: 'orderNumber',
|
const name = [admin.firstName, admin.lastName].filter(Boolean).join(" ");
|
||||||
title: 'شماره سفارش',
|
return name || admin.phone || "-";
|
||||||
render: (item: Order) => {
|
};
|
||||||
return formatFaNumber(item.orderNumber)
|
|
||||||
}
|
export const getOrderTableColumns = ({
|
||||||
},
|
t,
|
||||||
{
|
onSendPleaseComeSms,
|
||||||
key: 'customerName',
|
}: GetOrderTableColumnsParams): ColumnType<Order>[] => {
|
||||||
title: 'نام مشتری',
|
return [
|
||||||
render: (item: Order) => {
|
{
|
||||||
const name = [item.user.firstName, item.user.lastName].filter(Boolean).join(' ')
|
key: "orderNumber",
|
||||||
return name || '-'
|
title: "شماره",
|
||||||
}
|
render: (item: Order) => {
|
||||||
},
|
return formatFaNumber(item.orderNumber);
|
||||||
{
|
},
|
||||||
key: 'total',
|
},
|
||||||
title: 'مبلغ سفارش',
|
{
|
||||||
render: (item: Order) => {
|
key: "customerName",
|
||||||
return formatPrice(item.total)
|
title: "نام مشتری",
|
||||||
}
|
render: (item: Order) => {
|
||||||
},
|
const name = [item.user?.firstName, item.user?.lastName]
|
||||||
{
|
.filter(Boolean)
|
||||||
key: 'createdAt',
|
.join(" ");
|
||||||
title: 'زمان سفارش',
|
return name || "-";
|
||||||
render: (item: Order) => {
|
},
|
||||||
return formatOptionalDate(item.createdAt, {
|
},
|
||||||
year: 'numeric',
|
{
|
||||||
month: '2-digit',
|
key: "shiftAdmin",
|
||||||
day: '2-digit',
|
title: "شیفت",
|
||||||
hour: '2-digit',
|
render: (item: Order) => getShiftAdminName(item),
|
||||||
minute: '2-digit',
|
},
|
||||||
})
|
{
|
||||||
}
|
key: "createdAt",
|
||||||
},
|
title: "تاریخ",
|
||||||
{
|
render: (item: Order) => {
|
||||||
key: 'deliveryMethod',
|
if (!item.createdAt) return "-";
|
||||||
title: 'نوع تحویل',
|
return (
|
||||||
render: (item: Order) => {
|
<div className="flex flex-col whitespace-nowrap">
|
||||||
return item.deliveryMethod?.description || '-'
|
<span className="text-[11px] text-description">
|
||||||
}
|
{formatOptionalDate(item.createdAt, {
|
||||||
},
|
year: "numeric",
|
||||||
{
|
month: "2-digit",
|
||||||
key: 'paymentMethod',
|
day: "2-digit",
|
||||||
title: 'نوع پرداخت',
|
})}
|
||||||
render: (item: Order) => {
|
</span>
|
||||||
return item.paymentMethod?.description || '-'
|
<span className="text-[15px] font-medium">
|
||||||
}
|
{formatOptionalDate(item.createdAt, {
|
||||||
},
|
hour: "2-digit",
|
||||||
{
|
minute: "2-digit",
|
||||||
key: 'status',
|
})}
|
||||||
title: 'وضعیت',
|
</span>
|
||||||
render: (item: Order) => {
|
</div>
|
||||||
return (
|
);
|
||||||
<Status
|
},
|
||||||
variant={getStatusVariant(item.status)}
|
},
|
||||||
label={t(`order_status.${item.status}`)}
|
{
|
||||||
/>
|
key: "methods",
|
||||||
)
|
title: "تحویل / پرداخت",
|
||||||
}
|
render: (item: Order) => {
|
||||||
},
|
const rows = [
|
||||||
{
|
{
|
||||||
key: 'details',
|
label: "نوع تحویل",
|
||||||
title: '',
|
value: item.deliveryMethod?.description || "-",
|
||||||
render: (item: Order) => {
|
},
|
||||||
return (
|
{
|
||||||
<Link to={Pages.orders.detail + item.id}>
|
label: "نوع پرداخت",
|
||||||
<Eye size={20} color="#8C90A3" className="cursor-pointer" />
|
value: item.paymentMethod?.description || "-",
|
||||||
</Link>
|
},
|
||||||
)
|
];
|
||||||
}
|
return (
|
||||||
},
|
<div className="flex flex-col gap-1 text-[12px] whitespace-nowrap">
|
||||||
]
|
{rows.map((row) => (
|
||||||
}
|
<div
|
||||||
|
key={row.label}
|
||||||
|
className="flex items-center justify-between gap-3"
|
||||||
|
>
|
||||||
|
<span className="text-description">{row.label}</span>
|
||||||
|
<span>{row.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "amounts",
|
||||||
|
title: "مبالغ",
|
||||||
|
render: (item: Order) => {
|
||||||
|
const rows = [
|
||||||
|
{ label: "مبلغ کل", value: formatPrice(item.total) },
|
||||||
|
{
|
||||||
|
label: "پرداخت شده",
|
||||||
|
value: formatPrice(Number(item.paidAmount ?? 0)),
|
||||||
|
},
|
||||||
|
{ label: "مانده", value: formatPrice(Number(item.balance ?? 0)) },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 text-[12px] whitespace-nowrap">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.label}
|
||||||
|
className="flex items-center justify-between gap-3"
|
||||||
|
>
|
||||||
|
<span className="text-description">{row.label}</span>
|
||||||
|
<span>{row.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
title: "وضعیت",
|
||||||
|
render: (item: Order) => {
|
||||||
|
return (
|
||||||
|
<Status
|
||||||
|
variant={getStatusVariant(item.status)}
|
||||||
|
label={t(`order_status.${item.status}`)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
title: "",
|
||||||
|
render: (item: Order) => {
|
||||||
|
const canSendSms = Boolean(item.user?.phone);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 whitespace-nowrap">
|
||||||
|
{canSendSms && onSendPleaseComeSms && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSendPleaseComeSms(item)}
|
||||||
|
className="cursor-pointer text-[12px] text-teal-700 hover:text-teal-800 font-medium"
|
||||||
|
title="ارسال پیام به مشتری"
|
||||||
|
>
|
||||||
|
<MessageText
|
||||||
|
size={20}
|
||||||
|
color="#8C90A3"
|
||||||
|
className="cursor-pointer"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Link to={Pages.orders.detail + item.id} title="جزئیات">
|
||||||
|
<Eye size={20} color="#8C90A3" className="cursor-pointer" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,75 +1,80 @@
|
|||||||
import { formatFaNumber, formatPrice } from "@/helpers/func";
|
import { formatPrice } from "@/helpers/func";
|
||||||
import { PaymentMethodEnum } from "@/pages/paymentMethods/enum/Enum";
|
import { PaymentMethodEnum } from "@/pages/paymentMethods/enum/Enum";
|
||||||
import { DeliveryMethodEnum } from "@/pages/shipmentMethod/enum/Enum";
|
import { Edit2, Refresh } from "iconsax-react";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import type { Order } from "../types/Types";
|
import type { Order } from "../types/Types";
|
||||||
|
import { OrderStatus } from "../enum/Enum";
|
||||||
|
|
||||||
interface PaymentInfoProps {
|
interface PaymentInfoProps {
|
||||||
order: Order;
|
order: Order;
|
||||||
getPaymentStatusColor: (status: string) => string;
|
canEditFees?: boolean;
|
||||||
getPaymentStatusTextColor: (status: string) => string;
|
onEditFees?: () => void;
|
||||||
getPaymentStatusText: (status: string) => string;
|
onRefund?: (defaultAmount: number, maxAmount: number) => void;
|
||||||
|
isRefunding?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PaymentInfo: FC<PaymentInfoProps> = ({ order, getPaymentStatusColor, getPaymentStatusTextColor, getPaymentStatusText }) => {
|
const getPaymentMethodText = (method: string): string => {
|
||||||
const paymentStatus = order.payments?.[0]?.status;
|
const methodMap: Record<string, string> = {
|
||||||
|
[PaymentMethodEnum.Cash]: "پرداخت نقدی",
|
||||||
const getDeliveryMethodText = (method: string): string => {
|
[PaymentMethodEnum.Online]: "پرداخت آنلاین",
|
||||||
const methodMap: Record<string, string> = {
|
[PaymentMethodEnum.CreditCard]: "پرداخت کارت به کارت",
|
||||||
[DeliveryMethodEnum.DineIn]: "سرو در رستوران",
|
[PaymentMethodEnum.Wallet]: "کیف پول",
|
||||||
[DeliveryMethodEnum.CustomerPickup]: "دریافت از محل",
|
|
||||||
[DeliveryMethodEnum.DeliveryCar]: "تحویل به خودرو",
|
|
||||||
[DeliveryMethodEnum.DeliveryCourier]: "بیرون بر",
|
|
||||||
};
|
|
||||||
return methodMap[method] || method;
|
|
||||||
};
|
};
|
||||||
|
return methodMap[method] || method;
|
||||||
|
};
|
||||||
|
|
||||||
const getPaymentMethodText = (method: string): string => {
|
const PaymentInfo: FC<PaymentInfoProps> = ({
|
||||||
const methodMap: Record<string, string> = {
|
order,
|
||||||
[PaymentMethodEnum.Cash]: "پرداخت نقدی",
|
canEditFees = false,
|
||||||
[PaymentMethodEnum.Online]: "پرداخت آنلاین",
|
onEditFees,
|
||||||
[PaymentMethodEnum.CreditCard]: "پرداخت کارت به کارت",
|
onRefund,
|
||||||
[PaymentMethodEnum.Wallet]: "کیف پول",
|
isRefunding = false,
|
||||||
};
|
}) => {
|
||||||
return methodMap[method] || method;
|
const paidAmount = Number(order.paidAmount ?? 0);
|
||||||
};
|
const balance = Number(order.balance ?? 0);
|
||||||
|
const refundedAmount = Number(order.refundedAmount ?? 0);
|
||||||
|
const isCanceled = order.status === OrderStatus.CANCELED;
|
||||||
|
const showRefundOnPaid = isCanceled && paidAmount > 0;
|
||||||
|
const showRefundOnBalance = balance < 0;
|
||||||
|
|
||||||
|
const editFeesButton =
|
||||||
|
canEditFees && onEditFees ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEditFees}
|
||||||
|
title="ویرایش هزینهها"
|
||||||
|
className="p-0.5 rounded hover:bg-slate-100 transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<Edit2 size={14} color="#64748b" />
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const refundButton = (defaultAmount: number, maxAmount: number) =>
|
||||||
|
onRefund ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRefund(defaultAmount, maxAmount)}
|
||||||
|
disabled={isRefunding}
|
||||||
|
title="بازگشت وجه"
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded px-1.5 py-0.5 transition-colors shrink-0 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Refresh size={12} color="currentColor" />
|
||||||
|
بازگشت وجه
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-[330px] h-fit bg-white rounded-4xl p-8">
|
<div className="w-full h-fit bg-white rounded-4xl p-4 sm:p-6 lg:p-8">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div className="font-light">اطلاعات پرداخت</div>
|
<div className="font-light">خلاصه </div>
|
||||||
|
|
||||||
<div className="flex gap-1.5 items-center">
|
|
||||||
<div className={`size-2 rounded-full ${getPaymentStatusColor(paymentStatus)}`}></div>
|
|
||||||
<div className={`text-[13px] ${getPaymentStatusTextColor(paymentStatus)}`}>{getPaymentStatusText(paymentStatus)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-10">
|
<div className="mt-5">
|
||||||
<div className="flex text-[13px] font-light justify-between items-center border-b border-border border-dashed pb-4">
|
<div className="flex mt-4 text-[13px] font-light justify-between items-center border-b border-border border-dashed pb-4 gap-3">
|
||||||
<div className="text-description">روش ارسال</div>
|
<div className="text-description shrink-0">روش پرداخت</div>
|
||||||
<div>{order.deliveryMethod ? getDeliveryMethodText(order.deliveryMethod.method) : "-"}</div>
|
<div className="min-w-0 break-words">{order.paymentMethod ? getPaymentMethodText(order.paymentMethod.method) : "-"}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{order.deliveryMethod?.description && (
|
|
||||||
<div className="flex mt-4 text-[13px] font-light justify-between items-center border-b border-border border-dashed pb-4">
|
|
||||||
<div className="text-description">توضیحات روش ارسال</div>
|
|
||||||
<div>{order.deliveryMethod.description}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex mt-4 text-[13px] font-light justify-between items-center border-b border-border border-dashed pb-4">
|
|
||||||
<div className="text-description">روش پرداخت</div>
|
|
||||||
<div>{order.paymentMethod ? getPaymentMethodText(order.paymentMethod.method) : "-"}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{order.paymentMethod?.description && (
|
|
||||||
<div className="flex mt-4 text-[13px] font-light justify-between items-center border-b border-border border-dashed pb-4">
|
|
||||||
<div className="text-description">توضیحات روش پرداخت</div>
|
|
||||||
<div>{order.paymentMethod.description}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{order.paymentMethod?.gateway && (
|
{order.paymentMethod?.gateway && (
|
||||||
<div className="flex mt-4 text-[13px] font-light justify-between items-center border-b border-border border-dashed pb-4">
|
<div className="flex mt-4 text-[13px] font-light justify-between items-center border-b border-border border-dashed pb-4">
|
||||||
<div className="text-description">درگاه پرداخت</div>
|
<div className="text-description">درگاه پرداخت</div>
|
||||||
@@ -77,25 +82,11 @@ const PaymentInfo: FC<PaymentInfoProps> = ({ order, getPaymentStatusColor, getPa
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{order.paymentId && (
|
|
||||||
<div className="flex mt-4 text-[13px] font-light justify-between items-center border-b border-border border-dashed pb-4">
|
|
||||||
<div className="text-description">شناسه پرداخت</div>
|
|
||||||
<div className="font-mono text-xs">{order.paymentId}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex mt-4 text-[13px] font-light justify-between items-center border-b border-border border-dashed pb-4">
|
<div className="flex mt-4 text-[13px] font-light justify-between items-center border-b border-border border-dashed pb-4">
|
||||||
<div className="text-description">جمع آیتمها</div>
|
<div className="text-description">جمع آیتمها</div>
|
||||||
<div>{formatPrice(order.subTotal)}</div>
|
<div>{formatPrice(order.subTotal)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{order.itemsDiscount > 0 && (
|
|
||||||
<div className="flex mt-4 text-[13px] font-light justify-between items-center border-b border-border border-dashed pb-4">
|
|
||||||
<div className="text-description">تخفیف آیتمها</div>
|
|
||||||
<div className="text-green-600">-{formatPrice(order.itemsDiscount)}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{order.couponDiscount > 0 && (
|
{order.couponDiscount > 0 && (
|
||||||
<div className="flex mt-4 text-[13px] font-light justify-between items-center border-b border-border border-dashed pb-4">
|
<div className="flex mt-4 text-[13px] font-light justify-between items-center border-b border-border border-dashed pb-4">
|
||||||
<div className="text-description">تخفیف کوپن</div>
|
<div className="text-description">تخفیف کوپن</div>
|
||||||
@@ -103,16 +94,22 @@ const PaymentInfo: FC<PaymentInfoProps> = ({ order, getPaymentStatusColor, getPa
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{order.totalDiscount > 0 && (
|
<div className="flex mt-4 text-[13px] font-light justify-between items-center border-b border-border border-dashed pb-4">
|
||||||
<div className="flex mt-4 text-[13px] font-light justify-between items-center border-b border-border border-dashed pb-4">
|
<div className="text-description flex items-center gap-1">
|
||||||
<div className="text-description font-medium">جمع تخفیفها</div>
|
هزینه ارسال
|
||||||
<div className="text-green-600 font-medium">-{formatPrice(order.totalDiscount)}</div>
|
{editFeesButton}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div>{formatPrice(order.deliveryFee)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex mt-4 text-[13px] font-light justify-between items-center border-b border-border border-dashed pb-4">
|
<div className="flex mt-4 text-[13px] font-light justify-between items-center border-b border-border border-dashed pb-4">
|
||||||
<div className="text-description">هزینه ارسال</div>
|
<div className="text-description">هزینه بستهبندی</div>
|
||||||
<div>{formatPrice(order.deliveryFee)}</div>
|
<div>{formatPrice(order.packingFee ?? 0)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex mt-4 text-[13px] font-light justify-between items-center border-b border-border border-dashed pb-4">
|
||||||
|
<div className="text-description">زمان آمادهسازی</div>
|
||||||
|
<div>{order.prepareTime ? `${order.prepareTime} دقیقه` : "-"}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{order.tax > 0 && (
|
{order.tax > 0 && (
|
||||||
@@ -122,15 +119,35 @@ const PaymentInfo: FC<PaymentInfoProps> = ({ order, getPaymentStatusColor, getPa
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex mt-4 text-[13px] font-light justify-between items-center border-b border-border border-dashed pb-4">
|
<div className="flex mt-4 text-[13px] font-bold justify-between items-center border-b border-border border-dashed pb-4">
|
||||||
<div className="text-description">تعداد آیتمها</div>
|
|
||||||
<div>{formatFaNumber(order.totalItems)}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex mt-4 text-[13px] font-bold justify-between items-center">
|
|
||||||
<div>مبلغ کل</div>
|
<div>مبلغ کل</div>
|
||||||
<div>{formatPrice(order.total)}</div>
|
<div>{formatPrice(order.total)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex mt-4 text-[13px] font-light justify-between items-center border-b border-border border-dashed pb-4 gap-2">
|
||||||
|
<div className="text-description shrink-0">پرداخت شده</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap justify-end">
|
||||||
|
<div className="text-green-600">{formatPrice(paidAmount)}</div>
|
||||||
|
{showRefundOnPaid && refundButton(paidAmount, paidAmount)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{refundedAmount > 0 && (
|
||||||
|
<div className="flex mt-4 text-[13px] font-light justify-between items-center border-b border-border border-dashed pb-4">
|
||||||
|
<div className="text-description">بازگشت شده</div>
|
||||||
|
<div className="text-blue-600">{formatPrice(refundedAmount)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex mt-4 text-[13px] font-bold justify-between items-center gap-2">
|
||||||
|
<div className="shrink-0">مانده</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap justify-end">
|
||||||
|
<div className={balance > 0 ? "text-orange-600" : "text-green-600"}>
|
||||||
|
{formatPrice(balance)}
|
||||||
|
</div>
|
||||||
|
{showRefundOnBalance && refundButton(Math.abs(balance), Math.abs(balance))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import Button from '@/components/Button'
|
|
||||||
import { PaymentMethodEnum } from '@/pages/paymentMethods/enum/Enum'
|
|
||||||
import type { FC } from 'react'
|
|
||||||
import type { Order } from '../types/Types'
|
|
||||||
|
|
||||||
interface PaymentVerificationBoxProps {
|
|
||||||
order: Order
|
|
||||||
isVerifyingPayment: boolean
|
|
||||||
onVerifyPayment: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const PaymentVerificationBox: FC<PaymentVerificationBoxProps> = ({
|
|
||||||
order,
|
|
||||||
isVerifyingPayment,
|
|
||||||
onVerifyPayment,
|
|
||||||
}) => {
|
|
||||||
const payment = order.payments?.[0]
|
|
||||||
const isCreditCard = order.paymentMethod?.method === PaymentMethodEnum.CreditCard
|
|
||||||
const hasDescription = Boolean(payment?.description)
|
|
||||||
const hasAttachments = Boolean(payment?.attachments && payment.attachments.length > 0)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='w-[330px] h-fit bg-white rounded-4xl p-8 mt-6'>
|
|
||||||
<div className='font-light'>اطلاعات تایید پرداخت</div>
|
|
||||||
|
|
||||||
<div className='mt-6 space-y-4 text-[13px] font-light'>
|
|
||||||
{isCreditCard && hasDescription && (
|
|
||||||
<div>
|
|
||||||
<div className='text-description mb-2'>توضیحات پرداخت</div>
|
|
||||||
<div className='text-gray-700 bg-gray-50 p-3 rounded-lg'>{payment?.description}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isCreditCard && hasAttachments && (
|
|
||||||
<div>
|
|
||||||
<div className='text-description mb-2'>پیوست پرداخت</div>
|
|
||||||
<div className='flex gap-3 flex-wrap'>
|
|
||||||
{payment?.attachments?.map((attachment, index) => (
|
|
||||||
<a
|
|
||||||
key={index}
|
|
||||||
href={attachment}
|
|
||||||
target='_blank'
|
|
||||||
rel='noopener noreferrer'
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={attachment}
|
|
||||||
alt={`پیوست پرداخت ${index + 1}`}
|
|
||||||
className='w-24 h-24 object-cover rounded-xl border border-border'
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
label='تایید پرداخت'
|
|
||||||
onClick={onVerifyPayment}
|
|
||||||
isloading={isVerifyingPayment}
|
|
||||||
className='bg-green-600 hover:bg-green-700 w-full'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PaymentVerificationBox
|
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import Table from '@/components/Table'
|
||||||
|
import Status from '@/components/Status'
|
||||||
|
import Button from '@/components/Button'
|
||||||
|
import { Moneys } from 'iconsax-react'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import type { ColumnType, RowDataType } from '@/components/types/TableTypes'
|
||||||
|
import type { Order, OrderPayment } from '../types/Types'
|
||||||
|
import { formatOptionalDate, formatPrice } from '@/helpers/func'
|
||||||
|
import { PaymentStatusEnum } from '../enum/Enum'
|
||||||
|
import { PaymentMethodEnum } from '@/pages/paymentMethods/enum/Enum'
|
||||||
|
|
||||||
|
interface PaymentRow extends RowDataType {
|
||||||
|
amount: number
|
||||||
|
method: string
|
||||||
|
status: string
|
||||||
|
referenceId: string | null
|
||||||
|
transactionId: string | null
|
||||||
|
paidAt: string | null
|
||||||
|
createdAt: string
|
||||||
|
description: string | null
|
||||||
|
attachments: string[] | null
|
||||||
|
canVerify: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaymentsListProps {
|
||||||
|
order: Order
|
||||||
|
isVerifyingPayment: boolean
|
||||||
|
verifyingPaymentId?: string | null
|
||||||
|
onVerifyPayment: (paymentId: string) => void
|
||||||
|
canAddPayment?: boolean
|
||||||
|
onAddPayment?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPaymentMethodText = (method: string): string => {
|
||||||
|
const methodMap: Record<string, string> = {
|
||||||
|
[PaymentMethodEnum.Cash]: 'نقدی',
|
||||||
|
[PaymentMethodEnum.Online]: 'آنلاین',
|
||||||
|
[PaymentMethodEnum.CreditCard]: 'کارت به کارت',
|
||||||
|
[PaymentMethodEnum.Wallet]: 'کیف پول',
|
||||||
|
}
|
||||||
|
return methodMap[method] || method
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPaymentStatusVariant = (status: string): 'success' | 'error' | 'warning' | 'info' | 'pending' => {
|
||||||
|
const variantMap: Record<string, 'success' | 'error' | 'warning' | 'info' | 'pending'> = {
|
||||||
|
[PaymentStatusEnum.Paid]: 'success',
|
||||||
|
[PaymentStatusEnum.Pending]: 'warning',
|
||||||
|
[PaymentStatusEnum.Failed]: 'error',
|
||||||
|
[PaymentStatusEnum.Refunded]: 'info',
|
||||||
|
}
|
||||||
|
return variantMap[status] || 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPaymentStatusLabel = (status: string): string => {
|
||||||
|
const labelMap: Record<string, string> = {
|
||||||
|
[PaymentStatusEnum.Paid]: 'پرداخت شده',
|
||||||
|
[PaymentStatusEnum.Pending]: 'در انتظار پرداخت',
|
||||||
|
[PaymentStatusEnum.Failed]: 'پرداخت ناموفق',
|
||||||
|
[PaymentStatusEnum.Refunded]: 'بازگشت شده',
|
||||||
|
}
|
||||||
|
return labelMap[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
const canVerifyPayment = (payment: OrderPayment, orderCanceled: boolean): boolean => {
|
||||||
|
if (orderCanceled) return false
|
||||||
|
if (payment.status !== PaymentStatusEnum.Pending) return false
|
||||||
|
return (
|
||||||
|
payment.method === PaymentMethodEnum.Cash ||
|
||||||
|
payment.method === PaymentMethodEnum.CreditCard
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PaymentsList: FC<PaymentsListProps> = ({
|
||||||
|
order,
|
||||||
|
isVerifyingPayment,
|
||||||
|
verifyingPaymentId,
|
||||||
|
onVerifyPayment,
|
||||||
|
canAddPayment = false,
|
||||||
|
onAddPayment,
|
||||||
|
}) => {
|
||||||
|
const isCanceled = order.status === 'canceled'
|
||||||
|
const payments = order.payments ?? []
|
||||||
|
|
||||||
|
const rows: PaymentRow[] = payments.map((payment) => ({
|
||||||
|
id: payment.id,
|
||||||
|
amount: payment.amount,
|
||||||
|
method: payment.method,
|
||||||
|
status: payment.status,
|
||||||
|
referenceId: payment.referenceId,
|
||||||
|
transactionId: payment.transactionId,
|
||||||
|
paidAt: payment.paidAt,
|
||||||
|
createdAt: payment.createdAt,
|
||||||
|
description: payment.description,
|
||||||
|
attachments: payment.attachments,
|
||||||
|
canVerify: canVerifyPayment(payment, isCanceled),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const columns: ColumnType<PaymentRow>[] = [
|
||||||
|
{
|
||||||
|
title: 'مبلغ',
|
||||||
|
key: 'amount',
|
||||||
|
align: 'right',
|
||||||
|
render: (item) => formatPrice(item.amount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'روش',
|
||||||
|
key: 'method',
|
||||||
|
align: 'right',
|
||||||
|
render: (item) => getPaymentMethodText(item.method),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'وضعیت',
|
||||||
|
key: 'status',
|
||||||
|
align: 'center',
|
||||||
|
render: (item) => (
|
||||||
|
<Status
|
||||||
|
variant={getPaymentStatusVariant(item.status)}
|
||||||
|
label={getPaymentStatusLabel(item.status)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'شناسه مرجع',
|
||||||
|
key: 'referenceId',
|
||||||
|
align: 'right',
|
||||||
|
render: (item) =>
|
||||||
|
item.referenceId ? (
|
||||||
|
<span className='font-mono text-xs'>{item.referenceId}</span>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'شناسه تراکنش',
|
||||||
|
key: 'transactionId',
|
||||||
|
align: 'right',
|
||||||
|
render: (item) =>
|
||||||
|
item.transactionId ? (
|
||||||
|
<span className='font-mono text-xs'>{item.transactionId}</span>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'زمان پرداخت',
|
||||||
|
key: 'paidAt',
|
||||||
|
align: 'right',
|
||||||
|
render: (item) =>
|
||||||
|
formatOptionalDate(item.paidAt || item.createdAt, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'عملیات',
|
||||||
|
key: 'canVerify',
|
||||||
|
align: 'center',
|
||||||
|
render: (item) => {
|
||||||
|
if (!item.canVerify) return '-'
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
label='تایید پرداخت'
|
||||||
|
onClick={() => onVerifyPayment(String(item.id))}
|
||||||
|
isloading={isVerifyingPayment && verifyingPaymentId === item.id}
|
||||||
|
className='bg-green-600 hover:bg-green-700 !py-1.5 !px-3 text-xs w-fit'
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const paymentsWithReceipt = payments.filter(
|
||||||
|
(p) =>
|
||||||
|
p.method === PaymentMethodEnum.CreditCard &&
|
||||||
|
(Boolean(p.description) || Boolean(p.attachments?.length))
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-full bg-white rounded-4xl p-4 sm:p-6 lg:p-8'>
|
||||||
|
<div className='mb-5 sm:mb-6 flex flex-wrap items-center justify-between gap-3'>
|
||||||
|
<div className='text-lg font-light flex items-center gap-2'>
|
||||||
|
<Moneys color='#000' size={20} />
|
||||||
|
لیست پرداختها
|
||||||
|
</div>
|
||||||
|
{canAddPayment && onAddPayment && (
|
||||||
|
<Button
|
||||||
|
label='افزودن پرداخت'
|
||||||
|
onClick={onAddPayment}
|
||||||
|
className='w-full sm:w-fit px-4 h-9 text-xs'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className='text-[13px] text-description font-light py-8 text-center'>
|
||||||
|
پرداختی برای این سفارش ثبت نشده است
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table columns={columns} data={rows} showHeader={true} className='mt-0' />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{paymentsWithReceipt.length > 0 && (
|
||||||
|
<div className='mt-6 pt-6 border-t border-dashed border-border space-y-6'>
|
||||||
|
{paymentsWithReceipt.map((payment) => (
|
||||||
|
<div key={payment.id} className='space-y-3 text-[13px] font-light'>
|
||||||
|
<div className='text-description'>
|
||||||
|
جزئیات پرداخت کارت به کارت
|
||||||
|
{payment.referenceId ? ` (${payment.referenceId})` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{payment.description && (
|
||||||
|
<div>
|
||||||
|
<div className='text-description mb-2'>توضیحات</div>
|
||||||
|
<div className='text-gray-700 bg-gray-50 p-3 rounded-lg'>
|
||||||
|
{payment.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{payment.attachments && payment.attachments.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className='text-description mb-2'>پیوستها</div>
|
||||||
|
<div className='flex gap-3 flex-wrap'>
|
||||||
|
{payment.attachments.map((attachment, index) => (
|
||||||
|
<a
|
||||||
|
key={index}
|
||||||
|
href={attachment}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={attachment}
|
||||||
|
alt={`پیوست پرداخت ${index + 1}`}
|
||||||
|
className='w-24 h-24 object-cover rounded-xl border border-border'
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PaymentsList
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { type FC, useEffect, useState } from 'react'
|
||||||
|
import DefaulModal from '@/components/DefaulModal'
|
||||||
|
import Button from '@/components/Button'
|
||||||
|
import Input from '@/components/Input'
|
||||||
|
import Textarea from '@/components/Textarea'
|
||||||
|
|
||||||
|
interface RefundPaymentModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
defaultAmount: number
|
||||||
|
maxAmount: number
|
||||||
|
isLoading?: boolean
|
||||||
|
onSubmit: (amount: number, description: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const RefundPaymentModal: FC<RefundPaymentModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
defaultAmount,
|
||||||
|
maxAmount,
|
||||||
|
isLoading,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
const [amount, setAmount] = useState(String(defaultAmount))
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
setAmount(String(Math.abs(defaultAmount)))
|
||||||
|
setDescription('')
|
||||||
|
setError('')
|
||||||
|
}, [isOpen, defaultAmount])
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const parsed = Number(amount)
|
||||||
|
if (!parsed || parsed <= 0) {
|
||||||
|
setError('مبلغ بازگشت وجه باید بیشتر از صفر باشد')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (parsed > maxAmount) {
|
||||||
|
setError(`حداکثر مبلغ قابل بازگشت ${maxAmount.toLocaleString('fa-IR')} تومان است`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!description.trim()) {
|
||||||
|
setError('توضیحات الزامی است')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onSubmit(parsed, description.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaulModal
|
||||||
|
open={isOpen}
|
||||||
|
close={onClose}
|
||||||
|
isHeader
|
||||||
|
title_header='بازگشت وجه'
|
||||||
|
width={420}
|
||||||
|
>
|
||||||
|
<div className='mt-6 space-y-4'>
|
||||||
|
<p className='text-sm text-center text-description'>
|
||||||
|
آیا از بازگشت وجه این سفارش مطمئن هستید؟
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label='مبلغ بازگشت (تومان)'
|
||||||
|
type='number'
|
||||||
|
inputMode='numeric'
|
||||||
|
min={1}
|
||||||
|
max={maxAmount}
|
||||||
|
seprator
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAmount(e.target.value)
|
||||||
|
setError('')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
placeholder='توضیحات'
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDescription(e.target.value)
|
||||||
|
setError('')
|
||||||
|
}}
|
||||||
|
className='bg-transparent border border-gray-500 rounded-xl w-full p-2 min-h-[80px]'
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <p className='text-xs text-red-500 text-center'>{error}</p>}
|
||||||
|
|
||||||
|
<div className='flex gap-4 justify-center pt-4'>
|
||||||
|
<Button
|
||||||
|
label='تایید'
|
||||||
|
onClick={handleSubmit}
|
||||||
|
isloading={isLoading}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label='انصراف'
|
||||||
|
className='bg-transparent text-black border border-primary'
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DefaulModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RefundPaymentModal
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { type FC } from 'react'
|
||||||
|
import DefaulModal from '@/components/DefaulModal'
|
||||||
|
import Button from '@/components/Button'
|
||||||
|
|
||||||
|
interface SendPleaseComeSmsModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
isLoading?: boolean
|
||||||
|
customerName: string
|
||||||
|
restaurantName: string
|
||||||
|
onConfirm: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SendPleaseComeSmsModal: FC<SendPleaseComeSmsModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
isLoading,
|
||||||
|
customerName,
|
||||||
|
restaurantName,
|
||||||
|
onConfirm,
|
||||||
|
}) => {
|
||||||
|
const previewMessage = `${customerName || '#NAME#'} عزیز لطفا به پیشخوان ${restaurantName || '#REST#'} مراجعه کنید.`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaulModal
|
||||||
|
open={isOpen}
|
||||||
|
close={onClose}
|
||||||
|
isHeader
|
||||||
|
title_header='ارسال پیام'
|
||||||
|
width={420}
|
||||||
|
>
|
||||||
|
<div className='mt-6'>
|
||||||
|
<p className='text-sm text-description text-center mb-4'>
|
||||||
|
متن پیامک:
|
||||||
|
</p>
|
||||||
|
<div className='rounded-xl border border-gray-200 bg-gray-50 px-4 py-5 text-center text-[15px] leading-8'>
|
||||||
|
{previewMessage}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex gap-4 justify-center mt-10'>
|
||||||
|
<Button
|
||||||
|
label='ارسال'
|
||||||
|
onClick={onConfirm}
|
||||||
|
isloading={isLoading}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label='انصراف'
|
||||||
|
className='bg-transparent text-black border border-primary'
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DefaulModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SendPleaseComeSmsModal
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { type FC, useEffect, useState } from 'react'
|
||||||
|
import DefaulModal from '@/components/DefaulModal'
|
||||||
|
import Button from '@/components/Button'
|
||||||
|
import Input from '@/components/Input'
|
||||||
|
import type { Order, UpdateOrderFeesType } from '../types/Types'
|
||||||
|
|
||||||
|
interface UpdateOrderFeesModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
order: Order
|
||||||
|
isLoading?: boolean
|
||||||
|
onSubmit: (params: UpdateOrderFeesType) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdateOrderFeesModal: FC<UpdateOrderFeesModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
order,
|
||||||
|
isLoading,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
const [deliveryFee, setDeliveryFee] = useState(String(order.deliveryFee ?? 0))
|
||||||
|
const [packingFee, setPackingFee] = useState(String(order.packingFee ?? 0))
|
||||||
|
const [prepareTime, setPrepareTime] = useState(
|
||||||
|
order.prepareTime != null ? String(order.prepareTime) : ''
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
setDeliveryFee(String(order.deliveryFee ?? 0))
|
||||||
|
setPackingFee(String(order.packingFee ?? 0))
|
||||||
|
setPrepareTime(order.prepareTime != null ? String(order.prepareTime) : '')
|
||||||
|
}, [isOpen, order.deliveryFee, order.packingFee, order.prepareTime])
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
onSubmit({
|
||||||
|
deliveryFee: Math.max(Number(deliveryFee) || 0, 0),
|
||||||
|
packingFee: Math.max(Number(packingFee) || 0, 0),
|
||||||
|
prepareTime: Math.max(Number(prepareTime) || 0, 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaulModal
|
||||||
|
open={isOpen}
|
||||||
|
close={onClose}
|
||||||
|
isHeader
|
||||||
|
title_header='ویرایش هزینهها'
|
||||||
|
width={420}
|
||||||
|
>
|
||||||
|
<div className='mt-6 space-y-4'>
|
||||||
|
<Input
|
||||||
|
label='هزینه ارسال (تومان)'
|
||||||
|
type='number'
|
||||||
|
inputMode='numeric'
|
||||||
|
min={0}
|
||||||
|
seprator
|
||||||
|
value={deliveryFee}
|
||||||
|
onChange={(e) => setDeliveryFee(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label='هزینه بستهبندی (تومان)'
|
||||||
|
type='number'
|
||||||
|
inputMode='numeric'
|
||||||
|
min={0}
|
||||||
|
seprator
|
||||||
|
value={packingFee}
|
||||||
|
onChange={(e) => setPackingFee(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label='زمان آمادهسازی (دقیقه)'
|
||||||
|
type='number'
|
||||||
|
inputMode='numeric'
|
||||||
|
min={0}
|
||||||
|
value={prepareTime}
|
||||||
|
onChange={(e) => setPrepareTime(e.target.value)}
|
||||||
|
placeholder='مثلاً ۳۰'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className='flex gap-4 justify-center pt-4'>
|
||||||
|
<Button
|
||||||
|
label='ذخیره'
|
||||||
|
onClick={handleSubmit}
|
||||||
|
isloading={isLoading}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label='انصراف'
|
||||||
|
className='bg-transparent text-black border border-primary'
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DefaulModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UpdateOrderFeesModal
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
export enum OrderStatus {
|
export enum OrderStatus {
|
||||||
PENDING_PAYMENT = "pendingPayment",
|
NEW = "new",
|
||||||
PAID = "paid",
|
|
||||||
PREPARING = "preparing",
|
PREPARING = "preparing",
|
||||||
READY = "ready",
|
|
||||||
DELIVERED_TO_WAITER = "deliveredToWaiter",
|
DELIVERED_TO_WAITER = "deliveredToWaiter",
|
||||||
DELIVERED_TO_RECEPTIONIST = "deliveredToReceptionist",
|
DELIVERED_TO_RECEPTIONIST = "deliveredToReceptionist",
|
||||||
SHIPPED = "shipped",
|
SHIPPED = "shipped",
|
||||||
|
|||||||
@@ -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()) {
|
||||||
@@ -197,9 +208,9 @@ export const useCreateOrderForm = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const registerNewCustomer = async (): Promise<string | null> => {
|
const registerNewCustomer = async (): Promise<string | undefined> => {
|
||||||
if (!isNewCustomer) {
|
if (!isNewCustomer) {
|
||||||
return customerUserId
|
return customerUserId ?? undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await createUser({
|
const response = await createUser({
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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, AddPaymentParams } from "../service/OrderService";
|
||||||
|
import type { UpdateOrderFeesType, AddOrderItemType, UpdateOrderItemQuantityType } from "../types/Types";
|
||||||
|
|
||||||
export const useCreateOrder = () => {
|
export const useCreateOrder = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -72,3 +73,91 @@ export const useRefundPayment = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useAddPayment = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ orderId, params }: { orderId: string; params: AddPaymentParams }) =>
|
||||||
|
api.addPayment(orderId, params),
|
||||||
|
onSuccess: (_, { orderId }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["order", orderId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateOrderFees = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
orderId,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
orderId: string;
|
||||||
|
params: UpdateOrderFeesType;
|
||||||
|
}) => api.updateOrderFees(orderId, params),
|
||||||
|
onSuccess: (_, { orderId }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["order", orderId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAddOrderItem = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
orderId,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
orderId: string;
|
||||||
|
params: AddOrderItemType;
|
||||||
|
}) => api.addOrderItem(orderId, params),
|
||||||
|
onSuccess: (_, { orderId }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["order", orderId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateOrderItemQuantity = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
orderId,
|
||||||
|
itemId,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
orderId: string;
|
||||||
|
itemId: string;
|
||||||
|
params: UpdateOrderItemQuantityType;
|
||||||
|
}) => api.updateOrderItemQuantity(orderId, itemId, params),
|
||||||
|
onSuccess: (_, { orderId }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["order", orderId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRemoveOrderItem = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
orderId,
|
||||||
|
itemId,
|
||||||
|
}: {
|
||||||
|
orderId: string;
|
||||||
|
itemId: string;
|
||||||
|
}) => api.removeOrderItem(orderId, itemId),
|
||||||
|
onSuccess: (_, { orderId }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["order", orderId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["orders"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSendPleaseComeSms = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (orderId: string) => api.sendPleaseComeSms(orderId),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,14 +1,80 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import type { FilterValues } from "@/components/Filters";
|
import type { FilterValues } from "@/components/Filters";
|
||||||
|
|
||||||
const DEFAULT_PAGE = 1;
|
const DEFAULT_PAGE = 1;
|
||||||
const DEFAULT_LIMIT = 10;
|
const DEFAULT_LIMIT = 10;
|
||||||
|
const DEFAULT_FILTERS: FilterValues = {
|
||||||
|
statuses: [],
|
||||||
|
paymentStatus: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFiltersFromParams = (searchParams: URLSearchParams): FilterValues => {
|
||||||
|
const filters: FilterValues = { ...DEFAULT_FILTERS };
|
||||||
|
const search = searchParams.get("search");
|
||||||
|
const statuses = searchParams.getAll("statuses");
|
||||||
|
const paymentStatus = searchParams.get("paymentStatus");
|
||||||
|
|
||||||
|
if (search) filters.search = search;
|
||||||
|
if (statuses.length > 0) filters.statuses = statuses;
|
||||||
|
if (paymentStatus) filters.paymentStatus = paymentStatus;
|
||||||
|
|
||||||
|
return filters;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPageFromParams = (searchParams: URLSearchParams): number => {
|
||||||
|
const page = searchParams.get("page");
|
||||||
|
const parsed = page ? parseInt(page, 10) : DEFAULT_PAGE;
|
||||||
|
return Number.isNaN(parsed) || parsed < 1 ? DEFAULT_PAGE : parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSearchParams = (filters: FilterValues, page: number): URLSearchParams => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (page > DEFAULT_PAGE) {
|
||||||
|
params.set("page", String(page));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.search) {
|
||||||
|
params.set("search", filters.search as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.statuses && Array.isArray(filters.statuses) && filters.statuses.length > 0) {
|
||||||
|
(filters.statuses as string[]).forEach((status) => {
|
||||||
|
params.append("statuses", status);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.paymentStatus) {
|
||||||
|
params.set("paymentStatus", filters.paymentStatus as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
};
|
||||||
|
|
||||||
export const useOrderFilters = () => {
|
export const useOrderFilters = () => {
|
||||||
const [filters, setFilters] = useState<FilterValues>({});
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [currentPage, setCurrentPage] = useState(DEFAULT_PAGE);
|
|
||||||
|
const [filters, setFilters] = useState<FilterValues>(() =>
|
||||||
|
getFiltersFromParams(searchParams),
|
||||||
|
);
|
||||||
|
const [currentPage, setCurrentPage] = useState(() =>
|
||||||
|
getPageFromParams(searchParams),
|
||||||
|
);
|
||||||
const [limit] = useState(DEFAULT_LIMIT);
|
const [limit] = useState(DEFAULT_LIMIT);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFilters(getFiltersFromParams(searchParams));
|
||||||
|
setCurrentPage(getPageFromParams(searchParams));
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const updateUrlParams = useCallback(
|
||||||
|
(newFilters: FilterValues, page: number) => {
|
||||||
|
setSearchParams(buildSearchParams(newFilters, page), { replace: true });
|
||||||
|
},
|
||||||
|
[setSearchParams],
|
||||||
|
);
|
||||||
|
|
||||||
const apiParams = useMemo(() => {
|
const apiParams = useMemo(() => {
|
||||||
const params: Record<string, string | number | string[]> = {
|
const params: Record<string, string | number | string[]> = {
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
@@ -30,14 +96,22 @@ export const useOrderFilters = () => {
|
|||||||
return params;
|
return params;
|
||||||
}, [filters, currentPage, limit]);
|
}, [filters, currentPage, limit]);
|
||||||
|
|
||||||
const handleFiltersChange = (newFilters: FilterValues) => {
|
const handleFiltersChange = useCallback(
|
||||||
setFilters(newFilters);
|
(newFilters: FilterValues) => {
|
||||||
setCurrentPage(DEFAULT_PAGE);
|
setFilters(newFilters);
|
||||||
};
|
setCurrentPage(DEFAULT_PAGE);
|
||||||
|
updateUrlParams(newFilters, DEFAULT_PAGE);
|
||||||
|
},
|
||||||
|
[updateUrlParams],
|
||||||
|
);
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
const handlePageChange = useCallback(
|
||||||
setCurrentPage(page);
|
(page: number) => {
|
||||||
};
|
setCurrentPage(page);
|
||||||
|
updateUrlParams(filters, page);
|
||||||
|
},
|
||||||
|
[filters, updateUrlParams],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filters,
|
filters,
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import type {
|
|||||||
GetDailyOrderReportResponse,
|
GetDailyOrderReportResponse,
|
||||||
CreateOrderType,
|
CreateOrderType,
|
||||||
CreateOrderResponse,
|
CreateOrderResponse,
|
||||||
|
UpdateOrderFeesType,
|
||||||
|
AddOrderItemType,
|
||||||
|
UpdateOrderItemQuantityType,
|
||||||
} from "../types/Types";
|
} from "../types/Types";
|
||||||
|
|
||||||
export interface GetOrdersParams {
|
export interface GetOrdersParams {
|
||||||
@@ -21,9 +24,15 @@ export interface ChangeOrderStatusParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RefundPaymentParams {
|
export interface RefundPaymentParams {
|
||||||
|
amount: number;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AddPaymentParams {
|
||||||
|
amount: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const createOrder = async (
|
export const createOrder = async (
|
||||||
params: CreateOrderType
|
params: CreateOrderType
|
||||||
): Promise<CreateOrderResponse> => {
|
): Promise<CreateOrderResponse> => {
|
||||||
@@ -100,6 +109,57 @@ export const refundPayment = async (
|
|||||||
await axios.post(`/admin/orders/${orderId}/refund`, params);
|
await axios.post(`/admin/orders/${orderId}/refund`, params);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const addPayment = async (
|
||||||
|
orderId: string,
|
||||||
|
params: AddPaymentParams
|
||||||
|
): Promise<void> => {
|
||||||
|
await axios.post(`/admin/orders/${orderId}/payments`, params);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateOrderFees = async (
|
||||||
|
orderId: string,
|
||||||
|
params: UpdateOrderFeesType
|
||||||
|
): Promise<GetOrderByIdResponse> => {
|
||||||
|
const { data } = await axios.patch<GetOrderByIdResponse>(
|
||||||
|
`/admin/orders/${orderId}/fees`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addOrderItem = async (
|
||||||
|
orderId: string,
|
||||||
|
params: AddOrderItemType
|
||||||
|
): Promise<GetOrderByIdResponse> => {
|
||||||
|
const { data } = await axios.post<GetOrderByIdResponse>(
|
||||||
|
`/admin/orders/${orderId}/items`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateOrderItemQuantity = async (
|
||||||
|
orderId: string,
|
||||||
|
itemId: string,
|
||||||
|
params: UpdateOrderItemQuantityType
|
||||||
|
): Promise<GetOrderByIdResponse> => {
|
||||||
|
const { data } = await axios.patch<GetOrderByIdResponse>(
|
||||||
|
`/admin/orders/${orderId}/items/${itemId}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeOrderItem = async (
|
||||||
|
orderId: string,
|
||||||
|
itemId: string
|
||||||
|
): Promise<GetOrderByIdResponse> => {
|
||||||
|
const { data } = await axios.delete<GetOrderByIdResponse>(
|
||||||
|
`/admin/orders/${orderId}/items/${itemId}`
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
export interface GetFoodOrderReportParams {
|
export interface GetFoodOrderReportParams {
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
@@ -131,3 +191,10 @@ export const getDailyOrderReport = async (
|
|||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const sendPleaseComeSms = async (orderId: string): Promise<{ message: string }> => {
|
||||||
|
const { data } = await axios.post<{ message: string }>(
|
||||||
|
`/admin/orders/${orderId}/please-come`
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export interface Order extends RowDataType {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
user: OrderUser;
|
user: OrderUser | null;
|
||||||
restaurant: OrderRestaurant;
|
restaurant: OrderRestaurant;
|
||||||
deliveryMethod: OrderDeliveryMethod;
|
deliveryMethod: OrderDeliveryMethod;
|
||||||
userAddress: UserAddress | null;
|
userAddress: UserAddress | null;
|
||||||
@@ -207,18 +207,56 @@ export interface Order extends RowDataType {
|
|||||||
subTotal: number;
|
subTotal: number;
|
||||||
tax: number;
|
tax: number;
|
||||||
deliveryFee: number;
|
deliveryFee: number;
|
||||||
|
packingFee: number;
|
||||||
|
prepareTime?: number | null;
|
||||||
total: number;
|
total: number;
|
||||||
|
paidAmount: number;
|
||||||
|
balance: number;
|
||||||
|
refundedAmount: number;
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
description: string;
|
description: string;
|
||||||
tableNumber: string | null;
|
tableNumber: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
paymentStatus?: string;
|
paymentStatus?: string;
|
||||||
|
cashShift?: {
|
||||||
|
id: string;
|
||||||
|
admin?: {
|
||||||
|
id: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
phone?: string;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
history: OrderHistory[];
|
history: OrderHistory[];
|
||||||
payments: OrderPayment[];
|
payments: OrderPayment[];
|
||||||
items: OrderItem[];
|
items: OrderItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GetOrdersResponse = IResponse<Order[]>;
|
export interface UpdateOrderFeesType {
|
||||||
|
deliveryFee?: number;
|
||||||
|
packingFee?: number;
|
||||||
|
prepareTime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddOrderItemType {
|
||||||
|
foodId: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateOrderItemQuantityType {
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaginationMeta = {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetOrdersResponse = IResponse<Order[]> & {
|
||||||
|
meta: PaginationMeta;
|
||||||
|
};
|
||||||
export type GetOrderByIdResponse = IResponse<Order>;
|
export type GetOrderByIdResponse = IResponse<Order>;
|
||||||
|
|
||||||
export interface FoodOrderReportFood {
|
export interface FoodOrderReportFood {
|
||||||
@@ -265,10 +303,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;
|
||||||
|
|||||||
+60
-293
@@ -1,22 +1,43 @@
|
|||||||
import { type FC, useState } from 'react'
|
import { type FC, useMemo, useState } from 'react'
|
||||||
import { useGetReviewById, useUpdateReviewStatus } from './hooks/useReviewData'
|
import { useGetReviewById, useUpdateReviewStatus } from './hooks/useReviewData'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import PageLoading from '@/components/PageLoading'
|
import PageLoading from '@/components/PageLoading'
|
||||||
import Input from '@/components/Input'
|
import { toast } from 'react-toastify'
|
||||||
import Status from '@/components/Status'
|
import { extractErrorMessage } from '@/config/func'
|
||||||
import Select from '@/components/Select'
|
import ReviewStatusHeader from './components/ReviewStatusHeader'
|
||||||
import Button from '@/components/Button'
|
import ReviewChatView from './components/ReviewChatView'
|
||||||
import { Star1, User, ShoppingCart, DocumentText, Like1, Dislike } from 'iconsax-react'
|
import ReviewChatSidebar from './components/ReviewChatSidebar'
|
||||||
import { formatOptionalDate, formatPrice, formatFaNumber } from '@/helpers/func'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
const getParentId = (parent: string | { id: string } | null | undefined) => {
|
||||||
|
if (!parent) return null
|
||||||
|
return typeof parent === 'string' ? parent : parent.id
|
||||||
|
}
|
||||||
|
|
||||||
const DetailReview: FC = () => {
|
const DetailReview: FC = () => {
|
||||||
|
|
||||||
const { t } = useTranslation('global')
|
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const { data: reviewData, isLoading } = useGetReviewById(id!)
|
const { data: reviewData, isLoading } = useGetReviewById(id!)
|
||||||
const updateStatusMutation = useUpdateReviewStatus()
|
const updateStatusMutation = useUpdateReviewStatus()
|
||||||
const [selectedStatus, setSelectedStatus] = useState<string>('')
|
const [selectedStatus, setSelectedStatus] = useState('')
|
||||||
|
|
||||||
|
const { review, replies } = useMemo(() => {
|
||||||
|
const items = reviewData?.data ?? []
|
||||||
|
if (!id || items.length === 0) {
|
||||||
|
return { review: undefined, replies: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentReview =
|
||||||
|
items.find((item) => item.id === id) ??
|
||||||
|
items.find((item) => !getParentId(item.parent))
|
||||||
|
|
||||||
|
const repliesList = items
|
||||||
|
.filter((item) => item.id !== parentReview?.id)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||||
|
)
|
||||||
|
|
||||||
|
return { review: parentReview, replies: repliesList }
|
||||||
|
}, [reviewData?.data, id])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -26,8 +47,7 @@ const DetailReview: FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const review = reviewData?.data
|
if (!review || !id) {
|
||||||
if (!review) {
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-5'>
|
<div className='mt-5'>
|
||||||
<div className='flex justify-between items-center'>
|
<div className='flex justify-between items-center'>
|
||||||
@@ -38,294 +58,41 @@ const DetailReview: FC = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusVariant = (status: string): 'success' | 'error' | 'warning' | 'info' | 'pending' => {
|
|
||||||
const variantMap: Record<string, 'success' | 'error' | 'warning' | 'info' | 'pending'> = {
|
|
||||||
'pending': 'pending',
|
|
||||||
'approved': 'success',
|
|
||||||
'rejected': 'error',
|
|
||||||
}
|
|
||||||
return variantMap[status] || 'pending'
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
|
||||||
'pending': 'در انتظار',
|
|
||||||
'approved': 'تایید شده',
|
|
||||||
'rejected': 'رد شده',
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusOptions = [
|
|
||||||
{ value: 'pending', label: 'در انتظار' },
|
|
||||||
{ value: 'approved', label: 'تایید شده' },
|
|
||||||
{ value: 'rejected', label: 'رد شده' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const handleStatusChange = () => {
|
const handleStatusChange = () => {
|
||||||
if (selectedStatus && selectedStatus !== review.status && id) {
|
if (!selectedStatus || selectedStatus === review.status) return
|
||||||
updateStatusMutation.mutate(
|
|
||||||
{ id, status: selectedStatus },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
setSelectedStatus('')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const userName = [review.user.firstName, review.user.lastName].filter(Boolean).join(' ') || '-'
|
updateStatusMutation.mutate(
|
||||||
|
{ id, status: selectedStatus },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedStatus('')
|
||||||
|
toast.success('وضعیت نظر بهروزرسانی شد')
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractErrorMessage(error))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-5'>
|
<div className='mt-5'>
|
||||||
<div className='flex justify-between items-center mb-8'>
|
<ReviewStatusHeader
|
||||||
<h1 className='text-lg font-light'>جزئیات نظر</h1>
|
status={review.status}
|
||||||
<div className='flex items-center gap-4'>
|
selectedStatus={selectedStatus}
|
||||||
<Status
|
onStatusChange={setSelectedStatus}
|
||||||
variant={getStatusVariant(review.status)}
|
onSave={handleStatusChange}
|
||||||
label={statusLabels[review.status] || review.status}
|
isSaving={updateStatusMutation.isPending}
|
||||||
/>
|
/>
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<div>
|
<div className='flex gap-6 items-start'>
|
||||||
<Select
|
<div className='flex-1 min-w-0'>
|
||||||
items={statusOptions}
|
<ReviewChatView reviewId={id} review={review} replies={replies} />
|
||||||
value={selectedStatus || review.status}
|
|
||||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
|
||||||
className='w-40'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{selectedStatus && selectedStatus !== review.status && (
|
|
||||||
<Button
|
|
||||||
label='ذخیره'
|
|
||||||
onClick={handleStatusChange}
|
|
||||||
isloading={updateStatusMutation.isPending}
|
|
||||||
className='w-24'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex gap-7'>
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className='flex-1 bg-white rounded-4xl p-8 space-y-6'>
|
|
||||||
{/* Rating Section */}
|
|
||||||
<div className='flex items-center gap-3 pb-6 border-b border-border'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Star1 size={24} color="#FFB800" variant="Bold" />
|
|
||||||
<span className='text-2xl font-semibold'>{review.rating}</span>
|
|
||||||
</div>
|
|
||||||
<span className='text-sm text-gray-500'>از ۵</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Comment Section */}
|
|
||||||
<div>
|
|
||||||
<div className='flex items-center gap-2 mb-4'>
|
|
||||||
<DocumentText size={20} color="#8C90A3" />
|
|
||||||
<span className='text-sm font-medium'>نظر</span>
|
|
||||||
</div>
|
|
||||||
<div className='bg-gray-50 rounded-xl p-4 border border-border'>
|
|
||||||
<p className='text-sm leading-6 text-gray-700 whitespace-pre-wrap'>
|
|
||||||
{review.comment || 'نظری ثبت نشده است'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Positive Points */}
|
|
||||||
{review.positivePoints && review.positivePoints.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className='flex items-center gap-2 mb-4'>
|
|
||||||
<Like1 size={20} color="#22C55E" />
|
|
||||||
<span className='text-sm font-medium'>نقاط مثبت</span>
|
|
||||||
</div>
|
|
||||||
<div className='flex flex-wrap gap-3'>
|
|
||||||
{review.positivePoints.map((point, index) => (
|
|
||||||
<span
|
|
||||||
key={index}
|
|
||||||
className='px-4 py-2 border border-green-500 text-green-600 rounded-xl text-sm bg-white'
|
|
||||||
>
|
|
||||||
{t(`reviews.${point}`)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Negative Points */}
|
|
||||||
{review.negativePoints && review.negativePoints.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className='flex items-center gap-2 mb-4'>
|
|
||||||
<Dislike size={20} color="#EF4444" />
|
|
||||||
<span className='text-sm font-medium'>نقاط منفی</span>
|
|
||||||
</div>
|
|
||||||
<div className='flex flex-wrap gap-3'>
|
|
||||||
{review.negativePoints.map((point, index) => (
|
|
||||||
<span
|
|
||||||
key={index}
|
|
||||||
className='px-4 py-2 border border-red-500 text-red-600 rounded-xl text-sm bg-white'
|
|
||||||
>
|
|
||||||
{t(`reviews.${point}`)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Food Information */}
|
|
||||||
<div className='pt-6 border-t border-border'>
|
|
||||||
<div className='flex items-center gap-2 mb-4'>
|
|
||||||
<ShoppingCart size={20} color="#8C90A3" />
|
|
||||||
<span className='text-sm font-medium'>اطلاعات غذا</span>
|
|
||||||
</div>
|
|
||||||
<div className='grid grid-cols-2 gap-4'>
|
|
||||||
<Input
|
|
||||||
label='عنوان غذا'
|
|
||||||
value={review.food.title}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label='قیمت'
|
|
||||||
value={formatPrice(review.food.price)}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label='امتیاز غذا'
|
|
||||||
value={formatFaNumber(review.food.rate)}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label='دستهبندی'
|
|
||||||
value={review.food.category}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{review.food.images && review.food.images.length > 0 && (
|
|
||||||
<div className='mt-4'>
|
|
||||||
<span className='text-sm mb-2 block'>تصاویر غذا</span>
|
|
||||||
<div className='flex gap-3 flex-wrap'>
|
|
||||||
{review.food.images.map((image, index) => (
|
|
||||||
<img
|
|
||||||
key={index}
|
|
||||||
src={image}
|
|
||||||
alt={`غذا ${index + 1}`}
|
|
||||||
className='w-24 h-24 object-cover rounded-xl border border-border'
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className='w-[330px] h-fit bg-white rounded-4xl p-8 flex flex-col gap-6'>
|
|
||||||
{/* User Information */}
|
|
||||||
<div>
|
|
||||||
<div className='flex items-center gap-2 mb-4'>
|
|
||||||
<User size={20} color="#8C90A3" />
|
|
||||||
<span className='text-sm font-medium'>اطلاعات کاربر</span>
|
|
||||||
</div>
|
|
||||||
<div className='space-y-4'>
|
|
||||||
<Input
|
|
||||||
label='نام و نام خانوادگی'
|
|
||||||
value={userName}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label='شماره تماس'
|
|
||||||
value={review.user.phone}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label='تاریخ تولد'
|
|
||||||
value={formatOptionalDate(review.user.birthDate)}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label='کیف پول'
|
|
||||||
value={formatPrice(review.user.wallet)}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label='امتیاز'
|
|
||||||
value={formatFaNumber(review.user.points)}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Order Information */}
|
|
||||||
<div className='pt-6 border-t border-border'>
|
|
||||||
<div className='flex items-center gap-2 mb-4'>
|
|
||||||
<ShoppingCart size={20} color="#8C90A3" />
|
|
||||||
<span className='text-sm font-medium'>اطلاعات سفارش</span>
|
|
||||||
</div>
|
|
||||||
<div className='space-y-4'>
|
|
||||||
<Input
|
|
||||||
label='شماره سفارش'
|
|
||||||
value={formatFaNumber(review.order.orderNumber)}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label='تاریخ سفارش'
|
|
||||||
value={formatOptionalDate(review.order.createdAt, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label='مبلغ کل'
|
|
||||||
value={formatPrice(review.order.total)}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label='وضعیت سفارش'
|
|
||||||
value={review.order.status}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label='وضعیت پرداخت'
|
|
||||||
value={review.order.paymentStatus}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Review Metadata */}
|
|
||||||
<div className='pt-6 border-t border-border'>
|
|
||||||
<div className='space-y-4'>
|
|
||||||
<Input
|
|
||||||
label='تاریخ ثبت نظر'
|
|
||||||
value={formatOptionalDate(review.createdAt, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
{review.updatedAt && (
|
|
||||||
<Input
|
|
||||||
label='تاریخ آخرین بروزرسانی'
|
|
||||||
value={formatOptionalDate(review.updatedAt, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ReviewChatSidebar review={review} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DetailReview
|
export default DetailReview
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { type FC } from 'react'
|
||||||
|
import { User, ShoppingCart, Calendar } from 'iconsax-react'
|
||||||
|
import { formatOptionalDate, formatPrice, formatFaNumber } from '@/helpers/func'
|
||||||
|
import type { ReviewDetail } from '../types/Types'
|
||||||
|
|
||||||
|
interface ReviewChatSidebarProps {
|
||||||
|
review: ReviewDetail
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InfoRowProps {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const InfoRow: FC<InfoRowProps> = ({ label, value }) => (
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<span className='text-xs text-gray-500'>{label}</span>
|
||||||
|
<p className='text-sm text-gray-800 leading-6 break-words'>{value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ReviewChatSidebar: FC<ReviewChatSidebarProps> = ({ review }) => {
|
||||||
|
const userName = [review.user?.firstName, review.user?.lastName].filter(Boolean).join(' ') || '-'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-[280px] shrink-0 h-fit bg-white rounded-4xl p-6 flex flex-col gap-6'>
|
||||||
|
<div>
|
||||||
|
<div className='flex items-center gap-2 mb-4'>
|
||||||
|
<User size={18} color='#8C90A3' />
|
||||||
|
<span className='text-sm font-medium'>اطلاعات کاربر</span>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<InfoRow label='نام و نام خانوادگی' value={userName} />
|
||||||
|
<InfoRow label='شماره تماس' value={review.user?.phone || '-'} />
|
||||||
|
<InfoRow label='خریدار' value={review.isBuyer ? 'بله' : 'خیر'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='pt-5 border-t border-border'>
|
||||||
|
<div className='flex items-center gap-2 mb-4'>
|
||||||
|
<ShoppingCart size={18} color='#8C90A3' />
|
||||||
|
<span className='text-sm font-medium'>اطلاعات غذا</span>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<InfoRow label='عنوان غذا' value={review.food?.title || '-'} />
|
||||||
|
<InfoRow label='قیمت' value={formatPrice(review.food?.price || 0)} />
|
||||||
|
<InfoRow label='امتیاز غذا' value={formatFaNumber(review.food?.score)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='pt-5 border-t border-border'>
|
||||||
|
<div className='flex items-center gap-2 mb-4'>
|
||||||
|
<Calendar size={18} color='#8C90A3' />
|
||||||
|
<span className='text-sm font-medium'>تاریخ ثبت نظر</span>
|
||||||
|
</div>
|
||||||
|
<p dir='ltr' className='text-sm text-gray-800 text-right'>
|
||||||
|
{formatOptionalDate(review.createdAt, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReviewChatSidebar
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { type FC, useMemo, useState } from 'react'
|
||||||
|
import { Star1, Send2, Trash } from 'iconsax-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { toast } from 'react-toastify'
|
||||||
|
import { extractErrorMessage } from '@/config/func'
|
||||||
|
import { formatOptionalDate } from '@/helpers/func'
|
||||||
|
import { useDeleteReviewReply, useReplyToReview } from '../hooks/useReviewData'
|
||||||
|
import type { ReviewDetail, ReviewReply } from '../types/Types'
|
||||||
|
|
||||||
|
interface ReviewChatViewProps {
|
||||||
|
reviewId: string
|
||||||
|
review: ReviewDetail
|
||||||
|
replies: ReviewReply[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDisplayName = (
|
||||||
|
firstName?: string | null,
|
||||||
|
lastName?: string | null,
|
||||||
|
fallback = '-'
|
||||||
|
) => [firstName, lastName].filter(Boolean).join(' ') || fallback
|
||||||
|
|
||||||
|
const formatMessageTime = (date?: string) =>
|
||||||
|
formatOptionalDate(date, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ReviewChatView: FC<ReviewChatViewProps> = ({ reviewId, review, replies }) => {
|
||||||
|
const { t } = useTranslation('global')
|
||||||
|
const existingReply = replies[0]
|
||||||
|
const [comment, setComment] = useState('')
|
||||||
|
const replyMutation = useReplyToReview()
|
||||||
|
const deleteMutation = useDeleteReviewReply()
|
||||||
|
|
||||||
|
const userName = getDisplayName(review.user?.firstName, review.user?.lastName, 'کاربر')
|
||||||
|
const foodTitle = review.food?.title || '-'
|
||||||
|
|
||||||
|
const messages = useMemo(() => {
|
||||||
|
const items: Array<{
|
||||||
|
id: string
|
||||||
|
type: 'user' | 'admin'
|
||||||
|
senderName: string
|
||||||
|
content: string
|
||||||
|
createdAt?: string
|
||||||
|
rating?: number
|
||||||
|
positivePoints?: string[]
|
||||||
|
negativePoints?: string[]
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: review.id,
|
||||||
|
type: 'user',
|
||||||
|
senderName: userName,
|
||||||
|
content: review.comment || 'نظری ثبت نشده است',
|
||||||
|
createdAt: review.createdAt,
|
||||||
|
rating: review.rating,
|
||||||
|
positivePoints: review.positivePoints,
|
||||||
|
negativePoints: review.negativePoints,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
replies.forEach((reply) => {
|
||||||
|
items.push({
|
||||||
|
id: reply.id,
|
||||||
|
type: 'admin',
|
||||||
|
senderName: getDisplayName(
|
||||||
|
reply.admin?.firstName,
|
||||||
|
reply.admin?.lastName,
|
||||||
|
'رستوران'
|
||||||
|
),
|
||||||
|
content: reply.comment || '-',
|
||||||
|
createdAt: reply.createdAt,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return items
|
||||||
|
}, [review, replies, userName])
|
||||||
|
|
||||||
|
const trimmedComment = comment.trim()
|
||||||
|
const existingText = existingReply?.comment?.trim() || ''
|
||||||
|
const canSend = trimmedComment.length > 0 && trimmedComment !== existingText
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (!canSend) return
|
||||||
|
|
||||||
|
replyMutation.mutate(
|
||||||
|
{ id: reviewId, comment: trimmedComment },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setComment('')
|
||||||
|
toast.success(existingReply ? 'پاسخ با موفقیت بهروزرسانی شد' : 'پاسخ با موفقیت ثبت شد')
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractErrorMessage(error))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
deleteMutation.mutate(reviewId, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setComment('')
|
||||||
|
toast.success('پاسخ حذف شد')
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractErrorMessage(error))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSend()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col bg-white rounded-4xl overflow-hidden min-h-[520px] max-h-[calc(100vh-220px)]'>
|
||||||
|
<div className='px-6 py-4 border-b border-border shrink-0'>
|
||||||
|
<h2 className='text-base font-medium text-right'>{userName}</h2>
|
||||||
|
<p className='text-xs text-gray-500 mt-1 text-right'>{foodTitle}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex-1 overflow-y-auto px-6 py-5'>
|
||||||
|
<ul className='flex flex-col gap-5'>
|
||||||
|
{messages.map((message) => {
|
||||||
|
const isAdmin = message.type === 'admin'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={message.id}
|
||||||
|
className={`flex w-full ${isAdmin ? 'justify-start' : 'justify-end'}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-full max-w-[min(420px,85%)] rounded-[24px] px-5 py-4 ${
|
||||||
|
isAdmin
|
||||||
|
? 'bg-[#F6F7FA] rounded-tr-none'
|
||||||
|
: 'bg-[#EBEDF5] rounded-tl-none'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className='flex items-center justify-between gap-3 mb-2'>
|
||||||
|
<h6 className='text-xs font-medium text-gray-700'>
|
||||||
|
{message.senderName}
|
||||||
|
</h6>
|
||||||
|
{isAdmin && existingReply && (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className='shrink-0 text-red-500 hover:text-red-600 disabled:opacity-50 p-1'
|
||||||
|
aria-label='حذف پاسخ'
|
||||||
|
>
|
||||||
|
<Trash size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isAdmin && message.rating !== undefined && (
|
||||||
|
<div className='flex items-center gap-1.5 mb-2'>
|
||||||
|
<Star1 size={16} color='#FFB800' variant='Bold' />
|
||||||
|
<span className='text-sm font-medium'>{message.rating}</span>
|
||||||
|
<span className='text-xs text-gray-500'>از ۵</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className='text-sm leading-7 text-gray-800 whitespace-pre-wrap break-words'>
|
||||||
|
{message.content}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!isAdmin && message.positivePoints && message.positivePoints.length > 0 && (
|
||||||
|
<div className='flex flex-wrap gap-2 mt-3'>
|
||||||
|
{message.positivePoints.map((point, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className='px-3 py-1 border border-green-500 text-green-600 rounded-xl text-xs bg-white'
|
||||||
|
>
|
||||||
|
{t(`reviews.${point}`)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isAdmin && message.negativePoints && message.negativePoints.length > 0 && (
|
||||||
|
<div className='flex flex-wrap gap-2 mt-2'>
|
||||||
|
{message.negativePoints.map((point, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className='px-3 py-1 border border-red-500 text-red-600 rounded-xl text-xs bg-white'
|
||||||
|
>
|
||||||
|
{t(`reviews.${point}`)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message.createdAt && (
|
||||||
|
<div className='mt-3 flex justify-end'>
|
||||||
|
<span dir='ltr' className='text-[11px] text-gray-400'>
|
||||||
|
{formatMessageTime(message.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='px-6 pb-6 pt-4 border-t border-border shrink-0'>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className='focus-within:outline-blue-400 outline outline-transparent transition-colors duration-100 flex items-center gap-3 bg-white border border-border min-h-12 rounded-2xl py-2 px-3'
|
||||||
|
>
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
autoComplete='off'
|
||||||
|
dir='rtl'
|
||||||
|
className='flex-1 min-w-0 text-sm text-right bg-transparent outline-none placeholder:text-gray-400'
|
||||||
|
type='text'
|
||||||
|
placeholder={
|
||||||
|
existingReply
|
||||||
|
? 'برای ویرایش پاسخ، متن جدید بنویسید...'
|
||||||
|
: 'پاسخ خود را بنویسید...'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type='submit'
|
||||||
|
disabled={!canSend || replyMutation.isPending}
|
||||||
|
className='shrink-0 active:bg-neutral-100 transition-colors duration-150 rounded-full p-2 disabled:opacity-40'
|
||||||
|
aria-label='ارسال پاسخ'
|
||||||
|
>
|
||||||
|
<Send2
|
||||||
|
size={20}
|
||||||
|
color={canSend ? '#2563EB' : '#8C90A3'}
|
||||||
|
className='rotate-180'
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReviewChatView
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { type FC } from 'react'
|
||||||
|
import { Star1, DocumentText, Like1, Dislike } from 'iconsax-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { ReviewDetail } from '../types/Types'
|
||||||
|
|
||||||
|
interface ReviewContentSectionProps {
|
||||||
|
review: ReviewDetail
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReviewContentSection: FC<ReviewContentSectionProps> = ({ review }) => {
|
||||||
|
const { t } = useTranslation('global')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<div className='flex items-center gap-3 pb-6 border-b border-border'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Star1 size={24} color="#FFB800" variant="Bold" />
|
||||||
|
<span className='text-2xl font-semibold'>{review.rating}</span>
|
||||||
|
</div>
|
||||||
|
<span className='text-sm text-gray-500'>از ۵</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className='flex items-center gap-2 mb-4'>
|
||||||
|
<DocumentText size={20} color="#8C90A3" />
|
||||||
|
<span className='text-sm font-medium'>نظر</span>
|
||||||
|
</div>
|
||||||
|
<div className='bg-gray-50 rounded-xl p-4 border border-border'>
|
||||||
|
<p className='text-sm leading-6 text-gray-700 whitespace-pre-wrap'>
|
||||||
|
{review.comment || 'نظری ثبت نشده است'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{review.positivePoints && review.positivePoints.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className='flex items-center gap-2 mb-4'>
|
||||||
|
<Like1 size={20} color="#22C55E" />
|
||||||
|
<span className='text-sm font-medium'>نقاط مثبت</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-wrap gap-3'>
|
||||||
|
{review.positivePoints.map((point, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className='px-4 py-2 border border-green-500 text-green-600 rounded-xl text-sm bg-white'
|
||||||
|
>
|
||||||
|
{t(`reviews.${point}`)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{review.negativePoints && review.negativePoints.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className='flex items-center gap-2 mb-4'>
|
||||||
|
<Dislike size={20} color="#EF4444" />
|
||||||
|
<span className='text-sm font-medium'>نقاط منفی</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-wrap gap-3'>
|
||||||
|
{review.negativePoints.map((point, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className='px-4 py-2 border border-red-500 text-red-600 rounded-xl text-sm bg-white'
|
||||||
|
>
|
||||||
|
{t(`reviews.${point}`)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReviewContentSection
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { type FC } from 'react'
|
||||||
|
import Input from '@/components/Input'
|
||||||
|
import { ShoppingCart } from 'iconsax-react'
|
||||||
|
import { formatPrice, formatFaNumber } from '@/helpers/func'
|
||||||
|
import type { ReviewFood } from '../types/Types'
|
||||||
|
|
||||||
|
interface ReviewFoodSectionProps {
|
||||||
|
food: ReviewFood
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReviewFoodSection: FC<ReviewFoodSectionProps> = ({ food }) => {
|
||||||
|
return (
|
||||||
|
<div className='pt-6 border-t border-border'>
|
||||||
|
<div className='flex items-center gap-2 mb-4'>
|
||||||
|
<ShoppingCart size={20} color="#8C90A3" />
|
||||||
|
<span className='text-sm font-medium'>اطلاعات غذا</span>
|
||||||
|
</div>
|
||||||
|
<div className='grid grid-cols-2 gap-4'>
|
||||||
|
<Input label='عنوان غذا' value={food.title} readOnly />
|
||||||
|
<Input label='قیمت' value={formatPrice(food.price)} readOnly />
|
||||||
|
<Input label='امتیاز غذا' value={formatFaNumber(food.score)} readOnly />
|
||||||
|
<Input label='دستهبندی' value={food.category} readOnly />
|
||||||
|
</div>
|
||||||
|
{food.images && food.images.length > 0 && (
|
||||||
|
<div className='mt-4'>
|
||||||
|
<span className='text-sm mb-2 block'>تصاویر غذا</span>
|
||||||
|
<div className='flex gap-3 flex-wrap'>
|
||||||
|
{food.images.map((image, index) => (
|
||||||
|
<img
|
||||||
|
key={index}
|
||||||
|
src={image}
|
||||||
|
alt={`غذا ${index + 1}`}
|
||||||
|
className='w-24 h-24 object-cover rounded-xl border border-border'
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReviewFoodSection
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user