Compare commits

...

28 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
87 changed files with 5205 additions and 1536 deletions
+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.
+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:
+11 -13
View File
@@ -1,4 +1,4 @@
import { type FC, type InputHTMLAttributes } from 'react' import { type FC, type TextareaHTMLAttributes } from 'react'
import Error from './Error' import Error from './Error'
import { clx } from '../helpers/utils' import { clx } from '../helpers/utils'
@@ -6,34 +6,32 @@ type Props = {
label?: string, label?: string,
error_text?: string error_text?: string
isNotRequired?: boolean isNotRequired?: boolean
} & InputHTMLAttributes<HTMLTextAreaElement> } & TextareaHTMLAttributes<HTMLTextAreaElement>
const Textarea: FC<Props> = (props: Props) => { const Textarea: FC<Props> = ({ label, error_text, isNotRequired, className, ...textareaProps }) => {
return ( return (
<div className='w-full relative'> <div className='w-full relative'>
{ {
props.label && label &&
<div className='flex items-center gap-1'> <div className='flex items-center gap-1'>
<div className='text-sm'>{props.label}</div> <div className='text-sm'>{label}</div>
{props.isNotRequired && ( {isNotRequired && (
<span className='text-xs text-description'>(اختیاری)</span> <span className='text-xs text-description'>(اختیاری)</span>
)} )}
</div> </div>
} }
<textarea <textarea
{...props} {...textareaProps}
className={clx( className={clx(
'border border-border leading-7 w-full rounded-xl px-4 py-3 min-h-[100px] mt-1 text-xs', 'border border-border leading-7 w-full rounded-xl px-4 py-3 min-h-[100px] mt-1 text-xs',
props.className className
)} )}
> />
</textarea>
{ {
props.error_text && error_text &&
<Error errorText={props.error_text} /> <Error errorText={error_text} />
} }
</div> </div>
+4
View File
@@ -27,6 +27,7 @@ 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",
@@ -99,4 +100,7 @@ export const Pages = {
wallet: { wallet: {
transactions: "/wallet/transactions", transactions: "/wallet/transactions",
}, },
aiChats: {
list: "/ai-chats/list",
},
}; };
+37 -4
View File
@@ -69,7 +69,8 @@
"pagers": "پیجرها", "pagers": "پیجرها",
"notifications": "تنظیمات اعلانات", "notifications": "تنظیمات اعلانات",
"statistics": "گزارشات", "statistics": "گزارشات",
"sliders": "اسلایدرها" "sliders": "اسلایدرها",
"ai_chats": "چت‌های AI"
}, },
"shipment": { "shipment": {
"dineIn": "سرو در رستوران", "dineIn": "سرو در رستوران",
@@ -100,6 +101,7 @@
"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": "افزودن تخفیف",
@@ -889,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": "لغو شده",
@@ -929,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": "بسته"
}
} }
} }
+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;
+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;
}
+32 -1
View File
@@ -1,4 +1,4 @@
import { type FC, useEffect, useState } from 'react' import { type FC, useEffect, useMemo, useState } from 'react'
import { useFormik } from 'formik' import { useFormik } from 'formik'
import * as Yup from 'yup' import * as Yup from 'yup'
import { TickCircle } from 'iconsax-react' import { TickCircle } from 'iconsax-react'
@@ -6,11 +6,13 @@ import { toast } from 'react-toastify'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import Button from '@/components/Button' import Button from '@/components/Button'
import Input from '@/components/Input' import Input from '@/components/Input'
import MultiSelect from '@/components/MultiSelect'
import Select from '@/components/Select' import Select from '@/components/Select'
import DatePicker from '@/components/DatePicker' import DatePicker from '@/components/DatePicker'
import UploadBox from '@/components/UploadBox' import UploadBox from '@/components/UploadBox'
import { useCreateUser } from './hooks/useUsersData' import { useCreateUser } from './hooks/useUsersData'
import { useSingleUpload } from '@/pages/uploader/hooks/useUploaderData' import { useSingleUpload } from '@/pages/uploader/hooks/useUploaderData'
import { useGetUserGroups } from '@/pages/customers/groups/hooks/useUserGroupData'
import type { CreateUserType } from './types/Types' import type { CreateUserType } from './types/Types'
import { Pages } from '@/config/Pages' import { Pages } from '@/config/Pages'
import type { ErrorType } from '@/helpers/types' import type { ErrorType } from '@/helpers/types'
@@ -23,6 +25,7 @@ type CreateCustomerFormValues = {
birthDate: string birthDate: string
marriageDate: string marriageDate: string
gender: string gender: string
groupIds: string[]
} }
const buildCreateUserPayload = ( const buildCreateUserPayload = (
@@ -39,6 +42,7 @@ const buildCreateUserPayload = (
if (values.marriageDate) payload.marriageDate = values.marriageDate if (values.marriageDate) payload.marriageDate = values.marriageDate
if (values.gender !== '') payload.gender = values.gender === 'true' if (values.gender !== '') payload.gender = values.gender === 'true'
if (avatarUrl) payload.avatarUrl = avatarUrl if (avatarUrl) payload.avatarUrl = avatarUrl
if (values.groupIds.length > 0) payload.groupIds = values.groupIds
return payload return payload
} }
@@ -47,9 +51,19 @@ const CreateCustomer: FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { mutate: createUser, isPending } = useCreateUser() const { mutate: createUser, isPending } = useCreateUser()
const { mutate: singleUpload, isPending: isUploading } = useSingleUpload() const { mutate: singleUpload, isPending: isUploading } = useSingleUpload()
const { data: groupsData } = useGetUserGroups()
const [avatarFile, setAvatarFile] = useState<File[]>([]) const [avatarFile, setAvatarFile] = useState<File[]>([])
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState('') const [avatarPreviewUrl, setAvatarPreviewUrl] = useState('')
const groupOptions = useMemo(
() =>
(groupsData?.data || []).map((group) => ({
value: group.id,
label: group.name,
})),
[groupsData],
)
useEffect(() => { useEffect(() => {
if (avatarFile.length > 0) { if (avatarFile.length > 0) {
const url = URL.createObjectURL(avatarFile[0]) const url = URL.createObjectURL(avatarFile[0])
@@ -67,6 +81,7 @@ const CreateCustomer: FC = () => {
birthDate: '', birthDate: '',
marriageDate: '', marriageDate: '',
gender: '', gender: '',
groupIds: [],
}, },
validationSchema: Yup.object().shape({ validationSchema: Yup.object().shape({
phone: Yup.string().required('شماره تلفن الزامی است'), phone: Yup.string().required('شماره تلفن الزامی است'),
@@ -75,6 +90,7 @@ const CreateCustomer: FC = () => {
birthDate: Yup.string(), birthDate: Yup.string(),
marriageDate: Yup.string(), marriageDate: Yup.string(),
gender: Yup.string(), gender: Yup.string(),
groupIds: Yup.array().of(Yup.string()),
}), }),
onSubmit: (values) => { onSubmit: (values) => {
const submitCustomer = (avatarUrl?: string) => { const submitCustomer = (avatarUrl?: string) => {
@@ -197,6 +213,21 @@ const CreateCustomer: FC = () => {
/> />
</div> </div>
<div className='mt-5'>
<MultiSelect
label='گروه‌ها'
items={groupOptions}
value={formik.values.groupIds}
onChange={(values) => formik.setFieldValue('groupIds', values)}
placeholder='انتخاب گروه‌ها'
error_text={
formik.touched.groupIds && formik.errors.groupIds
? String(formik.errors.groupIds)
: ''
}
/>
</div>
<div className='mt-5'> <div className='mt-5'>
<UploadBox <UploadBox
label='تصویر پروفایل' label='تصویر پروفایل'
+47 -4
View File
@@ -1,15 +1,17 @@
import { type FC, useEffect, useState } from 'react' import { type FC, useEffect, useMemo, useState } from 'react'
import { useFormik } from 'formik' import { useFormik } from 'formik'
import { TickCircle } from 'iconsax-react' import { TickCircle } from 'iconsax-react'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { useLocation, useNavigate, useParams } from 'react-router-dom' import { useLocation, useNavigate, useParams } from 'react-router-dom'
import Button from '@/components/Button' import Button from '@/components/Button'
import Input from '@/components/Input' import Input from '@/components/Input'
import MultiSelect from '@/components/MultiSelect'
import Select from '@/components/Select' import Select from '@/components/Select'
import DatePicker from '@/components/DatePicker' import DatePicker from '@/components/DatePicker'
import UploadBox from '@/components/UploadBox' import UploadBox from '@/components/UploadBox'
import { useSingleUpload } from '@/pages/uploader/hooks/useUploaderData' import { useSingleUpload } from '@/pages/uploader/hooks/useUploaderData'
import { useUpdateUser } from './hooks/useUsersData' import { useGetUserGroupsForUser, useUpdateUser } from './hooks/useUsersData'
import { useGetUserGroups } from '@/pages/customers/groups/hooks/useUserGroupData'
import type { CustomerListRow, UpdateUserType } from './types/Types' import type { CustomerListRow, UpdateUserType } from './types/Types'
import { Pages } from '@/config/Pages' import { Pages } from '@/config/Pages'
import type { ErrorType } from '@/helpers/types' import type { ErrorType } from '@/helpers/types'
@@ -21,6 +23,7 @@ type UpdateCustomerFormValues = {
birthDate: string birthDate: string
marriageDate: string marriageDate: string
gender: string gender: string
groupIds: string[]
} }
type CustomerUpdateLocationState = { type CustomerUpdateLocationState = {
@@ -33,11 +36,27 @@ const UpdateCustomer: FC = () => {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const { mutate: updateUser, isPending: isUpdating } = useUpdateUser() const { mutate: updateUser, isPending: isUpdating } = useUpdateUser()
const { mutate: singleUpload, isPending: isUploading } = useSingleUpload() const { mutate: singleUpload, isPending: isUploading } = useSingleUpload()
const { data: userGroupsData } = useGetUserGroupsForUser(id || '')
const { data: allGroupsData } = useGetUserGroups()
const [avatarFile, setAvatarFile] = useState<File[]>([]) const [avatarFile, setAvatarFile] = useState<File[]>([])
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState('') const [avatarPreviewUrl, setAvatarPreviewUrl] = useState('')
const customer = (location.state as CustomerUpdateLocationState | null)?.customer 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(() => { useEffect(() => {
if (!customer?.avatarUrl) { if (!customer?.avatarUrl) {
setAvatarPreviewUrl('') setAvatarPreviewUrl('')
@@ -64,6 +83,7 @@ const UpdateCustomer: FC = () => {
customer?.gender === undefined || customer?.gender === null customer?.gender === undefined || customer?.gender === null
? '' ? ''
: String(customer.gender), : String(customer.gender),
groupIds: initialGroupIds,
}, },
enableReinitialize: true, enableReinitialize: true,
onSubmit: (values) => { onSubmit: (values) => {
@@ -73,7 +93,9 @@ const UpdateCustomer: FC = () => {
} }
const submitUpdate = (avatarUrl?: string) => { const submitUpdate = (avatarUrl?: string) => {
const payload: UpdateUserType = {} const payload: UpdateUserType = {
groupIds: values.groupIds,
}
if (values.firstName.trim()) payload.firstName = values.firstName.trim() if (values.firstName.trim()) payload.firstName = values.firstName.trim()
if (values.lastName.trim()) payload.lastName = values.lastName.trim() if (values.lastName.trim()) payload.lastName = values.lastName.trim()
@@ -82,7 +104,18 @@ const UpdateCustomer: FC = () => {
if (values.gender !== '') payload.gender = values.gender === 'true' if (values.gender !== '') payload.gender = values.gender === 'true'
if (avatarUrl) payload.avatarUrl = avatarUrl if (avatarUrl) payload.avatarUrl = avatarUrl
if (Object.keys(payload).length === 0) { 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('حداقل یک فیلد را برای ویرایش وارد کنید') toast.info('حداقل یک فیلد را برای ویرایش وارد کنید')
return return
} }
@@ -188,6 +221,16 @@ const UpdateCustomer: FC = () => {
/> />
</div> </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'> <div className='mt-5'>
<UploadBox <UploadBox
label='تصویر پروفایل' label='تصویر پروفایل'
@@ -1,11 +1,18 @@
import Status from "@/components/Status";
import type { ColumnType } from "@/components/types/TableTypes"; import type { ColumnType } from "@/components/types/TableTypes";
import { formatOptionalDate } from "@/helpers/func"; import { formatFaNumber, formatOptionalDate, formatPrice } from "@/helpers/func";
import type { Campaign } from "../types/Types"; import type { Campaign } from "../types/Types";
const getGroupNames = (groups: Campaign["groups"]) => { const getGroupNames = (groups: Campaign["groups"]) => {
if (!groups?.length) return "-"; if (!groups?.length) return "-";
return groups.map((group) => (typeof group === "string" ? group : group.name)).join(" ، "); 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> = { const sentTypeLabels: Record<string, string> = {
@@ -13,6 +20,25 @@ const sentTypeLabels: Record<string, string> = {
sms: "پیامک", 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>[] => { export const getCampaignTableColumns = (): ColumnType<Campaign>[] => {
return [ return [
{ {
@@ -40,15 +66,49 @@ export const getCampaignTableColumns = (): ColumnType<Campaign>[] => {
title: "گروه‌ها", title: "گروه‌ها",
render: (item: Campaign) => getGroupNames(item.groups), 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", key: "createdAt",
title: "تاریخ ایجاد", title: "تاریخ ایجاد",
render: (item: Campaign) => formatOptionalDate(item.createdAt), render: (item: Campaign) =>
formatOptionalDate(item.createdAt, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}),
}, },
// {
// key: "actions",
// title: "",
// render: (item: Campaign) => (onDelete ? <TrashWithConfrim onDelete={() => onDelete(item.id)} isloading={isDeleting} /> : null),
// },
]; ];
}; };
+16 -2
View File
@@ -3,16 +3,30 @@ import type { IResponse } from "@/types/response.types";
export type CampaignSentType = "push" | "sms"; export type CampaignSentType = "push" | "sms";
export type CampaignStatus = "pending" | "processing" | "completed" | "failed" | "partial";
export type CampaignGroup = UserGroup & {
restaurant?: string;
count?: number;
};
export type Campaign = { export type Campaign = {
id: string; id: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
deletedAt: string | null; deletedAt: string | null;
restaurant?: string;
title: string; title: string;
groups: UserGroup[] | string[]; groups: CampaignGroup[] | string[];
sentType: CampaignSentType; sentType: CampaignSentType;
discountCode: string; discountCode: string;
ocasion: string; ocasion?: string;
totalCount: number;
totalCost: number;
refunded: number;
sentCount: number;
failedCount: number;
status: CampaignStatus | string;
}; };
export type PaginationMeta = { export type PaginationMeta = {
+10 -1
View File
@@ -13,6 +13,14 @@ export const useGetUsers = (params?: GetUsersParams) => {
}); });
}; };
export const useGetUserGroupsForUser = (userId: string) => {
return useQuery({
queryKey: ["user-groups-for-user", userId],
queryFn: () => api.getUserGroups(userId),
enabled: !!userId,
});
};
export const useSearchUserByPhone = () => { export const useSearchUserByPhone = () => {
return useMutation({ return useMutation({
mutationFn: api.searchUserByPhone, mutationFn: api.searchUserByPhone,
@@ -36,8 +44,9 @@ export const useUpdateUser = () => {
return useMutation({ return useMutation({
mutationFn: ({ userId, params }: { userId: string; params: UpdateUserType }) => mutationFn: ({ userId, params }: { userId: string; params: UpdateUserType }) =>
api.updateUser(userId, params), api.updateUser(userId, params),
onSuccess: () => { onSuccess: (_, { userId }) => {
queryClient.invalidateQueries({ queryKey: ["users"] }); queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["user-groups-for-user", userId] });
}, },
}); });
}; };
@@ -5,6 +5,7 @@ import type {
CreateUserResponse, CreateUserResponse,
CreateUserType, CreateUserType,
GetCustomersResponse, GetCustomersResponse,
GetUserGroupsForUserResponse,
GetUsersParams, GetUsersParams,
ImportUsersResponse, ImportUsersResponse,
SearchUserByPhoneResponse, SearchUserByPhoneResponse,
@@ -40,6 +41,13 @@ export const updateUser = async (userId: string, params: UpdateUserType) => {
return data; return data;
}; };
export const getUserGroups = async (userId: string) => {
const { data } = await axios.get<GetUserGroupsForUserResponse>(
`/admin/users/${userId}/groups`,
);
return data;
};
export const createUserAddress = async ( export const createUserAddress = async (
userId: string, userId: string,
params: CreateUserAddressType, params: CreateUserAddressType,
+5
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;
@@ -111,6 +112,7 @@ export type CreateUserType = {
marriageDate?: string; marriageDate?: string;
gender?: boolean; gender?: boolean;
avatarUrl?: string; avatarUrl?: string;
groupIds?: string[];
}; };
export type CreateUserResult = { export type CreateUserResult = {
@@ -143,6 +145,9 @@ export type UpdateUserType = {
marriageDate?: string; marriageDate?: string;
gender?: boolean; gender?: boolean;
avatarUrl?: string; avatarUrl?: string;
groupIds?: string[];
}; };
export type UpdateUserResponse = IResponse<User>; 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;
+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
+12
View File
@@ -113,3 +113,15 @@ export const useSetStockForFood = () => {
}, },
}); });
}; };
export const useGenerateFoodDescription = () => {
return useMutation({
mutationFn: api.generateFoodDescription,
});
};
export const useGenerateFoodContent = () => {
return useMutation({
mutationFn: api.generateFoodContent,
});
};
+23
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,
@@ -90,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>;
+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'>
+306 -151
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,109 +89,115 @@ 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
const isCompleted = order.status === OrderStatus.COMPLETED
const isPendingPayment = order.status === OrderStatus.NEW
const canEditFees = !isCanceled && !isCompleted
const canEditItems = !isCanceled && !isCompleted
const canAddPayment = !isCanceled && Number(order.balance ?? 0) > 0
const isUpdatingItem = isAddingItem || isUpdatingItemQuantity || isRemovingItem
const canCompleteOrder = [ const canCompleteOrder = [
OrderStatus.DELIVERED_TO_WAITER, OrderStatus.DELIVERED_TO_WAITER,
OrderStatus.DELIVERED_TO_RECEPTIONIST, OrderStatus.DELIVERED_TO_RECEPTIONIST,
OrderStatus.SHIPPED, OrderStatus.SHIPPED,
].includes(order.status as OrderStatus) ].includes(order.status as OrderStatus)
// بررسی وضعیت پرداخت از payments array یا paymentStatus const canSendPleaseComeSms = Boolean(order.user?.phone)
// ابتدا از paymentStatus استفاده می‌کنیم، اگر نبود از اولین payment در payments array استفاده می‌کنیم
const paymentStatus = order.paymentStatus || order.payments?.[0]?.status
// بررسی وضعیت pending (هم enum و هم string literal)
const isPaymentPending = paymentStatus === PaymentStatusEnum.Pending || paymentStatus === 'pending'
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 payment = order.payments?.[0]
const hasPaymentDescription = Boolean(payment?.description)
const hasPaymentAttachments = Boolean(payment?.attachments && payment.attachments.length > 0)
const showPaymentReceiptBox = isCreditCardPayment && (hasPaymentDescription || hasPaymentAttachments)
const showVerifyPaymentButton = (isCashPayment || isCreditCardPayment) && isPaymentPending && !isCanceled
// نمایش دکمه ارسال به آشپزخانه
// برای pendingPayment با پرداخت نقدی یا برای paid
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)
} }
@@ -208,61 +215,167 @@ const OrderDetails: FC = () => {
) )
} }
const handleRefund = (text?: string) => { const handleRefund = (amount: number, description: string) => {
if (!text) return refundPayment(
refundPayment({ {
orderId: order.id, orderId: order.id,
params: { description: text } params: { amount, description },
}, { },
onError: (error) => { {
toast.error(extractErrorMessage(error)) onSuccess: () => {
toast.success('بازگشت وجه با موفقیت انجام شد')
setRefundModal(null)
},
onError: (error) => {
toast.error(extractErrorMessage(error))
},
} }
}) )
setShowRefundModal(false) }
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 })
}
/> />
{showPaymentReceiptBox && (
<PaymentVerificationBox
order={order}
isVerifyingPayment={isVerifyingPayment}
onVerifyPayment={handleVerifyPayment}
showVerifyButton={showVerifyPaymentButton}
/>
)}
<OrderActions <OrderActions
onPrint={handlePrintOrder} onPrint={handlePrintOrder}
showSendToKitchenButton={showSendToKitchenButton} showSendToKitchenButton={showSendToKitchenButton}
@@ -273,18 +386,36 @@ const OrderDetails: FC = () => {
isCustomerPickup={isCustomerPickup} isCustomerPickup={isCustomerPickup}
isCanceled={isCanceled} isCanceled={isCanceled}
canCompleteOrder={canCompleteOrder} 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} 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)}
@@ -294,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'>
+35 -4
View File
@@ -1,4 +1,4 @@
import { type FC } from 'react' import { type FC, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Add } from 'iconsax-react' import { Add } from 'iconsax-react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
@@ -6,12 +6,15 @@ import Table from '@/components/Table'
import Filters from '@/components/Filters' import Filters from '@/components/Filters'
import Button from '@/components/Button' import Button from '@/components/Button'
import { Pages } from '@/config/Pages' import { Pages } from '@/config/Pages'
import { useGetOrders } from './hooks/useOrderData' import { useGetOrders, useSendPleaseComeSms } from './hooks/useOrderData'
import { useOrderFilters } from './hooks/useOrderFilters' import { useOrderFilters } from './hooks/useOrderFilters'
import { getOrderTableColumns } from './components/OrderTableColumns' import { getOrderTableColumns } from './components/OrderTableColumns'
import { useOrderFiltersFields } from './components/OrderFiltersFields' import { useOrderFiltersFields } from './components/OrderFiltersFields'
import SendPleaseComeSmsModal from './components/SendPleaseComeSmsModal'
import type { Order } from './types/Types' import type { Order } from './types/Types'
import type { RowDataType } from '@/components/types/TableTypes' import type { RowDataType } from '@/components/types/TableTypes'
import { toast } from 'react-toastify'
import { extractErrorMessage } from '@/config/func'
const OrdersList: FC = () => { const OrdersList: FC = () => {
const { t } = useTranslation('global') const { t } = useTranslation('global')
@@ -24,13 +27,32 @@ const OrdersList: FC = () => {
} = 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 = ordersData?.meta?.totalPages || 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'>
@@ -64,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
+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 && (
+140 -123
View File
@@ -1,145 +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;
canCompleteOrder: boolean canCompleteOrder: boolean;
isChangingStatus: boolean canSendPleaseComeSms: boolean;
onSendToKitchen: () => void isChangingStatus: boolean;
onDeliverToCourier: () => void isSendingPleaseComeSms?: boolean;
onDeliverToWaiter: () => void onSendToKitchen: () => void;
onDeliverToReceptionist: () => void onDeliverToCourier: () => void;
onCompleteOrder: () => void onDeliverToWaiter: () => void;
onCancelOrder: () => 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,
canCompleteOrder, canCompleteOrder,
isChangingStatus, canSendPleaseComeSms,
onSendToKitchen, isChangingStatus,
onDeliverToCourier, isSendingPleaseComeSms,
onDeliverToWaiter, onSendToKitchen,
onDeliverToReceptionist, onDeliverToCourier,
onCompleteOrder, onDeliverToWaiter,
onCancelOrder 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();
} };
const handleCompleteOrder = () => { const handleCancelOrder = () => {
setLoadingAction('completeOrder') setLoadingAction("cancelOrder");
onCompleteOrder() onCancelOrder();
} };
return ( const handleCompleteOrder = () => {
<div className='mt-6 flex flex-col gap-3'> setLoadingAction("completeOrder");
<Button onCompleteOrder();
label='چاپ' };
onClick={onPrint}
className='bg-slate-700 hover:bg-slate-800'
/>
{!isCanceled && showSendToKitchenButton && ( 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={handleSendToKitchen} <Button
isloading={isChangingStatus && loadingAction === 'sendToKitchen'} label="چاپ"
className='bg-orange-600 hover:bg-orange-700' onClick={onPrint}
/> className="border bg-white border-slate-700 text-slate-700 hover:bg-slate-100"
)} />
{!isCanceled && isPreparing && isCourierDelivery && ( {canSendPleaseComeSms && (
<Button <Button
label='تحویل به پیک' label="ارسال پیام"
onClick={handleDeliverToCourier} onClick={onSendPleaseComeSms}
isloading={isChangingStatus && loadingAction === 'deliverToCourier'} isloading={isSendingPleaseComeSms}
className='bg-blue-600 hover:bg-blue-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 && isDineInOrCarDelivery && ( {!isCanceled && showSendToKitchenButton && (
<Button <Button
label='تحویل به گارسون' label="ارسال به آشپزخانه"
onClick={handleDeliverToWaiter} onClick={handleSendToKitchen}
isloading={isChangingStatus && loadingAction === 'deliverToWaiter'} isloading={isChangingStatus && loadingAction === "sendToKitchen"}
className='bg-purple-600 hover:bg-purple-700' className="bg-orange-600 hover:bg-orange-700"
/> />
)} )}
{!isCanceled && isPreparing && isCustomerPickup && ( {!isCanceled && isPreparing && isCourierDelivery && (
<Button <Button
label='تحویل به رسپشنیست' label="تحویل به پیک"
onClick={handleDeliverToReceptionist} onClick={handleDeliverToCourier}
isloading={isChangingStatus && loadingAction === 'deliverToReceptionist'} isloading={isChangingStatus && loadingAction === "deliverToCourier"}
className='bg-indigo-600 hover:bg-indigo-700' className="bg-blue-600 hover:bg-blue-700"
/> />
)} )}
{!isCanceled && canCompleteOrder && ( {!isCanceled && isPreparing && isDineInOrCarDelivery && (
<Button <Button
label='اتمام سفارش' label="تحویل به گارسون"
onClick={handleCompleteOrder} onClick={handleDeliverToWaiter}
isloading={isChangingStatus && loadingAction === 'completeOrder'} isloading={isChangingStatus && loadingAction === "deliverToWaiter"}
className='bg-green-600 hover:bg-green-700' className="bg-purple-600 hover:bg-purple-700"
/> />
)} )}
{!isCanceled && showCancelButton && ( {!isCanceled && isPreparing && isCustomerPickup && (
<Button <Button
label='لغو سفارش' label="تحویل به رسپشنیست"
onClick={handleCancelOrder} onClick={handleDeliverToReceptionist}
isloading={isChangingStatus && loadingAction === 'cancelOrder'} isloading={
className='bg-transparent border border-red-500 text-red-500 hover:bg-red-50' isChangingStatus && loadingAction === "deliverToReceptionist"
/> }
)} className="bg-indigo-600 hover:bg-indigo-700"
</div> />
) )}
}
export default OrderActions {!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,71 +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
showVerifyButton: boolean
}
const PaymentVerificationBox: FC<PaymentVerificationBoxProps> = ({
order,
isVerifyingPayment,
onVerifyPayment,
showVerifyButton,
}) => {
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>
)}
{showVerifyButton && (
<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",
+90 -1
View File
@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as api from "../service/OrderService"; import * as api from "../service/OrderService";
import type { GetOrdersParams, ChangeOrderStatusParams, RefundPaymentParams } from "../service/OrderService"; import type { GetOrdersParams, ChangeOrderStatusParams, RefundPaymentParams, AddPaymentParams } from "../service/OrderService";
import type { UpdateOrderFeesType, AddOrderItemType, UpdateOrderItemQuantityType } from "../types/Types";
export const useCreateOrder = () => { export const useCreateOrder = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -72,3 +73,91 @@ export const useRefundPayment = () => {
}, },
}); });
}; };
export const useAddPayment = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ orderId, params }: { orderId: string; params: AddPaymentParams }) =>
api.addPayment(orderId, params),
onSuccess: (_, { orderId }) => {
queryClient.invalidateQueries({ queryKey: ["order", orderId] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
},
});
};
export const useUpdateOrderFees = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
orderId,
params,
}: {
orderId: string;
params: UpdateOrderFeesType;
}) => api.updateOrderFees(orderId, params),
onSuccess: (_, { orderId }) => {
queryClient.invalidateQueries({ queryKey: ["order", orderId] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
},
});
};
export const useAddOrderItem = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
orderId,
params,
}: {
orderId: string;
params: AddOrderItemType;
}) => api.addOrderItem(orderId, params),
onSuccess: (_, { orderId }) => {
queryClient.invalidateQueries({ queryKey: ["order", orderId] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
},
});
};
export const useUpdateOrderItemQuantity = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
orderId,
itemId,
params,
}: {
orderId: string;
itemId: string;
params: UpdateOrderItemQuantityType;
}) => api.updateOrderItemQuantity(orderId, itemId, params),
onSuccess: (_, { orderId }) => {
queryClient.invalidateQueries({ queryKey: ["order", orderId] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
},
});
};
export const useRemoveOrderItem = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
orderId,
itemId,
}: {
orderId: string;
itemId: string;
}) => api.removeOrderItem(orderId, itemId),
onSuccess: (_, { orderId }) => {
queryClient.invalidateQueries({ queryKey: ["order", orderId] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
},
});
};
export const useSendPleaseComeSms = () => {
return useMutation({
mutationFn: (orderId: string) => api.sendPleaseComeSms(orderId),
});
};
+67
View File
@@ -6,6 +6,9 @@ import type {
GetDailyOrderReportResponse, GetDailyOrderReportResponse,
CreateOrderType, CreateOrderType,
CreateOrderResponse, CreateOrderResponse,
UpdateOrderFeesType,
AddOrderItemType,
UpdateOrderItemQuantityType,
} from "../types/Types"; } from "../types/Types";
export interface GetOrdersParams { export interface GetOrdersParams {
@@ -21,9 +24,15 @@ export interface ChangeOrderStatusParams {
} }
export interface RefundPaymentParams { export interface RefundPaymentParams {
amount: number;
description: string; description: string;
} }
export interface AddPaymentParams {
amount: number;
description?: string;
}
export const createOrder = async ( export const createOrder = async (
params: CreateOrderType params: CreateOrderType
): Promise<CreateOrderResponse> => { ): Promise<CreateOrderResponse> => {
@@ -100,6 +109,57 @@ export const refundPayment = async (
await axios.post(`/admin/orders/${orderId}/refund`, params); await axios.post(`/admin/orders/${orderId}/refund`, params);
}; };
export const addPayment = async (
orderId: string,
params: AddPaymentParams
): Promise<void> => {
await axios.post(`/admin/orders/${orderId}/payments`, params);
};
export const updateOrderFees = async (
orderId: string,
params: UpdateOrderFeesType
): Promise<GetOrderByIdResponse> => {
const { data } = await axios.patch<GetOrderByIdResponse>(
`/admin/orders/${orderId}/fees`,
params
);
return data;
};
export const addOrderItem = async (
orderId: string,
params: AddOrderItemType
): Promise<GetOrderByIdResponse> => {
const { data } = await axios.post<GetOrderByIdResponse>(
`/admin/orders/${orderId}/items`,
params
);
return data;
};
export const updateOrderItemQuantity = async (
orderId: string,
itemId: string,
params: UpdateOrderItemQuantityType
): Promise<GetOrderByIdResponse> => {
const { data } = await axios.patch<GetOrderByIdResponse>(
`/admin/orders/${orderId}/items/${itemId}`,
params
);
return data;
};
export const removeOrderItem = async (
orderId: string,
itemId: string
): Promise<GetOrderByIdResponse> => {
const { data } = await axios.delete<GetOrderByIdResponse>(
`/admin/orders/${orderId}/items/${itemId}`
);
return data;
};
export interface GetFoodOrderReportParams { export interface GetFoodOrderReportParams {
from?: string; from?: string;
to?: string; to?: string;
@@ -131,3 +191,10 @@ export const getDailyOrderReport = async (
); );
return data; return data;
}; };
export const sendPleaseComeSms = async (orderId: string): Promise<{ message: string }> => {
const { data } = await axios.post<{ message: string }>(
`/admin/orders/${orderId}/please-come`
);
return data;
};
+30 -1
View File
@@ -192,7 +192,7 @@ export interface Order extends RowDataType {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
deletedAt: string | null; deletedAt: string | null;
user: OrderUser; user: OrderUser | null;
restaurant: OrderRestaurant; restaurant: OrderRestaurant;
deliveryMethod: OrderDeliveryMethod; deliveryMethod: OrderDeliveryMethod;
userAddress: UserAddress | null; userAddress: UserAddress | null;
@@ -207,17 +207,46 @@ export interface Order extends RowDataType {
subTotal: number; subTotal: number;
tax: number; tax: number;
deliveryFee: number; deliveryFee: number;
packingFee: number;
prepareTime?: number | null;
total: number; total: number;
paidAmount: number;
balance: number;
refundedAmount: number;
totalItems: number; totalItems: number;
description: string; description: string;
tableNumber: string | null; tableNumber: string | null;
status: string; status: string;
paymentStatus?: string; paymentStatus?: string;
cashShift?: {
id: string;
admin?: {
id: string;
firstName?: string;
lastName?: string;
phone?: string;
} | null;
} | null;
history: OrderHistory[]; history: OrderHistory[];
payments: OrderPayment[]; payments: OrderPayment[];
items: OrderItem[]; items: OrderItem[];
} }
export interface UpdateOrderFeesType {
deliveryFee?: number;
packingFee?: number;
prepareTime?: number;
}
export interface AddOrderItemType {
foodId: string;
quantity: number;
}
export interface UpdateOrderItemQuantityType {
quantity: number;
}
export type PaginationMeta = { export type PaginationMeta = {
total: number; total: number;
page: number; page: number;
+60 -293
View File
@@ -1,22 +1,43 @@
import { type FC, useState } from 'react' import { type FC, useMemo, useState } from 'react'
import { useGetReviewById, useUpdateReviewStatus } from './hooks/useReviewData' import { useGetReviewById, useUpdateReviewStatus } from './hooks/useReviewData'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import PageLoading from '@/components/PageLoading' import PageLoading from '@/components/PageLoading'
import Input from '@/components/Input' import { toast } from 'react-toastify'
import Status from '@/components/Status' import { extractErrorMessage } from '@/config/func'
import Select from '@/components/Select' import ReviewStatusHeader from './components/ReviewStatusHeader'
import Button from '@/components/Button' import ReviewChatView from './components/ReviewChatView'
import { Star1, User, ShoppingCart, DocumentText, Like1, Dislike } from 'iconsax-react' import ReviewChatSidebar from './components/ReviewChatSidebar'
import { formatOptionalDate, formatPrice, formatFaNumber } from '@/helpers/func'
import { useTranslation } from 'react-i18next' const getParentId = (parent: string | { id: string } | null | undefined) => {
if (!parent) return null
return typeof parent === 'string' ? parent : parent.id
}
const DetailReview: FC = () => { const DetailReview: FC = () => {
const { t } = useTranslation('global')
const { id } = useParams() const { id } = useParams()
const { data: reviewData, isLoading } = useGetReviewById(id!) const { data: reviewData, isLoading } = useGetReviewById(id!)
const updateStatusMutation = useUpdateReviewStatus() const updateStatusMutation = useUpdateReviewStatus()
const [selectedStatus, setSelectedStatus] = useState<string>('') const [selectedStatus, setSelectedStatus] = useState('')
const { review, replies } = useMemo(() => {
const items = reviewData?.data ?? []
if (!id || items.length === 0) {
return { review: undefined, replies: [] }
}
const parentReview =
items.find((item) => item.id === id) ??
items.find((item) => !getParentId(item.parent))
const repliesList = items
.filter((item) => item.id !== parentReview?.id)
.sort(
(a, b) =>
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
)
return { review: parentReview, replies: repliesList }
}, [reviewData?.data, id])
if (isLoading) { if (isLoading) {
return ( return (
@@ -26,8 +47,7 @@ const DetailReview: FC = () => {
) )
} }
const review = reviewData?.data if (!review || !id) {
if (!review) {
return ( return (
<div className='mt-5'> <div className='mt-5'>
<div className='flex justify-between items-center'> <div className='flex justify-between items-center'>
@@ -38,294 +58,41 @@ const DetailReview: FC = () => {
) )
} }
const getStatusVariant = (status: string): 'success' | 'error' | 'warning' | 'info' | 'pending' => {
const variantMap: Record<string, 'success' | 'error' | 'warning' | 'info' | 'pending'> = {
'pending': 'pending',
'approved': 'success',
'rejected': 'error',
}
return variantMap[status] || 'pending'
}
const statusLabels: Record<string, string> = {
'pending': 'در انتظار',
'approved': 'تایید شده',
'rejected': 'رد شده',
}
const statusOptions = [
{ value: 'pending', label: 'در انتظار' },
{ value: 'approved', label: 'تایید شده' },
{ value: 'rejected', label: 'رد شده' },
]
const handleStatusChange = () => { const handleStatusChange = () => {
if (selectedStatus && selectedStatus !== review.status && id) { if (!selectedStatus || selectedStatus === review.status) return
updateStatusMutation.mutate(
{ id, status: selectedStatus },
{
onSuccess: () => {
setSelectedStatus('')
},
}
)
}
}
const userName = [review.user.firstName, review.user.lastName].filter(Boolean).join(' ') || '-' updateStatusMutation.mutate(
{ id, status: selectedStatus },
{
onSuccess: () => {
setSelectedStatus('')
toast.success('وضعیت نظر به‌روزرسانی شد')
},
onError: (error) => {
toast.error(extractErrorMessage(error))
},
}
)
}
return ( return (
<div className='mt-5'> <div className='mt-5'>
<div className='flex justify-between items-center mb-8'> <ReviewStatusHeader
<h1 className='text-lg font-light'>جزئیات نظر</h1> status={review.status}
<div className='flex items-center gap-4'> selectedStatus={selectedStatus}
<Status onStatusChange={setSelectedStatus}
variant={getStatusVariant(review.status)} onSave={handleStatusChange}
label={statusLabels[review.status] || review.status} isSaving={updateStatusMutation.isPending}
/> />
<div className='flex items-center gap-2'>
<div> <div className='flex gap-6 items-start'>
<Select <div className='flex-1 min-w-0'>
items={statusOptions} <ReviewChatView reviewId={id} review={review} replies={replies} />
value={selectedStatus || review.status}
onChange={(e) => setSelectedStatus(e.target.value)}
className='w-40'
/>
</div>
{selectedStatus && selectedStatus !== review.status && (
<Button
label='ذخیره'
onClick={handleStatusChange}
isloading={updateStatusMutation.isPending}
className='w-24'
/>
)}
</div>
</div>
</div>
<div className='flex gap-7'>
{/* Main Content */}
<div className='flex-1 bg-white rounded-4xl p-8 space-y-6'>
{/* Rating Section */}
<div className='flex items-center gap-3 pb-6 border-b border-border'>
<div className='flex items-center gap-2'>
<Star1 size={24} color="#FFB800" variant="Bold" />
<span className='text-2xl font-semibold'>{review.rating}</span>
</div>
<span className='text-sm text-gray-500'>از ۵</span>
</div>
{/* Comment Section */}
<div>
<div className='flex items-center gap-2 mb-4'>
<DocumentText size={20} color="#8C90A3" />
<span className='text-sm font-medium'>نظر</span>
</div>
<div className='bg-gray-50 rounded-xl p-4 border border-border'>
<p className='text-sm leading-6 text-gray-700 whitespace-pre-wrap'>
{review.comment || 'نظری ثبت نشده است'}
</p>
</div>
</div>
{/* Positive Points */}
{review.positivePoints && review.positivePoints.length > 0 && (
<div>
<div className='flex items-center gap-2 mb-4'>
<Like1 size={20} color="#22C55E" />
<span className='text-sm font-medium'>نقاط مثبت</span>
</div>
<div className='flex flex-wrap gap-3'>
{review.positivePoints.map((point, index) => (
<span
key={index}
className='px-4 py-2 border border-green-500 text-green-600 rounded-xl text-sm bg-white'
>
{t(`reviews.${point}`)}
</span>
))}
</div>
</div>
)}
{/* Negative Points */}
{review.negativePoints && review.negativePoints.length > 0 && (
<div>
<div className='flex items-center gap-2 mb-4'>
<Dislike size={20} color="#EF4444" />
<span className='text-sm font-medium'>نقاط منفی</span>
</div>
<div className='flex flex-wrap gap-3'>
{review.negativePoints.map((point, index) => (
<span
key={index}
className='px-4 py-2 border border-red-500 text-red-600 rounded-xl text-sm bg-white'
>
{t(`reviews.${point}`)}
</span>
))}
</div>
</div>
)}
{/* Food Information */}
<div className='pt-6 border-t border-border'>
<div className='flex items-center gap-2 mb-4'>
<ShoppingCart size={20} color="#8C90A3" />
<span className='text-sm font-medium'>اطلاعات غذا</span>
</div>
<div className='grid grid-cols-2 gap-4'>
<Input
label='عنوان غذا'
value={review.food.title}
readOnly
/>
<Input
label='قیمت'
value={formatPrice(review.food.price)}
readOnly
/>
<Input
label='امتیاز غذا'
value={formatFaNumber(review.food.rate)}
readOnly
/>
<Input
label='دسته‌بندی'
value={review.food.category}
readOnly
/>
</div>
{review.food.images && review.food.images.length > 0 && (
<div className='mt-4'>
<span className='text-sm mb-2 block'>تصاویر غذا</span>
<div className='flex gap-3 flex-wrap'>
{review.food.images.map((image, index) => (
<img
key={index}
src={image}
alt={`غذا ${index + 1}`}
className='w-24 h-24 object-cover rounded-xl border border-border'
/>
))}
</div>
</div>
)}
</div>
</div>
{/* Sidebar */}
<div className='w-[330px] h-fit bg-white rounded-4xl p-8 flex flex-col gap-6'>
{/* User Information */}
<div>
<div className='flex items-center gap-2 mb-4'>
<User size={20} color="#8C90A3" />
<span className='text-sm font-medium'>اطلاعات کاربر</span>
</div>
<div className='space-y-4'>
<Input
label='نام و نام خانوادگی'
value={userName}
readOnly
/>
<Input
label='شماره تماس'
value={review.user.phone}
readOnly
/>
<Input
label='تاریخ تولد'
value={formatOptionalDate(review.user.birthDate)}
readOnly
/>
<Input
label='کیف پول'
value={formatPrice(review.user.wallet)}
readOnly
/>
<Input
label='امتیاز'
value={formatFaNumber(review.user.points)}
readOnly
/>
</div>
</div>
{/* Order Information */}
<div className='pt-6 border-t border-border'>
<div className='flex items-center gap-2 mb-4'>
<ShoppingCart size={20} color="#8C90A3" />
<span className='text-sm font-medium'>اطلاعات سفارش</span>
</div>
<div className='space-y-4'>
<Input
label='شماره سفارش'
value={formatFaNumber(review.order.orderNumber)}
readOnly
/>
<Input
label='تاریخ سفارش'
value={formatOptionalDate(review.order.createdAt, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
readOnly
/>
<Input
label='مبلغ کل'
value={formatPrice(review.order.total)}
readOnly
/>
<Input
label='وضعیت سفارش'
value={review.order.status}
readOnly
/>
<Input
label='وضعیت پرداخت'
value={review.order.paymentStatus}
readOnly
/>
</div>
</div>
{/* Review Metadata */}
<div className='pt-6 border-t border-border'>
<div className='space-y-4'>
<Input
label='تاریخ ثبت نظر'
value={formatOptionalDate(review.createdAt, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
readOnly
/>
{review.updatedAt && (
<Input
label='تاریخ آخرین بروزرسانی'
value={formatOptionalDate(review.updatedAt, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
readOnly
/>
)}
</div>
</div>
</div> </div>
<ReviewChatSidebar review={review} />
</div> </div>
</div> </div>
) )
} }
export default DetailReview export default DetailReview
@@ -0,0 +1,70 @@
import { type FC } from 'react'
import { User, ShoppingCart, Calendar } from 'iconsax-react'
import { formatOptionalDate, formatPrice, formatFaNumber } from '@/helpers/func'
import type { ReviewDetail } from '../types/Types'
interface ReviewChatSidebarProps {
review: ReviewDetail
}
interface InfoRowProps {
label: string
value: string
}
const InfoRow: FC<InfoRowProps> = ({ label, value }) => (
<div className='space-y-1'>
<span className='text-xs text-gray-500'>{label}</span>
<p className='text-sm text-gray-800 leading-6 break-words'>{value}</p>
</div>
)
const ReviewChatSidebar: FC<ReviewChatSidebarProps> = ({ review }) => {
const userName = [review.user?.firstName, review.user?.lastName].filter(Boolean).join(' ') || '-'
return (
<div className='w-[280px] shrink-0 h-fit bg-white rounded-4xl p-6 flex flex-col gap-6'>
<div>
<div className='flex items-center gap-2 mb-4'>
<User size={18} color='#8C90A3' />
<span className='text-sm font-medium'>اطلاعات کاربر</span>
</div>
<div className='space-y-4'>
<InfoRow label='نام و نام خانوادگی' value={userName} />
<InfoRow label='شماره تماس' value={review.user?.phone || '-'} />
<InfoRow label='خریدار' value={review.isBuyer ? 'بله' : 'خیر'} />
</div>
</div>
<div className='pt-5 border-t border-border'>
<div className='flex items-center gap-2 mb-4'>
<ShoppingCart size={18} color='#8C90A3' />
<span className='text-sm font-medium'>اطلاعات غذا</span>
</div>
<div className='space-y-4'>
<InfoRow label='عنوان غذا' value={review.food?.title || '-'} />
<InfoRow label='قیمت' value={formatPrice(review.food?.price || 0)} />
<InfoRow label='امتیاز غذا' value={formatFaNumber(review.food?.score)} />
</div>
</div>
<div className='pt-5 border-t border-border'>
<div className='flex items-center gap-2 mb-4'>
<Calendar size={18} color='#8C90A3' />
<span className='text-sm font-medium'>تاریخ ثبت نظر</span>
</div>
<p dir='ltr' className='text-sm text-gray-800 text-right'>
{formatOptionalDate(review.createdAt, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
</div>
)
}
export default ReviewChatSidebar
@@ -0,0 +1,249 @@
import { type FC, useMemo, useState } from 'react'
import { Star1, Send2, Trash } from 'iconsax-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'react-toastify'
import { extractErrorMessage } from '@/config/func'
import { formatOptionalDate } from '@/helpers/func'
import { useDeleteReviewReply, useReplyToReview } from '../hooks/useReviewData'
import type { ReviewDetail, ReviewReply } from '../types/Types'
interface ReviewChatViewProps {
reviewId: string
review: ReviewDetail
replies: ReviewReply[]
}
const getDisplayName = (
firstName?: string | null,
lastName?: string | null,
fallback = '-'
) => [firstName, lastName].filter(Boolean).join(' ') || fallback
const formatMessageTime = (date?: string) =>
formatOptionalDate(date, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
const ReviewChatView: FC<ReviewChatViewProps> = ({ reviewId, review, replies }) => {
const { t } = useTranslation('global')
const existingReply = replies[0]
const [comment, setComment] = useState('')
const replyMutation = useReplyToReview()
const deleteMutation = useDeleteReviewReply()
const userName = getDisplayName(review.user?.firstName, review.user?.lastName, 'کاربر')
const foodTitle = review.food?.title || '-'
const messages = useMemo(() => {
const items: Array<{
id: string
type: 'user' | 'admin'
senderName: string
content: string
createdAt?: string
rating?: number
positivePoints?: string[]
negativePoints?: string[]
}> = [
{
id: review.id,
type: 'user',
senderName: userName,
content: review.comment || 'نظری ثبت نشده است',
createdAt: review.createdAt,
rating: review.rating,
positivePoints: review.positivePoints,
negativePoints: review.negativePoints,
},
]
replies.forEach((reply) => {
items.push({
id: reply.id,
type: 'admin',
senderName: getDisplayName(
reply.admin?.firstName,
reply.admin?.lastName,
'رستوران'
),
content: reply.comment || '-',
createdAt: reply.createdAt,
})
})
return items
}, [review, replies, userName])
const trimmedComment = comment.trim()
const existingText = existingReply?.comment?.trim() || ''
const canSend = trimmedComment.length > 0 && trimmedComment !== existingText
const handleSend = () => {
if (!canSend) return
replyMutation.mutate(
{ id: reviewId, comment: trimmedComment },
{
onSuccess: () => {
setComment('')
toast.success(existingReply ? 'پاسخ با موفقیت به‌روزرسانی شد' : 'پاسخ با موفقیت ثبت شد')
},
onError: (error) => {
toast.error(extractErrorMessage(error))
},
}
)
}
const handleDelete = () => {
deleteMutation.mutate(reviewId, {
onSuccess: () => {
setComment('')
toast.success('پاسخ حذف شد')
},
onError: (error) => {
toast.error(extractErrorMessage(error))
},
})
}
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
handleSend()
}
return (
<div className='flex flex-col bg-white rounded-4xl overflow-hidden min-h-[520px] max-h-[calc(100vh-220px)]'>
<div className='px-6 py-4 border-b border-border shrink-0'>
<h2 className='text-base font-medium text-right'>{userName}</h2>
<p className='text-xs text-gray-500 mt-1 text-right'>{foodTitle}</p>
</div>
<div className='flex-1 overflow-y-auto px-6 py-5'>
<ul className='flex flex-col gap-5'>
{messages.map((message) => {
const isAdmin = message.type === 'admin'
return (
<li
key={message.id}
className={`flex w-full ${isAdmin ? 'justify-start' : 'justify-end'}`}
>
<div
className={`w-full max-w-[min(420px,85%)] rounded-[24px] px-5 py-4 ${
isAdmin
? 'bg-[#F6F7FA] rounded-tr-none'
: 'bg-[#EBEDF5] rounded-tl-none'
}`}
>
<div className='flex items-center justify-between gap-3 mb-2'>
<h6 className='text-xs font-medium text-gray-700'>
{message.senderName}
</h6>
{isAdmin && existingReply && (
<button
type='button'
onClick={handleDelete}
disabled={deleteMutation.isPending}
className='shrink-0 text-red-500 hover:text-red-600 disabled:opacity-50 p-1'
aria-label='حذف پاسخ'
>
<Trash size={16} />
</button>
)}
</div>
{!isAdmin && message.rating !== undefined && (
<div className='flex items-center gap-1.5 mb-2'>
<Star1 size={16} color='#FFB800' variant='Bold' />
<span className='text-sm font-medium'>{message.rating}</span>
<span className='text-xs text-gray-500'>از ۵</span>
</div>
)}
<p className='text-sm leading-7 text-gray-800 whitespace-pre-wrap break-words'>
{message.content}
</p>
{!isAdmin && message.positivePoints && message.positivePoints.length > 0 && (
<div className='flex flex-wrap gap-2 mt-3'>
{message.positivePoints.map((point, index) => (
<span
key={index}
className='px-3 py-1 border border-green-500 text-green-600 rounded-xl text-xs bg-white'
>
{t(`reviews.${point}`)}
</span>
))}
</div>
)}
{!isAdmin && message.negativePoints && message.negativePoints.length > 0 && (
<div className='flex flex-wrap gap-2 mt-2'>
{message.negativePoints.map((point, index) => (
<span
key={index}
className='px-3 py-1 border border-red-500 text-red-600 rounded-xl text-xs bg-white'
>
{t(`reviews.${point}`)}
</span>
))}
</div>
)}
{message.createdAt && (
<div className='mt-3 flex justify-end'>
<span dir='ltr' className='text-[11px] text-gray-400'>
{formatMessageTime(message.createdAt)}
</span>
</div>
)}
</div>
</li>
)
})}
</ul>
</div>
<div className='px-6 pb-6 pt-4 border-t border-border shrink-0'>
<form
onSubmit={handleSubmit}
className='focus-within:outline-blue-400 outline outline-transparent transition-colors duration-100 flex items-center gap-3 bg-white border border-border min-h-12 rounded-2xl py-2 px-3'
>
<input
value={comment}
onChange={(e) => setComment(e.target.value)}
autoComplete='off'
dir='rtl'
className='flex-1 min-w-0 text-sm text-right bg-transparent outline-none placeholder:text-gray-400'
type='text'
placeholder={
existingReply
? 'برای ویرایش پاسخ، متن جدید بنویسید...'
: 'پاسخ خود را بنویسید...'
}
/>
<button
type='submit'
disabled={!canSend || replyMutation.isPending}
className='shrink-0 active:bg-neutral-100 transition-colors duration-150 rounded-full p-2 disabled:opacity-40'
aria-label='ارسال پاسخ'
>
<Send2
size={20}
color={canSend ? '#2563EB' : '#8C90A3'}
className='rotate-180'
/>
</button>
</form>
</div>
</div>
)
}
export default ReviewChatView
@@ -0,0 +1,76 @@
import { type FC } from 'react'
import { Star1, DocumentText, Like1, Dislike } from 'iconsax-react'
import { useTranslation } from 'react-i18next'
import type { ReviewDetail } from '../types/Types'
interface ReviewContentSectionProps {
review: ReviewDetail
}
const ReviewContentSection: FC<ReviewContentSectionProps> = ({ review }) => {
const { t } = useTranslation('global')
return (
<div className='space-y-6'>
<div className='flex items-center gap-3 pb-6 border-b border-border'>
<div className='flex items-center gap-2'>
<Star1 size={24} color="#FFB800" variant="Bold" />
<span className='text-2xl font-semibold'>{review.rating}</span>
</div>
<span className='text-sm text-gray-500'>از ۵</span>
</div>
<div>
<div className='flex items-center gap-2 mb-4'>
<DocumentText size={20} color="#8C90A3" />
<span className='text-sm font-medium'>نظر</span>
</div>
<div className='bg-gray-50 rounded-xl p-4 border border-border'>
<p className='text-sm leading-6 text-gray-700 whitespace-pre-wrap'>
{review.comment || 'نظری ثبت نشده است'}
</p>
</div>
</div>
{review.positivePoints && review.positivePoints.length > 0 && (
<div>
<div className='flex items-center gap-2 mb-4'>
<Like1 size={20} color="#22C55E" />
<span className='text-sm font-medium'>نقاط مثبت</span>
</div>
<div className='flex flex-wrap gap-3'>
{review.positivePoints.map((point, index) => (
<span
key={index}
className='px-4 py-2 border border-green-500 text-green-600 rounded-xl text-sm bg-white'
>
{t(`reviews.${point}`)}
</span>
))}
</div>
</div>
)}
{review.negativePoints && review.negativePoints.length > 0 && (
<div>
<div className='flex items-center gap-2 mb-4'>
<Dislike size={20} color="#EF4444" />
<span className='text-sm font-medium'>نقاط منفی</span>
</div>
<div className='flex flex-wrap gap-3'>
{review.negativePoints.map((point, index) => (
<span
key={index}
className='px-4 py-2 border border-red-500 text-red-600 rounded-xl text-sm bg-white'
>
{t(`reviews.${point}`)}
</span>
))}
</div>
</div>
)}
</div>
)
}
export default ReviewContentSection
@@ -0,0 +1,43 @@
import { type FC } from 'react'
import Input from '@/components/Input'
import { ShoppingCart } from 'iconsax-react'
import { formatPrice, formatFaNumber } from '@/helpers/func'
import type { ReviewFood } from '../types/Types'
interface ReviewFoodSectionProps {
food: ReviewFood
}
const ReviewFoodSection: FC<ReviewFoodSectionProps> = ({ food }) => {
return (
<div className='pt-6 border-t border-border'>
<div className='flex items-center gap-2 mb-4'>
<ShoppingCart size={20} color="#8C90A3" />
<span className='text-sm font-medium'>اطلاعات غذا</span>
</div>
<div className='grid grid-cols-2 gap-4'>
<Input label='عنوان غذا' value={food.title} readOnly />
<Input label='قیمت' value={formatPrice(food.price)} readOnly />
<Input label='امتیاز غذا' value={formatFaNumber(food.score)} readOnly />
<Input label='دسته‌بندی' value={food.category} readOnly />
</div>
{food.images && food.images.length > 0 && (
<div className='mt-4'>
<span className='text-sm mb-2 block'>تصاویر غذا</span>
<div className='flex gap-3 flex-wrap'>
{food.images.map((image, index) => (
<img
key={index}
src={image}
alt={`غذا ${index + 1}`}
className='w-24 h-24 object-cover rounded-xl border border-border'
/>
))}
</div>
</div>
)}
</div>
)
}
export default ReviewFoodSection
@@ -0,0 +1,128 @@
import { type FC, useEffect, useState } from 'react'
import { MessageText1 } from 'iconsax-react'
import Textarea from '@/components/Textarea'
import Button from '@/components/Button'
import { formatOptionalDate } from '@/helpers/func'
import { toast } from 'react-toastify'
import { extractErrorMessage } from '@/config/func'
import { useDeleteReviewReply, useReplyToReview } from '../hooks/useReviewData'
import type { ReviewReply } from '../types/Types'
interface ReviewReplySectionProps {
reviewId: string
replies: ReviewReply[]
}
const ReviewReplySection: FC<ReviewReplySectionProps> = ({ reviewId, replies }) => {
const existingReply = replies[0]
const [comment, setComment] = useState(existingReply?.comment || '')
const replyMutation = useReplyToReview()
const deleteMutation = useDeleteReviewReply()
useEffect(() => {
setComment(existingReply?.comment || '')
}, [existingReply?.comment, existingReply?.id])
const hasChanges = comment.trim() !== (existingReply?.comment || '').trim()
const canSave = comment.trim().length > 0 && hasChanges
const handleSave = () => {
if (!canSave) return
replyMutation.mutate(
{ id: reviewId, comment: comment.trim() },
{
onSuccess: () => {
toast.success(existingReply ? 'پاسخ با موفقیت به‌روزرسانی شد' : 'پاسخ با موفقیت ثبت شد')
},
onError: (error) => {
toast.error(extractErrorMessage(error))
},
}
)
}
const handleDelete = () => {
deleteMutation.mutate(reviewId, {
onSuccess: () => {
setComment('')
toast.success('پاسخ حذف شد')
},
onError: (error) => {
toast.error(extractErrorMessage(error))
},
})
}
return (
<div className='pt-6 border-t border-border space-y-6'>
<div className='flex items-center gap-2'>
<MessageText1 size={20} color="#8C90A3" />
<span className='text-sm font-medium'>پاسخ رستوران</span>
{replies.length > 0 && (
<span className='text-xs text-gray-400'>({replies.length})</span>
)}
</div>
{replies.length > 0 && (
<div className='space-y-3'>
{replies.map((reply) => (
<div
key={reply.id}
className='bg-gray-50 rounded-xl p-4 border border-border'
>
<div className='flex items-center justify-between mb-2'>
<span className='text-xs font-medium text-gray-500'>پاسخ</span>
{reply.createdAt && (
<span className='text-xs text-gray-500'>
{formatOptionalDate(reply.createdAt, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</span>
)}
</div>
<p className='text-sm leading-6 text-gray-700 whitespace-pre-wrap'>
{reply.comment || '-'}
</p>
</div>
))}
</div>
)}
<div>
<Textarea
label='متن پاسخ'
isNotRequired
placeholder='پاسخ خود را به این نظر بنویسید...'
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={4}
/>
<div className='flex items-center gap-3 mt-4'>
<Button
label={existingReply ? 'ویرایش پاسخ' : 'ثبت پاسخ'}
onClick={handleSave}
isloading={replyMutation.isPending}
disabled={!canSave}
className='w-36'
/>
{existingReply && (
<Button
label='حذف پاسخ'
onClick={handleDelete}
isloading={deleteMutation.isPending}
className='w-28 bg-red-500 hover:bg-red-600'
/>
)}
</div>
</div>
</div>
)
}
export default ReviewReplySection
@@ -0,0 +1,107 @@
import { type FC } from 'react'
import Input from '@/components/Input'
import { User, ShoppingCart } from 'iconsax-react'
import { formatOptionalDate, formatPrice, formatFaNumber } from '@/helpers/func'
import type { ReviewDetail } from '../types/Types'
interface ReviewSidebarProps {
review: ReviewDetail
}
const ReviewSidebar: FC<ReviewSidebarProps> = ({ review }) => {
const userName = [review.user?.firstName, review.user?.lastName].filter(Boolean).join(' ') || '-'
return (
<div className='w-[330px] h-fit bg-white rounded-4xl p-8 flex flex-col gap-6'>
<div>
<div className='flex items-center gap-2 mb-4'>
<User size={20} color="#8C90A3" />
<span className='text-sm font-medium'>اطلاعات کاربر</span>
</div>
<div className='space-y-4'>
<Input label='نام و نام خانوادگی' value={userName} readOnly />
<Input label='شماره تماس' value={review.user?.phone || '-'} readOnly />
<Input
label='تاریخ تولد'
value={formatOptionalDate(review.user?.birthDate)}
readOnly
/>
<Input
label='خریدار'
value={review.isBuyer ? 'بله' : 'خیر'}
readOnly
/>
</div>
</div>
{review.order && (
<div className='pt-6 border-t border-border'>
<div className='flex items-center gap-2 mb-4'>
<ShoppingCart size={20} color="#8C90A3" />
<span className='text-sm font-medium'>اطلاعات سفارش</span>
</div>
<div className='space-y-4'>
<Input
label='شماره سفارش'
value={formatFaNumber(review.order.orderNumber)}
readOnly
/>
<Input
label='تاریخ سفارش'
value={formatOptionalDate(review.order.createdAt, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
readOnly
/>
<Input
label='مبلغ کل'
value={formatPrice(review.order.total)}
readOnly
/>
<Input label='وضعیت سفارش' value={review.order.status} readOnly />
<Input
label='وضعیت پرداخت'
value={review.order.paymentStatus}
readOnly
/>
</div>
</div>
)}
<div className='pt-6 border-t border-border'>
<div className='space-y-4'>
<Input
label='تاریخ ثبت نظر'
value={formatOptionalDate(review.createdAt, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
readOnly
/>
{review.updatedAt && (
<Input
label='تاریخ آخرین بروزرسانی'
value={formatOptionalDate(review.updatedAt, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
readOnly
/>
)}
</div>
</div>
</div>
)
}
export default ReviewSidebar
@@ -0,0 +1,59 @@
import { type FC } from 'react'
import Status from '@/components/Status'
import Select from '@/components/Select'
import Button from '@/components/Button'
import {
getReviewStatusVariant,
reviewStatusLabels,
reviewStatusOptions,
} from '../utils/reviewStatus'
interface ReviewStatusHeaderProps {
status: string
selectedStatus: string
onStatusChange: (status: string) => void
onSave: () => void
isSaving: boolean
}
const ReviewStatusHeader: FC<ReviewStatusHeaderProps> = ({
status,
selectedStatus,
onStatusChange,
onSave,
isSaving,
}) => {
const hasChanges = Boolean(selectedStatus && selectedStatus !== status)
return (
<div className='flex justify-between items-center mb-8'>
<h1 className='text-lg font-light'>جزئیات نظر</h1>
<div className='flex items-center gap-4'>
<Status
variant={getReviewStatusVariant(status)}
label={reviewStatusLabels[status] || status}
/>
<div className='flex items-center gap-2'>
<div>
<Select
items={reviewStatusOptions}
value={selectedStatus || status}
onChange={(e) => onStatusChange(e.target.value)}
className='w-40'
/>
</div>
{hasChanges && (
<Button
label='ذخیره'
onClick={onSave}
isloading={isSaving}
className='w-24'
/>
)}
</div>
</div>
</div>
)
}
export default ReviewStatusHeader
@@ -5,15 +5,7 @@ import { 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 { getReviewStatusVariant, reviewStatusLabels } from '../utils/reviewStatus'
const getStatusVariant = (status: string): 'success' | 'error' | 'warning' | 'info' | 'pending' => {
const variantMap: Record<string, 'success' | 'error' | 'warning' | 'info' | 'pending'> = {
'pending': 'pending',
'approved': 'success',
'rejected': 'error',
}
return variantMap[status] || 'pending'
}
export const getReviewTableColumns = (): ColumnType<Review>[] => { export const getReviewTableColumns = (): ColumnType<Review>[] => {
return [ return [
@@ -21,7 +13,7 @@ export const getReviewTableColumns = (): ColumnType<Review>[] => {
key: 'user', key: 'user',
title: 'کاربر', title: 'کاربر',
render: (item: Review) => { render: (item: Review) => {
const name = [item.user.firstName, item.user.lastName].filter(Boolean).join(' ') const name = [item.user?.firstName, item.user?.lastName].filter(Boolean).join(' ')
return name || '-' return name || '-'
} }
}, },
@@ -63,15 +55,10 @@ export const getReviewTableColumns = (): ColumnType<Review>[] => {
key: 'status', key: 'status',
title: 'وضعیت', title: 'وضعیت',
render: (item: Review) => { render: (item: Review) => {
const statusLabels: Record<string, string> = {
'pending': 'در انتظار',
'approved': 'تایید شده',
'rejected': 'رد شده',
}
return ( return (
<Status <Status
variant={getStatusVariant(item.status)} variant={getReviewStatusVariant(item.status)}
label={statusLabels[item.status] || item.status} label={reviewStatusLabels[item.status] || item.status}
/> />
) )
} }
+23
View File
@@ -26,3 +26,26 @@ export const useUpdateReviewStatus = () => {
}, },
}); });
}; };
export const useReplyToReview = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, comment }: { id: string; comment: string }) =>
api.replyToReview(id, comment),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ["reviews"] });
queryClient.invalidateQueries({ queryKey: ["review", variables.id] });
},
});
};
export const useDeleteReviewReply = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.deleteReviewReply(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: ["reviews"] });
queryClient.invalidateQueries({ queryKey: ["review", id] });
},
});
};
+21 -1
View File
@@ -1,5 +1,9 @@
import axios from "@/config/axios"; import axios from "@/config/axios";
import type { GetReviewsResponse, GetReviewByIdResponse } from "../types/Types"; import type {
GetReviewsResponse,
GetReviewByIdResponse,
ReplyReviewResponse,
} from "../types/Types";
export const getReviews = async ( export const getReviews = async (
params?: Record<string, string | number> params?: Record<string, string | number>
@@ -23,3 +27,19 @@ export const updateReviewStatus = async (id: string, status: string) => {
const { data } = await axios.patch(`/admin/reviews/${id}/status`, { status }); const { data } = await axios.patch(`/admin/reviews/${id}/status`, { status });
return data; return data;
}; };
export const replyToReview = async (
id: string,
comment: string
): Promise<ReplyReviewResponse> => {
const { data } = await axios.post<ReplyReviewResponse>(
`/admin/reviews/${id}/reply`,
{ comment }
);
return data;
};
export const deleteReviewReply = async (id: string) => {
const { data } = await axios.delete(`/admin/reviews/${id}/reply`);
return data;
};
+26 -18
View File
@@ -10,24 +10,17 @@ export interface ReviewFood {
category: string; category: string;
title: string; title: string;
desc: string; desc: string;
content: string | null; content: string[];
price: number; price: number;
points: number | null; order: number;
order: number | null; prepareTime: number;
prepareTime: number | null; weekDays: number[];
sat: boolean; mealTypes: string[];
sun: boolean;
mon: boolean;
breakfast: boolean;
noon: boolean;
dinner: boolean;
stock: number;
stockDefault: number;
isActive: boolean; isActive: boolean;
images: string[]; images: string[];
inPlaceServe: boolean; inPlaceServe: boolean;
pickupServe: boolean; pickupServe: boolean;
rate: number; score: number;
discount: number; discount: number;
isSpecialOffer: boolean; isSpecialOffer: boolean;
} }
@@ -44,8 +37,7 @@ export interface ReviewUser {
referrer: string | null; referrer: string | null;
isActive: boolean; isActive: boolean;
gender: boolean; gender: boolean;
wallet: number; avatarUrl: string | null;
points: number;
phone: string; phone: string;
} }
@@ -75,6 +67,16 @@ export interface ReviewOrder {
paymentStatus: string; paymentStatus: string;
} }
export interface ReviewAdmin {
id: string;
createdAt?: string;
updatedAt?: string;
deletedAt?: string | null;
firstName?: string;
lastName?: string;
phone?: string;
}
export interface Review extends RowDataType { export interface Review extends RowDataType {
id: string; id: string;
createdAt: string; createdAt: string;
@@ -95,15 +97,21 @@ export interface ReviewDetail {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
deletedAt: string | null; deletedAt: string | null;
order: ReviewOrder; isBuyer: boolean;
order?: ReviewOrder;
food: ReviewFood; food: ReviewFood;
user: ReviewUser; user?: ReviewUser | null;
comment: string; comment: string;
rating: number; rating: number;
positivePoints: string[]; positivePoints: string[];
negativePoints: string[]; negativePoints: string[];
status: string; status: string;
parent?: string | { id: string } | null;
admin?: ReviewAdmin | null;
} }
export type ReviewReply = ReviewDetail;
export type GetReviewsResponse = IResponse<Review[]>; export type GetReviewsResponse = IResponse<Review[]>;
export type GetReviewByIdResponse = IResponse<ReviewDetail>; export type GetReviewByIdResponse = IResponse<ReviewDetail[]>;
export type ReplyReviewResponse = IResponse<ReviewReply>;
+25
View File
@@ -0,0 +1,25 @@
export const getReviewStatusVariant = (
status: string
): "success" | "error" | "warning" | "info" | "pending" => {
const variantMap: Record<
string,
"success" | "error" | "warning" | "info" | "pending"
> = {
pending: "pending",
approved: "success",
rejected: "error",
};
return variantMap[status] || "pending";
};
export const reviewStatusLabels: Record<string, string> = {
pending: "در انتظار",
approved: "تایید شده",
rejected: "رد شده",
};
export const reviewStatusOptions = [
{ value: "pending", label: "در انتظار" },
{ value: "approved", label: "تایید شده" },
{ value: "rejected", label: "رد شده" },
];
+3
View File
@@ -6,6 +6,9 @@ export const enum PermissionEnum {
// Order Management // Order Management
MANAGE_ORDERS = "manage_orders", MANAGE_ORDERS = "manage_orders",
VIEW_ORDER_REPORTS = "view_order_reports",
MANAGE_SHIFTS = "manage_shifts",
VIEW_ALL_SHIFTS = "view_all_shifts",
// User & Admin Management // User & Admin Management
MANAGE_ADMINS = "manage_admins", MANAGE_ADMINS = "manage_admins",
+69 -1
View File
@@ -4,6 +4,7 @@ import Input from '@/components/Input'
import Textarea from '@/components/Textarea' import Textarea from '@/components/Textarea'
import UploadBox from '@/components/UploadBox' import UploadBox from '@/components/UploadBox'
import DefaulModal from '@/components/DefaulModal' import DefaulModal from '@/components/DefaulModal'
import SwitchComponent from '@/components/Switch'
import LocationPicker from './components/LocationPicker' import LocationPicker from './components/LocationPicker'
import LocationInput from './components/LocationInput' import LocationInput from './components/LocationInput'
import { TickCircle } from 'iconsax-react' import { TickCircle } from 'iconsax-react'
@@ -27,10 +28,31 @@ const validationSchema = yup.object({
.min(1, 'حداقل یک شماره تماس الزامی است') .min(1, 'حداقل یک شماره تماس الزامی است')
.of(yup.string().required('شماره تماس الزامی است')), .of(yup.string().required('شماره تماس الزامی است')),
instagram: yup.string().url('لینک اینستاگرام معتبر نیست').nullable(), instagram: yup.string().url('لینک اینستاگرام معتبر نیست').nullable(),
domain: yup
.string()
.nullable()
.test('domain-or-url', 'آدرس وب‌سایت معتبر نیست', (value) => {
if (!value) return true
const trimmed = value.trim()
if (/^https?:\/\//i.test(trimmed)) {
try {
new URL(trimmed)
return true
} catch {
return false
}
}
return /^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(\/.*)?$/.test(trimmed)
}),
address: yup.string().required('آدرس الزامی است'), address: yup.string().required('آدرس الزامی است'),
latitude: yup.number().required('انتخاب موقعیت روی نقشه الزامی است'), latitude: yup.number().required('انتخاب موقعیت روی نقشه الزامی است'),
longitude: yup.number().required('انتخاب موقعیت روی نقشه الزامی است'), longitude: yup.number().required('انتخاب موقعیت روی نقشه الزامی است'),
description: yup.string().nullable(), description: yup.string().nullable(),
establishedYear: yup
.number()
.nullable()
.transform((value, originalValue) => (originalValue === '' || originalValue === null ? null : value))
.typeError('سال تاسیس باید عدد باشد'),
}) })
const GeneralSettings: FC = () => { const GeneralSettings: FC = () => {
@@ -46,12 +68,15 @@ const GeneralSettings: FC = () => {
menuColor: '#000000', menuColor: '#000000',
phones: [''], phones: [''],
instagram: '', instagram: '',
domain: '',
address: '', address: '',
latitude: 0, latitude: 0,
longitude: 0, longitude: 0,
description: '', description: '',
logo: '', logo: '',
establishedYear: null,
serviceArea: undefined, serviceArea: undefined,
enableAiChat: false,
}, },
validationSchema, validationSchema,
onSubmit: (values) => { onSubmit: (values) => {
@@ -104,12 +129,15 @@ const GeneralSettings: FC = () => {
? [restaurantData.phone] ? [restaurantData.phone]
: [''], : [''],
instagram: restaurantData.instagram || '', instagram: restaurantData.instagram || '',
domain: restaurantData.domain || '',
address: restaurantData.address || '', address: restaurantData.address || '',
latitude: restaurantData.latitude || 0, latitude: restaurantData.latitude || 0,
longitude: restaurantData.longitude || 0, longitude: restaurantData.longitude || 0,
description: restaurantData.description || '', description: restaurantData.description || '',
logo: restaurantData.logo || '', logo: restaurantData.logo || '',
establishedYear: restaurantData.establishedYear ?? null,
serviceArea: restaurantData.serviceArea || undefined, serviceArea: restaurantData.serviceArea || undefined,
enableAiChat: restaurantData.enableAiChat ?? false,
}) })
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -148,6 +176,22 @@ const GeneralSettings: FC = () => {
/> />
</div> </div>
<div className='mt-8'>
<Input
label='سال تاسیس'
name='establishedYear'
type='number'
value={formik.values.establishedYear ?? ''}
onChange={(e) => {
const value = e.target.value
formik.setFieldValue('establishedYear', value === '' ? null : Number(value))
}}
onBlur={formik.handleBlur}
error_text={formik.touched.establishedYear && formik.errors.establishedYear ? formik.errors.establishedYear : undefined}
isNotRequired
/>
</div>
<div className='mt-8'> <div className='mt-8'>
<UploadBox <UploadBox
label='لوگو' label='لوگو'
@@ -180,6 +224,22 @@ const GeneralSettings: FC = () => {
/> />
</div> </div>
<div className='mt-8'>
<Input
label='آدرس وب‌سایت (دامنه)'
name='domain'
value={formik.values.domain || ''}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error_text={formik.touched.domain && formik.errors.domain ? formik.errors.domain : undefined}
placeholder='example.com یا https://example.com'
isNotRequired
/>
<p className='mt-2 text-xs text-description'>
در صورتی که دامنه خریداری کرده اید آدرس دامنه خریداری شده رو وارد کنید
</p>
</div>
<div className='mt-8'> <div className='mt-8'>
<Textarea <Textarea
label='آدرس' label='آدرس'
@@ -220,7 +280,15 @@ const GeneralSettings: FC = () => {
onPolygonSelect={(polygon) => formik.setFieldValue('serviceArea', polygon)} onPolygonSelect={(polygon) => formik.setFieldValue('serviceArea', polygon)}
/> />
</div> </div>
{/* </div> */}
<div className='mt-8'>
<div className='text-sm mb-3'>چت هوش مصنوعی</div>
<SwitchComponent
active={formik.values.enableAiChat ?? false}
onChange={(value) => formik.setFieldValue('enableAiChat', value)}
label={formik.values.enableAiChat ? 'فعال' : 'غیرفعال'}
/>
</div>
<div className='mt-8 flex justify-end'> <div className='mt-8 flex justify-end'>
<Button <Button
+8 -1
View File
@@ -1,8 +1,9 @@
import Tabs from "@/components/Tabs"; import Tabs from "@/components/Tabs";
import { Brush2, Money2, NotificationStatus, Setting4 } from "iconsax-react"; import { Brush2, Money2, NotificationStatus, ScanBarcode, Setting4 } from "iconsax-react";
import { useState, type FC } from "react"; import { useState, type FC } from "react";
import NotificationsList from "../notifications/List"; import NotificationsList from "../notifications/List";
import DesignSettings from "./components/DesignSettings"; import DesignSettings from "./components/DesignSettings";
import QrCodeSettings from "./components/QrCodeSettings";
import ScoreSetting from "./components/ScoreSetting"; import ScoreSetting from "./components/ScoreSetting";
import { SettingTabEnum } from "./enum/Enum"; import { SettingTabEnum } from "./enum/Enum";
import GeneralSettings from "./GeneralSettings"; import GeneralSettings from "./GeneralSettings";
@@ -37,6 +38,11 @@ const Setting: FC = () => {
label: "تنظیمات طراحی", label: "تنظیمات طراحی",
value: SettingTabEnum.DESIGN, value: SettingTabEnum.DESIGN,
}, },
{
icon: <ScanBarcode color={activeTab === SettingTabEnum.QR_CODE ? "black" : "#8C90A3"} size={24} />,
label: "QR کد",
value: SettingTabEnum.QR_CODE,
},
]} ]}
onChange={(value: string) => setActiveTab(value as SettingTabEnum)} onChange={(value: string) => setActiveTab(value as SettingTabEnum)}
active={activeTab} active={activeTab}
@@ -48,6 +54,7 @@ const Setting: FC = () => {
{activeTab === SettingTabEnum.FINANCIAL && <ScoreSetting />} {activeTab === SettingTabEnum.FINANCIAL && <ScoreSetting />}
{activeTab === SettingTabEnum.NOTIFICATION && <NotificationsList />} {activeTab === SettingTabEnum.NOTIFICATION && <NotificationsList />}
{activeTab === SettingTabEnum.DESIGN && <DesignSettings />} {activeTab === SettingTabEnum.DESIGN && <DesignSettings />}
{activeTab === SettingTabEnum.QR_CODE && <QrCodeSettings />}
</div> </div>
</div> </div>
); );
@@ -0,0 +1,115 @@
import Button from "@/components/Button";
import { MENU_SITE_ORIGIN } from "@/config/menuPreview";
import { DocumentDownload } from "iconsax-react";
import { QRCodeCanvas } from "qrcode.react";
import { useMemo, useRef, type FC } from "react";
import { toast } from "react-toastify";
import { useGetRestaurant } from "../hooks/useSettingData";
const normalizeMenuUrl = (domain?: string | null, slug?: string | null): string => {
const trimmedDomain = domain?.trim();
if (trimmedDomain) {
if (/^https?:\/\//i.test(trimmedDomain)) {
return trimmedDomain.replace(/\/+$/, "");
}
return `https://${trimmedDomain.replace(/\/+$/, "")}`;
}
if (slug) {
return `${MENU_SITE_ORIGIN}/${slug}`;
}
return "";
};
const QrCodeSettings: FC = () => {
const { data: restaurant, isLoading } = useGetRestaurant();
const canvasWrapperRef = useRef<HTMLDivElement>(null);
const menuUrl = useMemo(
() => normalizeMenuUrl(restaurant?.data?.domain, restaurant?.data?.slug),
[restaurant?.data?.domain, restaurant?.data?.slug],
);
const handleDownload = () => {
const canvas = canvasWrapperRef.current?.querySelector("canvas");
if (!canvas) {
toast.error("QR کد آماده نیست");
return;
}
const link = document.createElement("a");
const fileName = restaurant?.data?.slug || restaurant?.data?.name || "menu";
link.download = `qr-${fileName}.png`;
link.href = canvas.toDataURL("image/png");
link.click();
toast.success("QR کد با موفقیت دانلود شد");
};
if (isLoading) {
return (
<div className="bg-white rounded-4xl p-8">
<div className="text-sm text-gray-500">در حال بارگذاری...</div>
</div>
);
}
if (!menuUrl) {
return (
<div className="bg-white rounded-4xl p-8">
<div className="text-lg font-light">QR کد منو</div>
<p className="mt-4 text-sm text-gray-500">
برای ساخت QR کد، ابتدا دامنه وبسایت را در تنظیمات عمومی وارد کنید یا از اسلاگ رستوران استفاده شود.
</p>
</div>
);
}
return (
<div className="bg-white rounded-4xl p-8">
<div className="text-lg font-light">QR کد منو</div>
<p className="mt-2 text-sm text-gray-500">
این QR کد به آدرس منوی دیجیتال رستوران شما لینک میشود. میتوانید آن را دانلود و چاپ کنید.
</p>
<div className="mt-8 flex flex-col items-center gap-6">
<div
ref={canvasWrapperRef}
className="rounded-2xl border border-gray-100 bg-white p-6 shadow-sm"
>
<QRCodeCanvas
value={menuUrl}
size={256}
level="H"
includeMargin
bgColor="#FFFFFF"
fgColor="#000000"
/>
</div>
<div className="w-full max-w-md text-center">
<div className="text-xs text-gray-400 mb-1">آدرس منو</div>
<a
href={menuUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary break-all hover:underline"
dir="ltr"
>
{menuUrl}
</a>
</div>
<Button type="button" className="w-fit px-6" onClick={handleDownload}>
<div className="flex gap-2 items-center">
<DocumentDownload size={20} color="#fff" />
<span>دانلود QR کد</span>
</div>
</Button>
</div>
</div>
);
};
export default QrCodeSettings;
+1
View File
@@ -5,4 +5,5 @@ export const enum SettingTabEnum {
DELIVERY = "delivery", DELIVERY = "delivery",
NOTIFICATION = "notification", NOTIFICATION = "notification",
DESIGN = "design", DESIGN = "design",
QR_CODE = "qr_code",
} }
+4
View File
@@ -17,7 +17,10 @@ export type UpdateRestaurantType = {
latitude?: number; latitude?: number;
longitude?: number; longitude?: number;
description?: string; description?: string;
establishedYear?: number | null;
domain?: string;
serviceArea?: GeoJSONPolygon; serviceArea?: GeoJSONPolygon;
enableAiChat?: boolean;
score?: { score?: {
purchaseAmount: string; purchaseAmount: string;
purchaseScore: string; purchaseScore: string;
@@ -49,6 +52,7 @@ export type Restaurant = {
longitude: number | null; longitude: number | null;
serviceArea: GeoJSONPolygon; serviceArea: GeoJSONPolygon;
isActive: boolean; isActive: boolean;
enableAiChat: boolean;
establishedYear: number | null; establishedYear: number | null;
phoneNumber: string | null; phoneNumber: string | null;
phone?: string; phone?: string;
+4
View File
@@ -19,6 +19,7 @@ import CreateOrder from '@/pages/orders/Create'
import OrderDetails from '@/pages/orders/Details' import OrderDetails from '@/pages/orders/Details'
import FoodOrderReport from '@/pages/orders/FoodOrderReport' import FoodOrderReport from '@/pages/orders/FoodOrderReport'
import DailyOrderReport from '@/pages/orders/DailyOrderReport' import DailyOrderReport from '@/pages/orders/DailyOrderReport'
import CashShiftsList from '@/pages/cashShifts/List'
import CommentsList from '@/pages/comments/List' import CommentsList from '@/pages/comments/List'
import CommentsDetails from '@/pages/comments/Details' import CommentsDetails from '@/pages/comments/Details'
import ScheduleList from '@/pages/schedule/List' import ScheduleList from '@/pages/schedule/List'
@@ -56,6 +57,7 @@ import UpdateUserGroup from '@/pages/customers/groups/Update'
import CampaignsList from '@/pages/customers/campaigns/List' import CampaignsList from '@/pages/customers/campaigns/List'
import CreateCampaign from '@/pages/customers/campaigns/Create' import CreateCampaign from '@/pages/customers/campaigns/Create'
import WalletTransactions from '@/pages/wallet/Transactions' import WalletTransactions from '@/pages/wallet/Transactions'
import AiChatsList from '@/pages/aiChats/List'
const MainRouter: FC = () => { const MainRouter: FC = () => {
const { hasSubMenu } = useSharedStore() const { hasSubMenu } = useSharedStore()
@@ -93,6 +95,7 @@ const MainRouter: FC = () => {
<Route path={Pages.orders.add} element={<CreateOrder />} /> <Route path={Pages.orders.add} element={<CreateOrder />} />
<Route path={Pages.orders.foodReport} element={<FoodOrderReport />} /> <Route path={Pages.orders.foodReport} element={<FoodOrderReport />} />
<Route path={Pages.orders.dailyReport} element={<DailyOrderReport />} /> <Route path={Pages.orders.dailyReport} element={<DailyOrderReport />} />
<Route path={Pages.orders.cashShifts} element={<CashShiftsList />} />
<Route path={Pages.orders.detail + ':id'} element={<OrderDetails />} /> <Route path={Pages.orders.detail + ':id'} element={<OrderDetails />} />
<Route path={Pages.comments.list} element={<CommentsList />} /> <Route path={Pages.comments.list} element={<CommentsList />} />
<Route path={Pages.comments.detail + ':id'} element={<CommentsDetails />} /> <Route path={Pages.comments.detail + ':id'} element={<CommentsDetails />} />
@@ -135,6 +138,7 @@ const MainRouter: FC = () => {
<Route path={Pages.statistics.list} element={<Statistics />} /> <Route path={Pages.statistics.list} element={<Statistics />} />
<Route path={Pages.wallet.transactions} element={<WalletTransactions />} /> <Route path={Pages.wallet.transactions} element={<WalletTransactions />} />
<Route path={Pages.aiChats.list} element={<AiChatsList />} />
</Routes> </Routes>
</div> </div>
</div> </div>
+13 -3
View File
@@ -1,6 +1,6 @@
import { usePermissions } from '@/hooks/usePermissions' import { usePermissions } from '@/hooks/usePermissions'
import { PermissionEnum } from '@/pages/roles/enum/Enum' import { PermissionEnum } from '@/pages/roles/enum/Enum'
import { Calendar, Card, DocumentText, Element3, Gallery, Home2, Logout, NotificationBing, People, Security, SecurityUser, Setting2, SmsEdit, Star1, TicketDiscount, TruckFast } from 'iconsax-react' import { Calendar, Card, DocumentText, Element3, Gallery, Home2, Logout, MessageQuestion, NotificationBing, People, Security, SecurityUser, Setting2, SmsEdit, Star1, TicketDiscount, TruckFast } from 'iconsax-react'
import { type FC, useEffect } from 'react' import { type FC, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Skeleton from 'react-loading-skeleton' import Skeleton from 'react-loading-skeleton'
@@ -102,12 +102,12 @@ const SideBar: FC = () => {
/> />
)} )}
{checkPermission(PermissionEnum.MANAGE_ORDERS) && ( {(checkPermission(PermissionEnum.MANAGE_ORDERS) || checkPermission(PermissionEnum.VIEW_ORDER_REPORTS)) && (
<SideBarItem <SideBarItem
icon={<DocumentText variant={isActive('orders') ? 'Bold' : 'Outline'} color={isActive('orders') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />} icon={<DocumentText variant={isActive('orders') ? 'Bold' : 'Outline'} color={isActive('orders') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
title={t('sidebar.orders')} title={t('sidebar.orders')}
isActive={isActive('orders')} isActive={isActive('orders')}
link={Pages.orders.list} link={checkPermission(PermissionEnum.MANAGE_ORDERS) ? Pages.orders.list : Pages.orders.foodReport}
name='orders' name='orders'
activeName='orders' activeName='orders'
/> />
@@ -123,6 +123,16 @@ const SideBar: FC = () => {
/> />
)} )}
{checkPermission(PermissionEnum.MANAGE_FOODS) && (
<SideBarItem
icon={<MessageQuestion variant={isActive('ai-chats') ? 'Bold' : 'Outline'} color={isActive('ai-chats') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
title={t('sidebar.ai_chats')}
isActive={isActive('ai-chats')}
link={Pages.aiChats.list}
activeName='ai-chats'
/>
)}
{checkPermission(PermissionEnum.MANAGE_SCHEDULES) && ( {checkPermission(PermissionEnum.MANAGE_SCHEDULES) && (
<SideBarItem <SideBarItem
icon={<Calendar variant={isActive('schedule') ? 'Bold' : 'Outline'} color={isActive('schedule') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />} icon={<Calendar variant={isActive('schedule') ? 'Bold' : 'Outline'} color={isActive('schedule') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
+38 -20
View File
@@ -4,11 +4,14 @@ import { useTranslation } from 'react-i18next'
import SubMenuItem from './SubMenuItem' import SubMenuItem from './SubMenuItem'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { Pages } from '../../config/Pages' import { Pages } from '../../config/Pages'
import { usePermissions } from '@/hooks/usePermissions'
import { PermissionEnum } from '@/pages/roles/enum/Enum'
const OrdersSubMenu: FC = () => { const OrdersSubMenu: FC = () => {
const location = useLocation() const location = useLocation()
const { t } = useTranslation('global') const { t } = useTranslation('global')
const { checkPermission } = usePermissions()
const isActive = (name: string) => { const isActive = (name: string) => {
return location.pathname.includes(name) return location.pathname.includes(name)
@@ -28,26 +31,41 @@ const OrdersSubMenu: FC = () => {
</div> </div>
<div className='flex flex-col mt-10 gap-4'> <div className='flex flex-col mt-10 gap-4'>
<SubMenuItem {checkPermission(PermissionEnum.MANAGE_ORDERS) && (
title={t('submenu.orders_list')} <SubMenuItem
isActive={isActive('list')} title={t('submenu.orders_list')}
link={Pages.orders.list} isActive={isActive('list')}
/> link={Pages.orders.list}
<SubMenuItem />
title={t('submenu.orders_food_report')} )}
isActive={isActive('report/by-food')} {checkPermission(PermissionEnum.VIEW_ORDER_REPORTS) && (
link={Pages.orders.foodReport} <SubMenuItem
/> title={t('submenu.orders_food_report')}
<SubMenuItem isActive={isActive('report/by-food')}
title={t('submenu.orders_daily_report')} link={Pages.orders.foodReport}
isActive={isActive('report/by-day')} />
link={Pages.orders.dailyReport} )}
/> {checkPermission(PermissionEnum.VIEW_ORDER_REPORTS) && (
<SubMenuItem <SubMenuItem
title={t('submenu.add_order')} title={t('submenu.orders_daily_report')}
isActive={isActive('add')} isActive={isActive('report/by-day')}
link={Pages.orders.add} link={Pages.orders.dailyReport}
/> />
)}
{checkPermission(PermissionEnum.MANAGE_ORDERS) && (
<SubMenuItem
title={t('submenu.cash_shifts')}
isActive={isActive('cash-shifts')}
link={Pages.orders.cashShifts}
/>
)}
{checkPermission(PermissionEnum.MANAGE_ORDERS) && (
<SubMenuItem
title={t('submenu.add_order')}
isActive={isActive('add')}
link={Pages.orders.add}
/>
)}
</div> </div>
</div> </div>
) )
-1
View File
@@ -25,7 +25,6 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true, "noUncheckedSideEffectImports": true,
/* Path Mapping */ /* Path Mapping */
"baseUrl": ".",
"paths": { "paths": {
"@/*": [ "@/*": [
"./src/*" "./src/*"
-1
View File
@@ -5,7 +5,6 @@
{ "path": "./tsconfig.node.json" } { "path": "./tsconfig.node.json" }
], ],
"compilerOptions": { "compilerOptions": {
"baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }