Compare commits

...

63 Commits

Author SHA1 Message Date
hamid zarghami e283d4a5c4 generate qr code
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-27 10:09:11 +03:30
morteza f30ee4f6e3 enable chat
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-26 14:13:12 +03:30
morteza b9fc6ab247 remove summary
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-26 12:27:56 +03:30
morteza 7ad840fbda send sms to user in order detail
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-26 11:56:21 +03:30
morteza 6dea561b05 update voice
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-26 11:22:03 +03:30
hamid zarghami b73f00299b base url
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-23 11:40:20 +03:30
hamid zarghami a6593ed0e8 questions chats 2026-07-23 11:40:20 +03:30
hamid zarghami 9f8877daf9 food scription and food contents 2026-07-23 11:40:20 +03:30
morteza 4f2bf864e4 update user
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-22 11:51:58 +03:30
morteza ee1655493c remove unused
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-19 19:37:22 +03:30
morteza 66992dd2c3 notif update
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-19 16:09:37 +03:30
morteza c0fa388ac3 add payment
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-19 11:34:59 +03:30
hamid zarghami 366d9faff3 fix build
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-19 09:36:08 +03:30
hamid zarghami 985a29b50f establishedYear
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-19 09:09:05 +03:30
morteza b979bb610a edit order
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-18 20:56:48 +03:30
morteza 06873bae01 t a
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-17 11:13:12 +03:30
morteza 3e17cf9e12 review
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-17 10:18:55 +03:30
hamid zarghami 7913c43bc0 fix review detail error
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-15 12:51:59 +03:30
morteza 331b5fe02d remove bg
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-14 23:08:47 +03:30
morteza dcc071d357 remove unused
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-14 23:05:58 +03:30
morteza 7918547137 update
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-14 22:49:57 +03:30
morteza 8b9ca1707c up
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-14 21:17:44 +03:30
morteza fa83d7d092 shift 2026-07-14 21:04:39 +03:30
morteza 9aed33d73b shift in admin
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-14 16:28:26 +03:30
morteza 435fe3e947 update costs
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-14 10:07:15 +03:30
morteza 0b3b7218b9 fix order list when user is null
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-12 11:29:18 +03:30
hamid zarghami 57284c0641 campain list
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-04 16:13:18 +03:30
hamid zarghami 9f18e33ef6 navigate back 2026-07-04 15:21:31 +03:30
hamid zarghami 6a7d5523a7 dublicate food
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-04 12:56:01 +03:30
hamid zarghami 6a6aa76b1e wallet 2026-07-04 12:50:02 +03:30
hamid zarghami 23f47705bb wallet 2026-07-04 12:26:28 +03:30
hamid zarghami 95814d6b00 campains
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-04 11:56:32 +03:30
hamid zarghami a4eaa50fdf group 2026-07-04 10:43:16 +03:30
hamid zarghami 457361e759 Crud group 2026-07-04 10:34:56 +03:30
hamid zarghami eb9973678c slider 2026-07-04 09:45:24 +03:30
hamid zarghami bcf03887f1 pagination orders list 2026-07-02 10:43:55 +03:30
hamid zarghami 6187e7c26d update customer 2026-07-02 10:35:38 +03:30
hamid zarghami 2d35a428b1 base url
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-02 10:15:14 +03:30
hamid zarghami aac1a4f81b user option and added in modal 2026-07-01 12:55:27 +03:30
hamid zarghami 282e001933 user optional 2026-07-01 12:52:52 +03:30
hamid zarghami 8ec3f307b0 allways show receipt 2026-07-01 12:40:44 +03:30
hamid zarghami 4c240ac9a1 finish order 2026-07-01 12:35:08 +03:30
hamid zarghami d52ff4f07e discountAmount and packingFee in create order admin 2026-07-01 12:28:51 +03:30
hamid zarghami c7b4e77421 fix chart 2026-07-01 12:14:00 +03:30
hamid zarghami 3c6d3f009a create order without scoll
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-01 10:10:15 +03:30
hamid zarghami 0539d4bc48 Create order filter by category 2026-07-01 09:07:24 +03:30
hamid zarghami e6b3610bfc change design in norder 2026-07-01 09:02:29 +03:30
hamid zarghami 4fc7d19766 fix role not found
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-29 15:05:30 +03:30
hamid zarghami b155c0b959 revenuechart 2026-06-29 14:54:56 +03:30
hamid zarghami 53ebbc8d07 login image change size 2026-06-29 14:51:32 +03:30
hamid zarghami 6bff75109a fix build
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-29 12:38:31 +03:30
hamid zarghami b92faa81a9 base url
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-29 12:09:26 +03:30
hamid zarghami a248064800 multi phones in setting 2026-06-29 12:09:10 +03:30
hamid zarghami 72ca39c284 multi card number in payment method 2026-06-29 11:41:03 +03:30
hamid zarghami 6324cabb32 fix build
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-28 09:11:12 +03:30
hamid zarghami 3e7cc74d57 create customer
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-27 15:24:50 +03:30
hamid zarghami 047bfc6f46 show demo 2026-06-27 11:31:57 +03:30
hamid zarghami 050ffc255e fix base url
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-23 14:12:21 +03:30
hamid zarghami 67dfc03c7b search in maps
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-23 12:38:23 +03:30
hamid zarghami 7130c304c5 Create order 2026-06-23 12:31:39 +03:30
hamid zarghami f83d0743c6 Create user 2026-06-23 11:25:19 +03:30
hamid zarghami 45494d7ae5 user Addresses 2026-06-23 11:16:25 +03:30
hamid zarghami 223b1b5e8f base create order 2026-06-23 10:12:56 +03:30
168 changed files with 12002 additions and 2558 deletions
+2 -1
View File
@@ -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/'
+75
View File
@@ -0,0 +1,75 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { IncomingMessage as ProxyIncomingMessage } from "node:http";
import type { ProxyOptions } from "vite";
import { MENU_SITE_ORIGIN, isMenuSlugProxyPath, stripMenuPreviewCacheParam, toMenuSitePath } from "./src/config/menuPreview";
const stripFrameHeaders = (_proxyRes: ProxyIncomingMessage, _req: IncomingMessage, res: ServerResponse) => {
res.removeHeader("x-frame-options");
res.removeHeader("content-security-policy");
};
const stripPreviewPageHeaders = (_proxyRes: ProxyIncomingMessage, _req: IncomingMessage, res: ServerResponse) => {
stripFrameHeaders(_proxyRes, _req, res);
res.removeHeader("cache-control");
res.removeHeader("etag");
res.removeHeader("last-modified");
res.setHeader("cache-control", "no-store, no-cache, must-revalidate");
res.setHeader("pragma", "no-cache");
res.setHeader("expires", "0");
};
const menuAssetProxy = (): ProxyOptions => ({
target: MENU_SITE_ORIGIN,
changeOrigin: true,
secure: true,
configure: (proxy) => {
proxy.on("proxyRes", stripFrameHeaders);
},
});
export const createMenuPreviewProxy = (): Record<string, ProxyOptions> => ({
"/assets/fonts": menuAssetProxy(),
"/assets/images": menuAssetProxy(),
"/_next": menuAssetProxy(),
"/icons": menuAssetProxy(),
});
export const shouldProxyMenuSlugRequest = (url: string | undefined) => {
if (!url) return false;
const pathname = url.split("?")[0] ?? "";
if (pathname.startsWith("/@") || pathname.startsWith("/src/") || pathname.startsWith("/node_modules/")) {
return false;
}
if (
pathname.startsWith("/assets/fonts/") ||
pathname.startsWith("/assets/images/") ||
pathname.startsWith("/_next/") ||
pathname.startsWith("/icons/")
) {
return false;
}
if (pathname.startsWith("/assets/")) {
return false;
}
return isMenuSlugProxyPath(pathname);
};
export const getMenuSiteTargetUrl = (url: string) => {
const [pathname, search = ""] = url.split("?");
const menuPath = toMenuSitePath(pathname ?? "");
if (!menuPath) return null;
return `${MENU_SITE_ORIGIN}${menuPath}${stripMenuPreviewCacheParam(search)}`;
};
export const createMenuSlugProxyAgent = (): { configure: NonNullable<ProxyOptions["configure"]> } => ({
configure: (proxy) => {
proxy.on("proxyReq", (proxyReq) => {
proxyReq.removeHeader("origin");
});
proxy.on("proxyRes", stripPreviewPageHeaders);
},
});
+82 -7
View File
@@ -5,17 +5,92 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# SPA fallback # Public menu site assets (Next.js)
location / { location ^~ /_next/ {
proxy_pass https://dmenu.danakcorp.com/_next/;
proxy_ssl_server_name on;
proxy_set_header Host dmenu.danakcorp.com;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location ^~ /icons/ {
proxy_pass https://dmenu.danakcorp.com/icons/;
proxy_ssl_server_name on;
proxy_set_header Host dmenu.danakcorp.com;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location ^~ /assets/fonts/ {
proxy_pass https://dmenu.danakcorp.com/assets/fonts/;
proxy_ssl_server_name on;
proxy_set_header Host dmenu.danakcorp.com;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location ^~ /assets/images/ {
proxy_pass https://dmenu.danakcorp.com/assets/images/;
proxy_ssl_server_name on;
proxy_set_header Host dmenu.danakcorp.com;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
# Vite build outputs to /assets by default
location ^~ /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Admin SPA routes
location ^~ /auth/ {
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";
} }
# Vite build outputs to /assets by default location = /dashboard {
location /assets/ { try_files $uri /index.html;
expires 1y; add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header Cache-Control "public, immutable"; }
try_files $uri =404;
location = /setting {
try_files $uri /index.html;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
location = /statistics {
try_files $uri /index.html;
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|wallet)/ {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# 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|wallet|assets|icons|_next)([a-zA-Z0-9][a-zA-Z0-9_-]*)(/.*)?$ {
proxy_pass https://dmenu.danakcorp.com/$1$2$is_args$args;
proxy_ssl_server_name on;
proxy_set_header Host dmenu.danakcorp.com;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_hide_header Cache-Control;
proxy_hide_header ETag;
proxy_hide_header Last-Modified;
proxy_hide_header Expires;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-store, no-cache, must-revalidate";
} }
# optional: favicon (avoid noisy logs if missing) # optional: favicon (avoid noisy logs if missing)
+21
View File
@@ -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",
+2
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

+7 -3
View File
@@ -14,7 +14,8 @@ type Props = {
reset?: boolean; reset?: boolean;
isDateTime?: boolean; isDateTime?: boolean;
className?: string; className?: string;
label?: string label?: string;
isNotRequired?: boolean;
}; };
const DatePickerComponent: FC<Props> = (props: Props) => { const DatePickerComponent: FC<Props> = (props: Props) => {
@@ -85,8 +86,11 @@ const DatePickerComponent: FC<Props> = (props: Props) => {
return ( return (
<div className="w-full"> <div className="w-full">
{props.label && {props.label &&
<div className='text-sm'> <div className='flex items-center gap-1 text-sm'>
{props.label} <div>{props.label}</div>
{props.isNotRequired && (
<span className='text-xs text-description'>(اختیاری)</span>
)}
</div>} </div>}
<div className={clx( <div className={clx(
'relative ', 'relative ',
+8 -3
View File
@@ -84,9 +84,14 @@ const Input: FC<Props> = (props: Props) => {
return ( return (
<div className='w-full'> <div className='w-full'>
<label className='text-sm'> {props.label && (
{props.label} <div className='flex items-center gap-1'>
</label> <label className='text-sm'>{props.label}</label>
{props.isNotRequired && (
<span className='text-xs text-description'>(اختیاری)</span>
)}
</div>
)}
<div className='w-full relative mt-1'> <div className='w-full relative mt-1'>
<input {...props} onChange={(e) => { <input {...props} onChange={(e) => {
+50 -17
View File
@@ -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>
+2 -25
View File
@@ -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:
+1 -1
View File
@@ -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'>
+43 -13
View File
@@ -13,28 +13,58 @@ type Props = {
error_text?: string, error_text?: string,
placeholder?: string, placeholder?: string,
label?: string, label?: string,
isNotRequired?: boolean,
} & SelectHTMLAttributes<HTMLSelectElement> } & SelectHTMLAttributes<HTMLSelectElement>
const Select: FC<Props> = (props: Props) => { const Select: FC<Props> = (props: Props) => {
const {
className,
items,
error_text,
placeholder,
label,
isNotRequired,
value,
...selectProps
} = props
const hasMatchingItem =
value !== undefined &&
value !== '' &&
items?.some((item) => String(item.value) === String(value))
const selectValue =
!value || hasMatchingItem || !items?.length
? (value ?? '')
: ''
return ( return (
<div className='w-full relative'> <div className='w-full relative'>
{ {
props.label && label &&
<label className='text-sm'> <div className='flex items-center gap-1'>
{props.label} <label className='text-sm'>
</label> {label}
</label>
{isNotRequired && (
<span className='text-xs text-description'>(اختیاری)</span>
)}
</div>
} }
<select {...props} className={clx( <select
{...selectProps}
value={selectValue}
className={clx(
'w-full text-black block border appearance-none border-border !bg-white px-2.5 h-10 text-sm rounded-[10px] bg-gray', 'w-full text-black block border appearance-none border-border !bg-white px-2.5 h-10 text-sm rounded-[10px] bg-gray',
props.className, className,
props.label && 'mt-1' label && 'mt-1'
)}> )}>
{ {
props.placeholder && placeholder &&
<option value="" disabled selected>{props.placeholder}</option> <option value="" disabled>{placeholder}</option>
} }
{ {
props.items?.map((item) => { items?.map((item) => {
return ( return (
<option key={item.value} value={item.value}> <option key={item.value} value={item.value}>
{item.label} {item.label}
@@ -45,12 +75,12 @@ const Select: FC<Props> = (props: Props) => {
</select> </select>
<ArrowDown2 size={16} color='black' className={clx( <ArrowDown2 size={16} color='black' className={clx(
'absolute z-0 top-3 left-2', 'absolute z-0 top-3 left-2',
props.label && 'top-10' label && 'top-10'
)} /> )} />
{ {
props.error_text && props.error_text !== '' ? error_text && error_text !== '' ?
<Error <Error
errorText={props.error_text} errorText={error_text}
/> />
: null : null
} }
+15 -13
View File
@@ -1,35 +1,37 @@
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'
type Props = { type Props = {
label?: string, label?: string,
error_text?: string error_text?: string
} & InputHTMLAttributes<HTMLTextAreaElement> isNotRequired?: boolean
} & 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='text-sm' > <div className='flex items-center gap-1'>
{props.label} <div className='text-sm'>{label}</div>
{isNotRequired && (
<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>
+7 -1
View File
@@ -9,6 +9,7 @@ type Props = {
isMultiple?: boolean, isMultiple?: boolean,
onChange?: (file: File[]) => void; onChange?: (file: File[]) => void;
isReset?: boolean; isReset?: boolean;
isNotRequired?: boolean;
} }
const UploadBox: FC<Props> = (props: Props) => { const UploadBox: FC<Props> = (props: Props) => {
@@ -55,7 +56,12 @@ const UploadBox: FC<Props> = (props: Props) => {
return ( return (
<div> <div>
<div className='text-sm'>{props.label}</div> <div className='flex items-center gap-1 text-sm'>
<div>{props.label}</div>
{props.isNotRequired && (
<span className='text-xs text-description'>(اختیاری)</span>
)}
</div>
<div className='w-full h-10 mt-1 border border-border rounded-xl items-center px-2 flex gap-2.5'> <div className='w-full h-10 mt-1 border border-border rounded-xl items-center px-2 flex gap-2.5'>
<button {...getRootProps()} className=' w-fit h-7 rounded-lg text-xs px-6 bg-secondary'> <button {...getRootProps()} className=' w-fit h-7 rounded-lg text-xs px-6 bg-secondary'>
<input {...getInputProps()} /> <input {...getInputProps()} />
+17
View File
@@ -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",
},
}; };
+67
View File
@@ -0,0 +1,67 @@
export const MENU_SITE_ORIGIN = "https://dmenu.danakcorp.com";
/** Admin routes whose first segment must not be proxied to the public menu site. */
export const MENU_PREVIEW_RESERVED_SEGMENTS = new Set([
"auth",
"dashboard",
"setting",
"statistics",
"schedule",
"payment_methods",
"foods",
"orders",
"customers",
"discounts",
"announcements",
"reports",
"comments",
"roles",
"admins",
"shipment_methods",
"coupons",
"reviews",
"pagers",
"notifications",
"sliders",
"wallet",
"assets",
"menu-preview",
]);
export const isMenuPreviewProxyPath = (pathname: string) => {
if (!pathname.startsWith("/menu-preview/")) return false;
const rest = pathname.slice("/menu-preview".length);
return rest.length > 0;
};
export const isMenuSlugProxyPath = (pathname: string) => {
const [firstSegment] = pathname.split("/").filter(Boolean);
if (!firstSegment) return false;
return !MENU_PREVIEW_RESERVED_SEGMENTS.has(firstSegment);
};
export const toMenuPreviewProxyPath = (slug: string) => `/${slug}`;
export const MENU_PREVIEW_CACHE_PARAM = "_preview";
export const buildMenuPreviewUrl = (slug: string, cacheKey: number) =>
`${toMenuPreviewProxyPath(slug)}?${MENU_PREVIEW_CACHE_PARAM}=${cacheKey}`;
export const stripMenuPreviewCacheParam = (search: string) => {
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
params.delete(MENU_PREVIEW_CACHE_PARAM);
const query = params.toString();
return query ? `?${query}` : "";
};
export const toMenuSitePath = (pathname: string) => {
if (isMenuPreviewProxyPath(pathname)) {
return pathname.replace(/^\/menu-preview/, "") || "/";
}
if (isMenuSlugProxyPath(pathname)) {
return pathname;
}
return null;
};
+65 -6
View File
@@ -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": "بسته"
}
} }
} }
+134 -142
View File
@@ -1,156 +1,148 @@
import { type FC, useEffect } from 'react' import Button from "@/components/Button";
import { useFormik } from 'formik' import Input from "@/components/Input";
import * as Yup from 'yup' import Select from "@/components/Select";
import { TickCircle } from 'iconsax-react' import { extractErrorMessage } from "@/config/func";
import { toast } from 'react-toastify' import { Pages } from "@/config/Pages";
import { useNavigate, useParams } from 'react-router-dom' import type { ErrorType } from "@/helpers/types";
import Button from '@/components/Button' import { useGetRoles } from "@/pages/roles/hooks/useRolesData";
import Input from '@/components/Input' import { useFormik } from "formik";
import Select from '@/components/Select' import { TickCircle } from "iconsax-react";
import { useGetAdminById, useUpdateAdmin } from './hooks/useAdminData' import { type FC, useEffect } from "react";
import { useGetRoles } from '@/pages/roles/hooks/useRolesData' import { useNavigate, useParams } from "react-router-dom";
import type { CreateAdminType } from './types/Types' import { toast } from "react-toastify";
import { Pages } from '@/config/Pages' import * as Yup from "yup";
import type { ErrorType } from '@/helpers/types' import { useGetAdminById, useUpdateAdmin } from "./hooks/useAdminData";
import { extractErrorMessage } from '@/config/func' import type { CreateAdminType } from "./types/Types";
const UpdateAdmin: FC = () => { const UpdateAdmin: FC = () => {
const navigate = useNavigate() const navigate = useNavigate();
const { id } = useParams() const { id } = useParams();
const { data: adminData, isLoading } = useGetAdminById(id!) const { data: adminData, isLoading } = useGetAdminById(id!);
const { mutate: updateAdmin, isPending } = useUpdateAdmin() const { mutate: updateAdmin, isPending } = useUpdateAdmin();
const { data: rolesData } = useGetRoles() const { data: rolesData } = useGetRoles();
const roles = rolesData?.data?.map(role => ({ const roles =
label: role.name, rolesData?.data?.map((role) => ({
value: role.id label: role.name,
})) || [] value: role.id,
})) || [];
const admin = adminData?.data const admin = adminData?.data;
const formik = useFormik<CreateAdminType>({ const formik = useFormik<CreateAdminType>({
initialValues: { initialValues: {
phone: '', phone: "",
firstName: '', firstName: "",
lastName: '', lastName: "",
roleId: '', roleId: "",
},
validationSchema: Yup.object().shape({
phone: Yup.string().required("شماره تلفن الزامی است"),
firstName: Yup.string().required("نام الزامی است"),
lastName: Yup.string().required("نام خانوادگی الزامی است"),
roleId: Yup.string().required("نقش الزامی است"),
}),
onSubmit: (values) => {
if (!id) {
toast.error("شناسه مدیر یافت نشد");
return;
}
updateAdmin(
{ id, params: values },
{
onSuccess: () => {
toast.success("مدیر با موفقیت به‌روزرسانی شد");
navigate(Pages.admins.list);
},
onError: (error: ErrorType) => {
toast.error(extractErrorMessage(error));
},
}, },
validationSchema: Yup.object().shape({ );
phone: Yup.string().required('شماره تلفن الزامی است'), },
firstName: Yup.string().required('نام الزامی است'), });
lastName: Yup.string().required('نام خانوادگی الزامی است'),
roleId: Yup.string().required('نقش الزامی است'),
}),
onSubmit: (values) => {
if (!id) {
toast.error('شناسه مدیر یافت نشد')
return
}
updateAdmin({ id, params: values }, { useEffect(() => {
onSuccess: () => { if (admin) {
toast.success('مدیر با موفقیت به‌روزرسانی شد') formik.setValues({
navigate(Pages.admins.list) phone: admin.phone || "",
}, firstName: admin.firstName || "",
onError: (error: ErrorType) => { lastName: admin.lastName || "",
toast.error(extractErrorMessage(error)) roleId: admin.role?.id || "",
}, });
})
},
})
useEffect(() => {
if (admin) {
formik.setValues({
phone: admin.phone || '',
firstName: admin.firstName || '',
lastName: admin.lastName || '',
roleId: admin.roles?.[0]?.role?.id || '',
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [admin])
if (isLoading) {
return (
<div className='w-full mt-4 flex justify-center items-center'>
<div>در حال بارگذاری...</div>
</div>
)
}
if (!admin) {
return (
<div className='w-full mt-4 flex justify-center items-center'>
<div>مدیر یافت نشد</div>
</div>
)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [admin]);
if (isLoading) {
return ( return (
<div className='mt-5'> <div className="w-full mt-4 flex justify-center items-center">
<div className='flex w-full justify-between items-center'> <div>در حال بارگذاری...</div>
<div className='text-lg font-light'> </div>
ویرایش مدیر );
</div> }
<div>
<Button
className='px-5'
onClick={() => formik.handleSubmit()}
isloading={isPending}
>
<div className='flex gap-2 items-center'>
<TickCircle className='size-5' color='white' />
<div>ذخیره تغییرات</div>
</div>
</Button>
</div>
</div>
<div className='mt-6'> if (!admin) {
<div className='bg-white rounded-3xl p-6'> return (
<div className='mb-4'> <div className="w-full mt-4 flex justify-center items-center">
<Input <div>مدیر یافت نشد</div>
label='نام' </div>
name='firstName' );
value={formik.values.firstName} }
onChange={formik.handleChange}
error_text={formik.touched.firstName && formik.errors.firstName ? formik.errors.firstName : ''} return (
/> <div className="mt-5">
</div> <div className="flex w-full justify-between items-center">
<div className='mt-6'> <div className="text-lg font-light">ویرایش مدیر</div>
<Input <div>
label='نام خانوادگی' <Button className="px-5" onClick={() => formik.handleSubmit()} isloading={isPending}>
name='lastName' <div className="flex gap-2 items-center">
value={formik.values.lastName} <TickCircle className="size-5" color="white" />
onChange={formik.handleChange} <div>ذخیره تغییرات</div>
error_text={formik.touched.lastName && formik.errors.lastName ? formik.errors.lastName : ''}
/>
</div>
<div className='mt-6'>
<Input
label='شماره تلفن'
name='phone'
value={formik.values.phone}
onChange={formik.handleChange}
error_text={formik.touched.phone && formik.errors.phone ? formik.errors.phone : ''}
/>
</div>
<div className='mt-6'>
<Select
label='نقش'
name='roleId'
value={formik.values.roleId}
onChange={formik.handleChange}
items={roles}
placeholder='نقش را انتخاب کنید'
error_text={formik.touched.roleId && formik.errors.roleId ? formik.errors.roleId : ''}
/>
</div>
</div>
</div> </div>
</Button>
</div> </div>
) </div>
}
export default UpdateAdmin <div className="mt-6">
<div className="bg-white rounded-3xl p-6">
<div className="mb-4">
<Input
label="نام"
name="firstName"
value={formik.values.firstName}
onChange={formik.handleChange}
error_text={formik.touched.firstName && formik.errors.firstName ? formik.errors.firstName : ""}
/>
</div>
<div className="mt-6">
<Input
label="نام خانوادگی"
name="lastName"
value={formik.values.lastName}
onChange={formik.handleChange}
error_text={formik.touched.lastName && formik.errors.lastName ? formik.errors.lastName : ""}
/>
</div>
<div className="mt-6">
<Input label="شماره تلفن" name="phone" value={formik.values.phone} onChange={formik.handleChange} error_text={formik.touched.phone && formik.errors.phone ? formik.errors.phone : ""} />
</div>
<div className="mt-6">
<Select
label="نقش"
name="roleId"
value={formik.values.roleId}
onChange={formik.handleChange}
items={roles}
placeholder="نقش را انتخاب کنید"
error_text={formik.touched.roleId && formik.errors.roleId ? formik.errors.roleId : ""}
/>
</div>
</div>
</div>
</div>
);
};
export default UpdateAdmin;
+1
View File
@@ -28,6 +28,7 @@ export type Admin = {
lastName: string; lastName: string;
phone: string; phone: string;
roles: AdminRole[]; roles: AdminRole[];
role: RoleDetail;
}; };
export type GetAdminsParams = { export type GetAdminsParams = {
+110
View File
@@ -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);
}}
/>
);
},
},
];
};
+10
View File
@@ -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;
};
+47
View File
@@ -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;
+24 -36
View File
@@ -1,42 +1,30 @@
import { type FC } from 'react' import { type FC } from "react";
import LogoImage from '../../assets/images/logo.png' import LogoSmallImage from "../../assets/images/logo-small.png";
import LogoSmallImage from '../../assets/images/logo-small.png' import LogoImage from "../../assets/images/logo.png";
import { useAuthStore } from './store/AuthStore' import LoginStep1 from "./components/LoginStep1";
import LoginStep1 from './components/LoginStep1' import LoginStep2 from "./components/LoginStep2";
import LoginStep2 from './components/LoginStep2' import { useAuthStore } from "./store/AuthStore";
const Login: FC = () => { const Login: FC = () => {
const { stepLogin, phone } = useAuthStore();
const { stepLogin, phone } = useAuthStore() return (
<div className="w-full h-full flex justify-center lg:py-[75px] py-4 lg:items-center lg:px-10 px-4">
<div className="flex w-full max-h-[812px] max-w-[1200px] bg-secondary h-full rounded-3xl overflow-hidden">
<div className="flex-1 min-w-[50%] overflow-y-auto bg-white lg:px-9 px-4 py-7">
<div className="flex-1 h-full flex flex-col">
<img src={LogoSmallImage} className="w-8" />
return ( <div className="flex flex-1 flex-col h-full justify-center">{stepLogin === 1 ? <LoginStep1 /> : stepLogin === 2 && !!phone ? <LoginStep2 /> : null}</div>
<div className='w-full h-full flex justify-center lg:py-[75px] py-4 lg:items-center lg:px-10 px-4'> </div>
<div className='flex w-full max-h-[812px] max-w-[1200px] bg-secondary h-full rounded-3xl overflow-hidden'>
<div className='flex-1 min-w-[50%] overflow-y-auto bg-white lg:px-9 px-4 py-7'>
<div className='flex-1 h-full flex flex-col'>
<img src={LogoSmallImage} className='w-8' />
<div className='flex flex-1 flex-col h-full justify-center'>
{
stepLogin === 1 ?
<LoginStep1 />
:
stepLogin === 2 && !!phone ?
<LoginStep2 />
: null
}
</div>
</div>
</div>
<div className='flex-1 min-w-[50%] lg:flex hidden justify-center items-center'>
<img src={LogoImage} className='h-20' />
</div>
</div>
</div> </div>
)
}
export default Login <div className="flex-1 min-w-[50%] lg:flex hidden justify-center items-center">
<img src={LogoImage} className="h-14" />
</div>
</div>
</div>
);
};
export default Login;
+196
View File
@@ -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;
+4
View File
@@ -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;
};
+60
View File
@@ -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;
}
+227 -132
View File
@@ -1,155 +1,250 @@
import { type FC, useEffect, useMemo, useState } from 'react'
import { useFormik } from 'formik'
import * as Yup from 'yup'
import { TickCircle } from 'iconsax-react'
import { toast } from 'react-toastify'
import { useNavigate } 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 Textarea from '@/components/Textarea' import UploadBox from '@/components/UploadBox'
import CustomerStats from '@/components/CustomerStats' import { useCreateUser } from './hooks/useUsersData'
import { TickCircle, Wallet2, TicketDiscount } from 'iconsax-react' import { useSingleUpload } from '@/pages/uploader/hooks/useUploaderData'
import { type FC } from 'react' import { useGetUserGroups } from '@/pages/customers/groups/hooks/useUserGroupData'
import type { CreateUserType } from './types/Types'
import { Pages } from '@/config/Pages'
import type { ErrorType } from '@/helpers/types'
import { extractErrorMessage } from '@/config/func'
type CreateCustomerFormValues = {
phone: string
firstName: string
lastName: string
birthDate: string
marriageDate: string
gender: string
groupIds: string[]
}
const buildCreateUserPayload = (
values: CreateCustomerFormValues,
avatarUrl?: string,
): CreateUserType => {
const payload: CreateUserType = {
phone: values.phone.trim(),
firstName: values.firstName.trim(),
lastName: values.lastName.trim(),
}
if (values.birthDate) payload.birthDate = values.birthDate
if (values.marriageDate) payload.marriageDate = values.marriageDate
if (values.gender !== '') payload.gender = values.gender === 'true'
if (avatarUrl) payload.avatarUrl = avatarUrl
if (values.groupIds.length > 0) payload.groupIds = values.groupIds
return payload
}
const CreateCustomer: FC = () => { const CreateCustomer: FC = () => {
const navigate = useNavigate()
const { mutate: createUser, isPending } = useCreateUser()
const { mutate: singleUpload, isPending: isUploading } = useSingleUpload()
const { data: groupsData } = useGetUserGroups()
const [avatarFile, setAvatarFile] = useState<File[]>([])
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState('')
const groupOptions = useMemo(
() =>
(groupsData?.data || []).map((group) => ({
value: group.id,
label: group.name,
})),
[groupsData],
)
useEffect(() => {
if (avatarFile.length > 0) {
const url = URL.createObjectURL(avatarFile[0])
setAvatarPreviewUrl(url)
return () => URL.revokeObjectURL(url)
}
setAvatarPreviewUrl('')
}, [avatarFile])
const formik = useFormik<CreateCustomerFormValues>({
initialValues: {
phone: '',
firstName: '',
lastName: '',
birthDate: '',
marriageDate: '',
gender: '',
groupIds: [],
},
validationSchema: Yup.object().shape({
phone: Yup.string().required('شماره تلفن الزامی است'),
firstName: Yup.string().required('نام الزامی است'),
lastName: Yup.string().required('نام خانوادگی الزامی است'),
birthDate: Yup.string(),
marriageDate: Yup.string(),
gender: Yup.string(),
groupIds: Yup.array().of(Yup.string()),
}),
onSubmit: (values) => {
const submitCustomer = (avatarUrl?: string) => {
createUser(buildCreateUserPayload(values, avatarUrl), {
onSuccess: (response) => {
toast.success('مشتری با موفقیت ایجاد شد')
const userId = response.data.user.id
navigate(Pages.customers.detail + userId)
},
onError: (error: ErrorType) => {
toast.error(extractErrorMessage(error))
},
})
}
if (avatarFile.length > 0) {
singleUpload(avatarFile[0], {
onSuccess: (response) => {
const avatarUrl = response?.data?.url || ''
submitCustomer(avatarUrl)
},
onError: (error: ErrorType) => {
toast.error(extractErrorMessage(error, 'خطا در آپلود تصویر'))
},
})
} else {
submitCustomer()
}
},
})
return ( return (
<div className='mt-5'> <div className='mt-5'>
<div className='flex justify-between items-center'> <div className='flex justify-between items-center'>
<h1 className='text-lg font-light'>افزودن مشتری</h1> <h1 className='text-lg font-light'>افزودن مشتری</h1>
<Button className='w-fit px-6'> <Button
className='w-fit px-6'
isloading={isPending || isUploading}
onClick={() => formik.handleSubmit()}
>
<div className='flex gap-2 items-center'> <div className='flex gap-2 items-center'>
<TickCircle <TickCircle size={20} color='#fff' />
size={20}
color='#fff'
/>
<span>ثبت مشتری</span> <span>ثبت مشتری</span>
</div> </div>
</Button> </Button>
</div> </div>
<div className='flex gap-7 mt-9'> <div className='mt-9'>
<div className='flex-1'> <div className='w-full bg-white rounded-4xl p-8'>
<div className='w-full bg-white rounded-4xl p-8'> <div className='text-lg font-light'>اطلاعات مشتری</div>
<div className='text-lg font-light'>
اطلاعات مشتری
</div>
<div className='grid grid-cols-2 gap-4 mt-6'> <div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-6'>
<Input <Input
label='نام' label='شماره تماس'
placeholder='' placeholder='۰۹۱۲۱۲۳۴۵۶۷'
name='name' name='phone'
/> value={formik.values.phone}
<Input onChange={formik.handleChange}
label='کد ملی' error_text={
placeholder='' formik.touched.phone && formik.errors.phone
name='nationalCode' ? formik.errors.phone
/> : ''
</div> }
/>
<Select
label='جنسیت'
placeholder='انتخاب'
name='gender'
value={formik.values.gender}
onChange={formik.handleChange}
isNotRequired
items={[
{ value: 'true', label: 'مرد' },
{ value: 'false', label: 'زن' },
]}
/>
</div>
<div className='grid grid-cols-2 gap-4 mt-5'> <div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-5'>
<Input <Input
label='شماره تماس' label='نام'
placeholder='' placeholder=''
name='phone' name='firstName'
/> value={formik.values.firstName}
<Input onChange={formik.handleChange}
label='ایمیل' error_text={
placeholder='' formik.touched.firstName && formik.errors.firstName
name='email' ? formik.errors.firstName
type='email' : ''
/> }
</div> />
<Input
label='نام خانوادگی'
placeholder=''
name='lastName'
value={formik.values.lastName}
onChange={formik.handleChange}
error_text={
formik.touched.lastName && formik.errors.lastName
? formik.errors.lastName
: ''
}
/>
</div>
<div className='grid grid-cols-2 gap-4 mt-5'> <div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-5'>
<div> <DatePicker
<DatePicker label='تاریخ تولد'
label='تاریخ تولد' placeholder='انتخاب'
placeholder='انتخاب' isNotRequired
onChange={() => { }} 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='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'>
<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>
<DatePicker )}
label='تاریخ ازدواج'
placeholder='انتخاب'
onChange={() => { }}
/>
</div>
</div>
<div className='w-full bg-white rounded-4xl p-8 mt-8'>
<div className='text-lg font-light'>
آدرس
</div>
<div className='grid grid-cols-2 gap-4 mt-6'>
<Select
label='استان'
placeholder='انتخاب'
items={[]}
/>
<Select
label='شهر'
placeholder='انتخاب'
items={[]}
/>
</div>
<div className='mt-5'>
<Input
label='عنوان آدرس'
placeholder=''
name='addressTitle'
/>
</div>
<div className='mt-5'>
<Textarea
label='آدرس'
placeholder=''
name='address'
/>
</div>
</div>
</div>
<div className='w-[330px]'>
<div className='bg-white rounded-4xl p-8'>
<CustomerStats points={0} walletBalance='۰ تومان' />
<Button className='w-full mt-5'>
<div className='flex gap-2 items-center'>
<Wallet2 size={16} color='#fff' />
<span>افزایش موجودی کیف پول</span>
</div>
</Button>
</div>
<div className='mt-6 bg-white rounded-4xl p-8'>
<div className='font-light'>
کدهای تخفیف
</div>
<div className='mt-4'>
<table className='w-full'>
<thead>
<tr className='border-b border-border'>
<th className='text-right pb-2 font-light text-xs text-description'>
کد
</th>
<th className='text-center pb-2 font-light text-xs text-description'>
انقضا
</th>
<th className='text-center pb-2 font-light text-xs text-description'>
وضعیت
</th>
</tr>
</thead>
</table>
<div className='text-center py-8 text-xs text-description'>
کد تخفیفی موجود نیست
</div>
<Button className='w-full'>
<div className='flex gap-2 items-center'>
<TicketDiscount size={16} color='#fff' />
<span>اضافه کردن کد تخفیف</span>
</div>
</Button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -157,4 +252,4 @@ const CreateCustomer: FC = () => {
) )
} }
export default CreateCustomer export default CreateCustomer
+1
View File
@@ -0,0 +1 @@
export { default } from "./groups/List";
+143 -143
View File
@@ -1,158 +1,158 @@
import Table from '@/components/Table' import Button from "@/components/Button";
import Filters, { type FieldType } from '@/components/Filters' import Filters, { type FieldType } from "@/components/Filters";
import Button from '@/components/Button' import Table from "@/components/Table";
import { type FC, useMemo, useState } from 'react' import { Pages } from "@/config/Pages";
import { useTranslation } from 'react-i18next' import { formatFaNumber, formatOptionalDate } from "@/helpers/func";
import { DocumentDownload, DocumentUpload } from 'iconsax-react' import type { CustomerListRow, UserRestaurant } from "@/pages/customers/types/Types";
import { useGetUsers } from './hooks/useUsersData' import { DocumentDownload, DocumentUpload, Edit } from "iconsax-react";
import { useCustomerFilters } from './hooks/useCustomerFilters' import { type FC, useMemo, useState } from "react";
import ImportCustomersModal from './components/ImportCustomersModal' import { useTranslation } from "react-i18next";
import { downloadCustomerImportSampleExcel } from './utils/downloadCustomerImportSampleExcel' import { useNavigate } from "react-router-dom";
import type { CustomerListRow, UserRestaurant } from '@/pages/customers/types/Types' import ImportCustomersModal from "./components/ImportCustomersModal";
import { formatOptionalDate, formatFaNumber } from '@/helpers/func' import { useCustomerFilters } from "./hooks/useCustomerFilters";
import { useGetUsers } from "./hooks/useUsersData";
import { downloadCustomerImportSampleExcel } from "./utils/downloadCustomerImportSampleExcel";
const mapUserRestaurantToRow = (userRestaurant: UserRestaurant): CustomerListRow => { const mapUserRestaurantToRow = (userRestaurant: UserRestaurant): CustomerListRow => {
const { user, orderCount, totalOrderAmount } = userRestaurant const { user, orderCount, totalOrderAmount } = userRestaurant;
const fullName = [user.firstName, user.lastName].filter(Boolean).join(' ').trim() const fullName = [user.firstName, user.lastName].filter(Boolean).join(" ").trim();
return { return {
id: user.id, id: user.id,
fullName: fullName || 'بدون نام', fullName: fullName || "بدون نام",
phone: user.phone || '-', phone: user.phone || "-",
birthDate: formatOptionalDate(user.birthDate), birthDate: formatOptionalDate(user.birthDate),
marriageDate: formatOptionalDate(user.marriageDate), marriageDate: formatOptionalDate(user.marriageDate),
orderCount: formatFaNumber(orderCount), orderCount: formatFaNumber(orderCount),
totalOrderAmount: formatFaNumber(totalOrderAmount), totalOrderAmount: formatFaNumber(totalOrderAmount),
status: user.isActive !== false ? 'فعال' : 'غیرفعال', status: user.isActive !== false ? "فعال" : "غیرفعال",
} firstName: user.firstName,
} lastName: user.lastName,
birthDateRaw: user.birthDate,
marriageDateRaw: user.marriageDate,
gender: user.gender,
avatarUrl: user.avatarUrl,
};
};
const CustomersList: FC = () => { const CustomersList: FC = () => {
const { t } = useTranslation('global') const { t } = useTranslation("global");
const [isImportModalOpen, setIsImportModalOpen] = useState(false) const navigate = useNavigate();
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const { const { filters, currentPage, apiParams, handleFiltersChange, handlePageChange } = useCustomerFilters();
filters,
currentPage,
apiParams,
handleFiltersChange,
handlePageChange,
} = useCustomerFilters()
const { data: usersData, isLoading } = useGetUsers(apiParams) const { data: usersData, isLoading } = useGetUsers(apiParams);
const filterFields = useMemo<FieldType[]>(() => [ const filterFields = useMemo<FieldType[]>(
{ () => [
type: 'input', {
name: 'search', type: "input",
placeholder: 'نام یا شماره تماس', name: "search",
}, placeholder: "نام یا شماره تماس",
], []) },
],
[],
);
const userRestaurants = usersData?.data ?? [] const userRestaurants = usersData?.data ?? [];
const tableData = useMemo<CustomerListRow[]>( const tableData = useMemo<CustomerListRow[]>(() => userRestaurants.map(mapUserRestaurantToRow), [userRestaurants]);
() => userRestaurants.map(mapUserRestaurantToRow),
[userRestaurants],
)
const totalPages = usersData?.meta?.totalPages || 1 const totalPages = usersData?.meta?.totalPages || 1;
const columns = useMemo(() => [ const columns = useMemo(
{ () => [
key: 'fullName', {
title: 'نام و نام خانوادگی', key: "fullName",
}, title: "نام و نام خانوادگی",
{ },
key: 'phone', {
title: 'شماره تماس', key: "phone",
}, title: "شماره تماس",
{ },
key: 'birthDate', {
title: 'تاریخ تولد', key: "birthDate",
}, title: "تاریخ تولد",
{ },
key: 'marriageDate', {
title: 'تاریخ ازدواج', key: "marriageDate",
}, title: "تاریخ ازدواج",
{ },
key: 'orderCount', {
title: 'تعداد سفارش', key: "orderCount",
}, title: "تعداد سفارش",
{ },
key: 'totalOrderAmount', {
title: 'مجموع سفارش (تومان)', key: "totalOrderAmount",
}, title: "مجموع سفارش (تومان)",
{ },
key: 'status', {
title: 'وضعیت', key: "status",
render: (item: CustomerListRow) => ( title: "وضعیت",
<span render: (item: CustomerListRow) => (
className={`px-3 py-1 rounded-full text-xs ${item.status === 'فعال' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-600'}`} <span className={`px-3 py-1 rounded-full text-xs ${item.status === "فعال" ? "bg-green-100 text-green-700" : "bg-red-100 text-red-600"}`}>{item.status}</span>
> ),
{item.status} },
</span> {
), key: "details",
}, title: "عملیات",
{ render: (item: CustomerListRow) => (
key: 'details', <button
title: 'جزئیات', type="button"
}, className="text-primary text-xs flex items-center gap-1"
], []) onClick={(e) => {
e.stopPropagation();
navigate(Pages.customers.update + item.id, {
state: { customer: item },
});
}}
>
<Edit size={18} color="#0047FF" />
</button>
),
},
],
[navigate],
);
return ( return (
<div className='mt-5'> <div className="mt-5">
<div className='flex flex-wrap justify-between items-center gap-3'> <div className="flex flex-wrap justify-between items-center gap-3">
<h1 className='text-lg font-light'>{t('customer.customer_list')}</h1> <h1 className="text-lg font-light">{t("customer.customer_list")}</h1>
<div className='flex flex-wrap gap-2'> <div className="flex flex-wrap gap-2">
<Button <Button type="button" className="w-fit px-4 bg-gray-100 text-gray-700" onClick={downloadCustomerImportSampleExcel}>
type='button' <div className="flex items-center gap-2">
className='w-fit px-4 bg-gray-100 text-gray-700' <DocumentDownload size={18} color="#374151" />
onClick={downloadCustomerImportSampleExcel} <span>{t("customer.download_sample")}</span>
>
<div className='flex items-center gap-2'>
<DocumentDownload size={18} color='#374151' />
<span>{t('customer.download_sample')}</span>
</div>
</Button>
<Button
type='button'
className='w-fit px-4'
onClick={() => setIsImportModalOpen(true)}
>
<div className='flex items-center gap-2'>
<DocumentUpload size={18} color='#fff' />
<span>{t('customer.import_excel')}</span>
</div>
</Button>
</div>
</div> </div>
</Button>
<div className='mt-8'> <Button type="button" className="w-fit px-4" onClick={() => setIsImportModalOpen(true)}>
<Filters <div className="flex items-center gap-2">
fields={filterFields} <DocumentUpload size={18} color="#fff" />
onChange={handleFiltersChange} <span>{t("customer.import_excel")}</span>
initialValues={filters}
searchField="search"
/>
</div> </div>
</Button>
<Table
columns={columns}
data={tableData}
isloading={isLoading}
selectable
pagination={{
currentPage,
totalPages,
onPageChange: handlePageChange,
}}
/>
<ImportCustomersModal
open={isImportModalOpen}
onClose={() => setIsImportModalOpen(false)}
/>
</div> </div>
) </div>
}
export default CustomersList <div className="mt-8">
<Filters fields={filterFields} onChange={handleFiltersChange} initialValues={filters} searchField="search" />
</div>
<Table
columns={columns}
data={tableData}
isloading={isLoading}
selectable
pagination={{
currentPage,
totalPages,
onPageChange: handlePageChange,
}}
/>
<ImportCustomersModal open={isImportModalOpen} onClose={() => setIsImportModalOpen(false)} />
</div>
);
};
export default CustomersList;
+258
View File
@@ -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
+152
View File
@@ -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;
+54
View File
@@ -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;
};
+60
View File
@@ -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;
+75
View File
@@ -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;
+98
View File
@@ -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;
};
+29
View File
@@ -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[];
};
+53 -1
View File
@@ -1,6 +1,10 @@
import * as api from "../service/UsersService"; import * as api from "../service/UsersService";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { 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,54 @@ 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 = () => {
return useMutation({
mutationFn: api.searchUserByPhone,
});
};
export const useCreateUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.createUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
};
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 = () => {
return useMutation({
mutationFn: ({
userId,
...params
}: CreateUserAddressType & { userId: string }) =>
api.createUserAddress(userId, params),
});
};
export const useImportUsersFromExcel = () => { export const useImportUsersFromExcel = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -1,8 +1,16 @@
import axios from "@/config/axios"; import axios from "@/config/axios";
import type { import type {
CreateUserAddressResponse,
CreateUserAddressType,
CreateUserResponse,
CreateUserType,
GetCustomersResponse, GetCustomersResponse,
GetUserGroupsForUserResponse,
GetUsersParams, GetUsersParams,
ImportUsersResponse, ImportUsersResponse,
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) => {
@@ -12,6 +20,45 @@ export const getUsers = async (params?: GetUsersParams) => {
return data; return data;
}; };
export const searchUserByPhone = async (phone: string) => {
const { data } = await axios.get<SearchUserByPhoneResponse>(
"/admin/users/search-by-phone",
{ params: { phone } },
);
return data;
};
export const createUser = async (params: CreateUserType) => {
const { data } = await axios.post<CreateUserResponse>("/admin/users", params);
return data;
};
export const 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 (
userId: string,
params: CreateUserAddressType,
) => {
const { data } = await axios.post<CreateUserAddressResponse>(
`/admin/users/${userId}/addresses`,
params,
);
return data;
};
export const importUsersFromExcel = async (file: File) => { export const importUsersFromExcel = async (file: File) => {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
+79
View File
@@ -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;
@@ -14,6 +15,28 @@ export type User = {
referrer?: string | null; referrer?: string | null;
isActive?: boolean; isActive?: boolean;
gender?: boolean; gender?: boolean;
avatarUrl?: string | null;
};
export type UserSavedAddress = {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
user: string;
title: string;
address: string;
city: string;
province: string;
postalCode: string;
latitude: number;
longitude: number;
phone: string;
isDefault: boolean;
};
export type UserWithAddresses = User & {
addresses?: UserSavedAddress[];
}; };
export type UserRestaurant = { export type UserRestaurant = {
@@ -54,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 = {
@@ -72,3 +101,53 @@ export type ImportUsersResult = {
}; };
export type ImportUsersResponse = IResponse<ImportUsersResult>; export type ImportUsersResponse = IResponse<ImportUsersResult>;
export type SearchUserByPhoneResponse = IResponse<UserWithAddresses>;
export type CreateUserType = {
phone: string;
firstName: string;
lastName: string;
birthDate?: string;
marriageDate?: string;
gender?: boolean;
avatarUrl?: string;
groupIds?: string[];
};
export type CreateUserResult = {
id: string;
createdAt: string;
updatedAt: string;
user: User;
orderCount: number;
totalOrderAmount: number;
};
export type CreateUserResponse = IResponse<CreateUserResult>;
export type CreateUserAddressType = {
title: string;
address: string;
postalCode?: string;
latitude: number;
longitude: number;
phone: string;
isDefault: boolean;
};
export type CreateUserAddressResponse = IResponse<UserSavedAddress>;
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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+51 -2
View File
@@ -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
+22
View File
@@ -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,
});
};
+28
View File
@@ -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;
};
+15
View File
@@ -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>;
+94
View File
@@ -0,0 +1,94 @@
import { type FC } from 'react'
import { TickCircle } from 'iconsax-react'
import Button from '@/components/Button'
import { useCreateOrderForm } from './hooks/useCreateOrderForm'
import CreateOrderCustomerSection from './components/CreateOrderCustomerSection'
import CreateOrderItemsSection from './components/CreateOrderItemsSection'
import CreateOrderSidebar from './components/CreateOrderSidebar'
const CreateOrder: FC = () => {
const {
formik,
foods,
deliveryMethods,
cashPaymentMethod,
selectedDeliveryMethod,
selectedPaymentMethod,
isCourierDelivery,
isCarDelivery,
isDineIn,
orderSummary,
addFoodToOrder,
updateItemQuantity,
removeItem,
isSubmitting,
customerAddresses,
selectedAddressId,
handleUserFound,
handleUserNotFound,
clearCustomerData,
applySavedAddress,
selectManualAddress,
handleAddressFieldChange,
handleLocationSelect,
} = useCreateOrderForm()
return (
<div className='flex flex-col h-[calc(100vh-113px)] xl:overflow-hidden overflow-y-auto -mb-20'>
<div className='flex justify-between items-center flex-shrink-0 mb-3'>
<h1 className='text-lg font-light'>ثبت سفارش جدید</h1>
<Button
className='w-fit px-6'
isloading={isSubmitting}
onClick={() => formik.handleSubmit()}
>
<div className='flex gap-2 items-center'>
<TickCircle size={20} color='#fff' />
<span>ثبت سفارش</span>
</div>
</Button>
</div>
<CreateOrderCustomerSection
formik={formik}
onUserFound={handleUserFound}
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
formik={formik}
foods={foods}
onAddFood={addFoodToOrder}
/>
</div>
<div className='min-h-0 h-full flex flex-col overflow-hidden'>
<CreateOrderSidebar
formik={formik}
orderSummary={orderSummary}
deliveryMethods={deliveryMethods}
cashPaymentMethod={cashPaymentMethod}
selectedDeliveryMethod={selectedDeliveryMethod}
selectedPaymentMethod={selectedPaymentMethod}
isCourierDelivery={isCourierDelivery}
isCarDelivery={isCarDelivery}
isDineIn={isDineIn}
customerAddresses={customerAddresses}
selectedAddressId={selectedAddressId}
onSelectSavedAddress={applySavedAddress}
onSelectManualAddress={selectManualAddress}
onAddressFieldChange={handleAddressFieldChange}
onLocationSelect={handleLocationSelect}
onUpdateQuantity={updateItemQuantity}
onRemoveItem={removeItem}
/>
</div>
</div>
</div>
)
}
export default CreateOrder
+16
View File
@@ -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
View File
@@ -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
+16
View File
@@ -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'>
+48 -6
View File
@@ -1,13 +1,20 @@
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 { Link } from 'react-router-dom'
import Table from '@/components/Table' import Table from '@/components/Table'
import Filters from '@/components/Filters' import Filters from '@/components/Filters'
import { useGetOrders } from './hooks/useOrderData' import Button from '@/components/Button'
import { Pages } from '@/config/Pages'
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')
@@ -17,21 +24,47 @@ 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'>
<div className='flex justify-between items-center'> <div className='flex justify-between items-center'>
<h1 className='text-lg font-light'>لیست سفارشات</h1> <h1 className='text-lg font-light'>لیست سفارشات</h1>
<Link to={Pages.orders.add}>
<Button className='w-fit px-6'>
<div className='flex gap-2 items-center'>
<Add color='#fff' size={20} />
<span>ثبت سفارش</span>
</div>
</Button>
</Link>
</div> </div>
<div className='mt-8'> <div className='mt-8'>
@@ -53,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
@@ -0,0 +1,77 @@
import { type FC } from 'react'
import { Location } from 'iconsax-react'
import type { UserSavedAddress } from '@/pages/customers/types/Types'
interface CreateOrderAddressSelectorProps {
addresses: UserSavedAddress[]
selectedAddressId: string | null
onSelectAddress: (address: UserSavedAddress) => void
onSelectManual: () => void
}
const CreateOrderAddressSelector: FC<CreateOrderAddressSelectorProps> = ({
addresses,
selectedAddressId,
onSelectAddress,
onSelectManual,
}) => {
if (addresses.length === 0) {
return null
}
const isManualSelected = selectedAddressId === null
return (
<div className='mb-6'>
<div className='text-sm mb-3'>انتخاب آدرس ذخیرهشده</div>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
{addresses.map((address) => {
const isSelected = selectedAddressId === address.id
return (
<button
key={address.id}
type='button'
onClick={() => onSelectAddress(address)}
className={`text-right p-4 rounded-xl border transition-colors ${
isSelected
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/40'
}`}
>
<div className='flex items-center justify-between gap-2'>
<div className='text-sm font-medium'>{address.title || 'آدرس'}</div>
{address.isDefault && (
<span className='text-[10px] px-2 py-0.5 rounded-full bg-primary/10 text-primary'>
پیشفرض
</span>
)}
</div>
<div className='text-xs mt-2 line-clamp-2'>{address.address}</div>
</button>
)
})}
<button
type='button'
onClick={onSelectManual}
className={`text-right p-4 rounded-xl border transition-colors ${
isManualSelected
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/40'
}`}
>
<div className='flex items-center gap-2 text-sm font-medium'>
<Location size={16} color='#000' />
<span>ورود آدرس جدید</span>
</div>
<div className='text-xs text-description mt-1'>
آدرس را بهصورت دستی وارد کنید
</div>
</button>
</div>
</div>
)
}
export default CreateOrderAddressSelector
@@ -0,0 +1,249 @@
import { type FC, useState } from 'react'
import { Profile, SearchNormal, TickCircle, UserAdd } from 'iconsax-react'
import type { FormikProps } from 'formik'
import { toast } from 'react-toastify'
import { clx } from '@/helpers/utils'
import Button from '@/components/Button'
import DefaulModal from '@/components/DefaulModal'
import Input from '@/components/Input'
import { useSearchUserByPhone } from '@/pages/customers/hooks/useUsersData'
import type { UserWithAddresses } from '@/pages/customers/types/Types'
import type { CreateOrderFormValues } from '../hooks/useCreateOrderForm'
import type { ErrorType } from '@/helpers/types'
import { extractErrorMessage } from '@/config/func'
import { isAxiosError } from 'axios'
interface CreateOrderCustomerSectionProps {
formik: FormikProps<CreateOrderFormValues>
onUserFound: (user: UserWithAddresses) => void
onUserNotFound: () => void
onClearCustomerData: () => void
}
type CustomerSearchState = 'idle' | 'found' | 'not_found'
const CreateOrderCustomerSection: FC<CreateOrderCustomerSectionProps> = ({
formik,
onUserFound,
onUserNotFound,
onClearCustomerData,
}) => {
const { mutate: searchUser, isPending: isSearching } = useSearchUserByPhone()
const [searchState, setSearchState] = useState<CustomerSearchState>('idle')
const [isCustomerModalOpen, setIsCustomerModalOpen] = useState(false)
const handleSearch = () => {
const phone = formik.values.userPhone.trim()
if (!phone) {
toast.error('شماره تلفن را وارد کنید')
return
}
searchUser(phone, {
onSuccess: (response) => {
const user = response?.data
if (!user) {
setSearchState('not_found')
onUserNotFound()
toast.info('مشتری یافت نشد. اطلاعات را وارد کنید.')
return
}
formik.setFieldValue('userPhone', user.phone || phone)
formik.setFieldValue('firstName', user.firstName || '')
formik.setFieldValue('lastName', user.lastName || '')
onUserFound(user)
setSearchState('found')
toast.success('اطلاعات مشتری بارگذاری شد')
},
onError: (error: ErrorType) => {
if (isAxiosError(error) && error.response?.status === 404) {
setSearchState('not_found')
onUserNotFound()
toast.info('مشتری یافت نشد. اطلاعات را وارد کنید.')
return
}
setSearchState('idle')
toast.error(extractErrorMessage(error, 'خطا در جستجوی مشتری'))
},
})
}
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
formik.handleChange(e)
if (searchState !== 'idle') {
setSearchState('idle')
onClearCustomerData()
}
}
const isCustomerLocked = searchState !== 'not_found'
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 (
<>
<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 items-center gap-2'>
<div
className={clx(
'w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 transition-colors',
searchState === 'found' ? 'bg-green-50' : 'bg-[#EEF0F7]',
)}
>
{searchState === 'found' ? (
<TickCircle size={18} color='#16A34A' />
) : searchState === 'not_found' ? (
<UserAdd size={18} color='#8C90A3' />
) : (
<Profile size={18} color='#8C90A3' />
)}
</div>
<div>
<div className='text-sm font-light flex items-center gap-2'>
اطلاعات مشتری
<span className='text-[11px] text-description bg-[#F8F9FC] rounded-full px-2 py-0.5'>
اختیاری
</span>
</div>
{searchState === 'found' && customerName && (
<div className='text-[11px] text-green-600 mt-0.5'>
{customerName}
</div>
)}
{searchState === 'not_found' && (
<div className='text-[11px] text-description mt-0.5'>
مشتری جدید
</div>
)}
{searchState === 'idle' && (
<div className='text-[11px] text-description mt-0.5'>
برای این سفارش وارد کردن اطلاعات مشتری الزامی نیست
</div>
)}
</div>
</div>
<Button
type='button'
className='w-fit px-4 h-10'
onClick={() => setIsCustomerModalOpen(true)}
>
{searchState === 'idle' ? 'افزودن مشتری' : 'ویرایش مشتری'}
</Button>
</div>
</div>
<DefaulModal
open={isCustomerModalOpen}
close={() => setIsCustomerModalOpen(false)}
isHeader={true}
title_header='اطلاعات مشتری'
width={700}
>
<div className='p-2'>
<div className='text-xs text-description mb-4'>
اطلاعات مشتری اختیاری است و در صورت نیاز میتوانید ثبت کنید.
</div>
<div className='grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-[1.2fr_1fr_1fr] gap-3 xl:items-end'>
<div className='w-full'>
<label className='text-xs text-description'>شماره تلفن</label>
<div className='flex mt-1'>
<input
name='userPhone'
placeholder='۰۹۱۲۱۲۳۴۵۶۷'
value={formik.values.userPhone}
onChange={handlePhoneChange}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSearch()
}
}}
className={clx(
'flex-1 min-w-0 h-10 text-xs px-4 border border-border bg-white',
'rounded-r-xl border-l-0 focus:outline-none',
phoneError && 'border-red-400',
)}
/>
<button
type='button'
disabled={isSearching}
onClick={handleSearch}
className={clx(
'h-10 px-4 flex items-center gap-1.5 flex-shrink-0',
'bg-primary text-white text-xs rounded-l-xl',
'hover:opacity-90 transition-opacity disabled:opacity-60',
)}
>
{isSearching ? (
<span className='w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin' />
) : (
<SearchNormal size={16} color='#fff' />
)}
<span className='hidden sm:inline'>جستجو</span>
</button>
</div>
{phoneError && (
<p className='text-[11px] text-red-500 mt-1'>{phoneError}</p>
)}
</div>
<Input
label='نام'
name='firstName'
placeholder={isNameDisabled ? 'پس از جستجو' : 'نام'}
value={formik.values.firstName}
onChange={formik.handleChange}
readOnly={isCustomerLocked}
disabled={isNameDisabled}
className={isNameDisabled ? 'bg-[#F8F9FC] border-0 text-description cursor-not-allowed' : ''}
error_text={
formik.touched.firstName && formik.errors.firstName
? formik.errors.firstName
: ''
}
/>
<Input
label='نام خانوادگی'
name='lastName'
placeholder={isNameDisabled ? 'پس از جستجو' : 'نام خانوادگی'}
value={formik.values.lastName}
onChange={formik.handleChange}
readOnly={isCustomerLocked}
disabled={isNameDisabled}
className={isNameDisabled ? 'bg-[#F8F9FC] border-0 text-description cursor-not-allowed' : ''}
error_text={
formik.touched.lastName && formik.errors.lastName
? formik.errors.lastName
: ''
}
/>
</div>
<div className='mt-5 flex justify-end'>
<Button
type='button'
className='w-fit px-6'
onClick={() => setIsCustomerModalOpen(false)}
>
تایید
</Button>
</div>
</div>
</DefaulModal>
</>
)
}
export default CreateOrderCustomerSection
@@ -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
@@ -0,0 +1,151 @@
import { type FC, useMemo, useState } from 'react'
import type { FormikProps } from 'formik'
import { Add } from 'iconsax-react'
import Input from '@/components/Input'
import type { Food } from '@/pages/food/types/Types'
import { formatPrice } from '@/helpers/func'
import { getFoodUnitPrice } from '../hooks/useCreateOrderForm'
import type { CreateOrderFormValues } from '../hooks/useCreateOrderForm'
interface CreateOrderItemsSectionProps {
formik: FormikProps<CreateOrderFormValues>
foods: Food[]
onAddFood: (foodId: string) => void
}
const CreateOrderItemsSection: FC<CreateOrderItemsSectionProps> = ({
formik,
foods,
onAddFood,
}) => {
const [search, setSearch] = useState('')
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null)
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])
return (
<div className='w-full h-full bg-white rounded-4xl p-5 flex flex-col min-h-0'>
<div className='text-sm font-light flex-shrink-0'>انتخاب غذا</div>
<div className='mt-3 flex-1 min-h-0 flex flex-col border border-border rounded-xl p-3'>
<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-2 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 inCart = formik.values.items.some(
(item) => item.foodId === food.id
)
const cartQty = formik.values.items.find(
(item) => item.foodId === food.id
)?.quantity
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'
onClick={() => onAddFood(food.id)}
className={`flex items-center gap-1 px-2.5 h-7 rounded-lg text-[11px] transition-colors flex-shrink-0 ${
inCart
? 'bg-primary/10 text-primary'
: 'bg-primary text-white'
}`}
>
<Add size={12} color={inCart ? '#000' : '#fff'} />
{inCart ? `×${cartQty}` : 'انتخاب'}
</button>
</div>
)
})
)}
</div>
</div>
{formik.touched.items && formik.errors.items && typeof formik.errors.items === 'string' && (
<div className='text-xs text-red-500 mt-2 flex-shrink-0'>{formik.errors.items}</div>
)}
</div>
)
}
export default CreateOrderItemsSection
@@ -0,0 +1,421 @@
import { formatFaNumber, formatPrice } from '@/helpers/func'
import { PaymentMethodEnum } from '@/pages/paymentMethods/enum/Enum'
import { DeliveryMethodEnum } from '@/pages/shipmentMethod/enum/Enum'
import type { FormikProps } from 'formik'
import {
Add,
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 {
lineItems: Array<{
foodId: string
title: string
image: string
quantity: number
unitPrice: number
totalPrice: number
} | null>
subTotal: number
deliveryFee: number
packingFee: number
discountAmount: number
total: number
totalItems: number
}
interface CreateOrderSidebarProps {
formik: FormikProps<CreateOrderFormValues>
orderSummary: OrderSummaryData
deliveryMethods: RestaurantShipmentMethod[]
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 methodMap: Record<string, string> = {
[DeliveryMethodEnum.DineIn]: 'سرو در رستوران',
[DeliveryMethodEnum.CustomerPickup]: 'دریافت از محل',
[DeliveryMethodEnum.DeliveryCar]: 'تحویل به خودرو',
[DeliveryMethodEnum.DeliveryCourier]: 'بیرون‌بر',
}
return method ? methodMap[method] || method : 'انتخاب نشده'
}
const getPaymentMethodText = (method?: string): string => {
const methodMap: Record<string, string> = {
[PaymentMethodEnum.Cash]: 'پرداخت نقدی',
[PaymentMethodEnum.Online]: 'پرداخت آنلاین',
[PaymentMethodEnum.CreditCard]: 'کارت به کارت',
[PaymentMethodEnum.Wallet]: 'کیف پول',
}
return method ? methodMap[method] || method : 'انتخاب نشده'
}
const CreateOrderSidebar: FC<CreateOrderSidebarProps> = ({
formik,
orderSummary,
deliveryMethods,
cashPaymentMethod,
selectedDeliveryMethod,
selectedPaymentMethod,
isCourierDelivery,
isCarDelivery,
isDineIn,
customerAddresses,
selectedAddressId,
onSelectSavedAddress,
onSelectManualAddress,
onAddressFieldChange,
onLocationSelect,
onUpdateQuantity,
onRemoveItem,
}) => {
const [isDeliveryModalOpen, setIsDeliveryModalOpen] = useState(false)
const customerName = [formik.values.firstName, formik.values.lastName].filter(Boolean).join(' ')
const deliveryText = getDeliveryMethodText(selectedDeliveryMethod?.method)
const paymentText = getPaymentMethodText(selectedPaymentMethod?.method)
const deliveryFeeText =
selectedDeliveryMethod && selectedDeliveryMethod.deliveryFee > 0
? formatPrice(selectedDeliveryMethod.deliveryFee)
: null
const getDeliveryExtraInfo = (): { text: string; warning?: boolean } | null => {
if (isCourierDelivery) {
const address = formik.values.userAddress.address?.trim()
if (!address) return { text: 'آدرس تحویل ثبت نشده', warning: true }
return { text: address }
}
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
}
const extraInfo = getDeliveryExtraInfo()
const isDeliverySelected = Boolean(selectedDeliveryMethod)
const isPaymentSelected = Boolean(selectedPaymentMethod)
return (
<>
<div className='bg-white rounded-4xl p-4 flex-1 min-h-0 flex flex-col h-full'>
<div className='flex items-center justify-between flex-shrink-0'>
<div className='flex items-center gap-2 text-sm font-light'>
<ShoppingCart size={18} color='#000' />
خلاصه سفارش
</div>
{orderSummary.totalItems > 0 && (
<span className='text-[11px] text-description bg-[#F8F9FC] rounded-full px-2.5 py-0.5'>
{formatFaNumber(orderSummary.totalItems)} آیتم
</span>
)}
</div>
{(customerName || formik.values.userPhone) && (
<div className='mt-2 p-2.5 bg-[#F8F9FC] rounded-xl flex-shrink-0'>
<div className='text-[11px] font-medium truncate'>
{customerName || 'مشتری'}
</div>
{formik.values.userPhone && (
<div className='text-[10px] text-description mt-0.5'>
{formik.values.userPhone}
</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>
<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
+9 -48
View File
@@ -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 && (
+142 -107
View File
@@ -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 },
+105 -17
View File
@@ -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
+184 -100
View File
@@ -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>
);
},
},
];
};
+99 -82
View File
@@ -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 -3
View File
@@ -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",

Some files were not shown because too many files have changed in this diff Show More