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",
"leaflet": "^1.9.4",
"moment-jalaali": "^0.10.4",
"qrcode.react": "^4.2.0",
"rc-rate": "^2.13.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
@@ -46,6 +47,7 @@
"@types/leaflet": "^1.9.21",
"@types/moment-jalaali": "^0.7.9",
"@types/node": "^24.10.1",
"@types/qrcode.react": "^1.0.5",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0",
@@ -2383,6 +2385,16 @@
"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": {
"version": "19.1.11",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.11.tgz",
@@ -4987,6 +4999,15 @@
"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": {
"version": "1.2.3",
"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",
"leaflet": "^1.9.4",
"moment-jalaali": "^0.10.4",
"qrcode.react": "^4.2.0",
"rc-rate": "^2.13.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
@@ -48,6 +49,7 @@
"@types/leaflet": "^1.9.21",
"@types/moment-jalaali": "^0.7.9",
"@types/node": "^24.10.1",
"@types/qrcode.react": "^1.0.5",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0",
Binary file not shown.
+50 -17
View File
@@ -1,11 +1,13 @@
import { getToken } from '@/config/func'
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 { NotificationSubject } from '@/types/notification.types'
import NotificationItem from '@/components/NotificationItem'
import { useQueryClient } from '@tanstack/react-query'
const NOTIFICATION_SOUND_SRC = '/Qoddusi_improved.mp3'
interface NotificationItemData extends NotificationPayload {
id: string
timestamp: number
@@ -13,13 +15,26 @@ interface NotificationItemData extends NotificationPayload {
const Notification: FC = () => {
const [notifications, setNotifications] = useState<NotificationItemData[]>([])
const [audioEnabled, setAudioEnabled] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null)
const audioUnlockedRef = useRef(false)
const queryClient = useQueryClient()
const token = getToken()
const socketUrl = `${import.meta.env.VITE_SOCKET_URL}/notifications`
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(() => {
if (error) {
console.error('❌ Notification: خطا در اتصال:', error)
@@ -31,35 +46,54 @@ const Notification: FC = () => {
}, [connect])
useEffect(() => {
const enableAudio = async () => {
const unlockAudio = async () => {
if (audioUnlockedRef.current || !audioRef.current) {
return
}
try {
const audio = new Audio('/mixkit-correct-answer-tone-2870.wav')
audio.volume = 0
const audio = audioRef.current
audio.muted = true
await audio.play()
audio.pause()
audio.currentTime = 0
setAudioEnabled(true)
audio.muted = false
audioUnlockedRef.current = true
} catch {
console.warn('صدا هنوز فعال نشده است')
// Browser may still block until a later gesture — keep listening.
}
}
const handleUserInteraction = () => {
if (!audioEnabled) {
enableAudio()
}
void unlockAudio()
}
window.addEventListener('click', handleUserInteraction, { once: true })
window.addEventListener('touchstart', handleUserInteraction, { once: true })
window.addEventListener('keydown', handleUserInteraction, { once: true })
window.addEventListener('click', handleUserInteraction)
window.addEventListener('touchstart', handleUserInteraction)
window.addEventListener('keydown', handleUserInteraction)
return () => {
window.removeEventListener('click', handleUserInteraction)
window.removeEventListener('touchstart', 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 newNotification: NotificationItemData = {
@@ -68,14 +102,14 @@ const Notification: FC = () => {
timestamp: Date.now(),
}
setNotifications((prev) => [...prev, newNotification])
playNotificationSound()
// Refetch بر اساس نوع نوتیفیکیشن
if (data.subject === NotificationSubject.PAGER_CREATED) {
queryClient.invalidateQueries({ queryKey: ['pagers'] })
} else if (data.subject === NotificationSubject.ORDER_CREATED) {
queryClient.invalidateQueries({ queryKey: ['orders'] })
}
}, [queryClient])
}, [playNotificationSound, queryClient])
const handleRemoveNotification = useCallback((id: string) => {
setNotifications((prev) => prev.filter((notif) => notif.id !== id))
@@ -105,7 +139,6 @@ const Notification: FC = () => {
subject={notification.subject}
body={notification.body}
onRemove={handleRemoveNotification}
audioEnabled={audioEnabled}
/>
))}
</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 Button from '@/components/Button'
import { NotificationSubject } from '@/types/notification.types'
@@ -8,32 +8,9 @@ interface NotificationItemProps {
subject: string
body: string
onRemove: (id: string) => void
audioEnabled: boolean
}
const NotificationItem: FC<NotificationItemProps> = ({ id, subject, body, onRemove, audioEnabled }) => {
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 NotificationItem: FC<NotificationItemProps> = ({ id, subject, body, onRemove }) => {
const getNotificationConfig = (subjectType: string) => {
switch (subjectType) {
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 { clx } from '../helpers/utils'
@@ -6,34 +6,32 @@ type Props = {
label?: string,
error_text?: string
isNotRequired?: boolean
} & InputHTMLAttributes<HTMLTextAreaElement>
} & TextareaHTMLAttributes<HTMLTextAreaElement>
const Textarea: FC<Props> = (props: Props) => {
const Textarea: FC<Props> = ({ label, error_text, isNotRequired, className, ...textareaProps }) => {
return (
<div className='w-full relative'>
{
props.label &&
label &&
<div className='flex items-center gap-1'>
<div className='text-sm'>{props.label}</div>
{props.isNotRequired && (
<div className='text-sm'>{label}</div>
{isNotRequired && (
<span className='text-xs text-description'>(اختیاری)</span>
)}
</div>
}
<textarea
{...props}
{...textareaProps}
className={clx(
'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 errorText={props.error_text} />
error_text &&
<Error errorText={error_text} />
}
</div>
+4
View File
@@ -27,6 +27,7 @@ export const Pages = {
detail: "/orders/detail/",
foodReport: "/orders/report/by-food",
dailyReport: "/orders/report/by-day",
cashShifts: "/orders/cash-shifts",
},
customers: {
list: "/customers/list",
@@ -99,4 +100,7 @@ export const Pages = {
wallet: {
transactions: "/wallet/transactions",
},
aiChats: {
list: "/ai-chats/list",
},
};
+37 -4
View File
@@ -69,7 +69,8 @@
"pagers": "پیجرها",
"notifications": "تنظیمات اعلانات",
"statistics": "گزارشات",
"sliders": "اسلایدرها"
"sliders": "اسلایدرها",
"ai_chats": "چت‌های AI"
},
"shipment": {
"dineIn": "سرو در رستوران",
@@ -100,6 +101,7 @@
"orders_list": "لیست سفارشات",
"orders_food_report": "گزارش فروش به تفکیک غذا",
"orders_daily_report": "گزارش فروش روزانه",
"cash_shifts": "شیفت‌های صندوق",
"add_order": "افزودن سفارش",
"discount_list": "لیست تخفیف",
"add_discount": "افزودن تخفیف",
@@ -889,11 +891,9 @@
"payment_methods_update": "ویرایش روش پرداخت"
},
"order_status": {
"pendingPayment": "در انتظار پرداخت",
"paid": "پرداخت شده",
"new": "جدید",
"confirmed": "تایید شده",
"preparing": "در حال آماده سازی",
"ready": "آماده",
"shipped": "ارسال شده",
"completed": "تکمیل شده",
"canceled": "لغو شده",
@@ -929,5 +929,38 @@
},
"change_status": "تغییر وضعیت",
"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 * as Yup from 'yup'
import { TickCircle } from 'iconsax-react'
@@ -6,11 +6,13 @@ import { toast } from 'react-toastify'
import { useNavigate } from 'react-router-dom'
import Button from '@/components/Button'
import Input from '@/components/Input'
import MultiSelect from '@/components/MultiSelect'
import Select from '@/components/Select'
import DatePicker from '@/components/DatePicker'
import UploadBox from '@/components/UploadBox'
import { useCreateUser } from './hooks/useUsersData'
import { useSingleUpload } from '@/pages/uploader/hooks/useUploaderData'
import { useGetUserGroups } from '@/pages/customers/groups/hooks/useUserGroupData'
import type { CreateUserType } from './types/Types'
import { Pages } from '@/config/Pages'
import type { ErrorType } from '@/helpers/types'
@@ -23,6 +25,7 @@ type CreateCustomerFormValues = {
birthDate: string
marriageDate: string
gender: string
groupIds: string[]
}
const buildCreateUserPayload = (
@@ -39,6 +42,7 @@ const buildCreateUserPayload = (
if (values.marriageDate) payload.marriageDate = values.marriageDate
if (values.gender !== '') payload.gender = values.gender === 'true'
if (avatarUrl) payload.avatarUrl = avatarUrl
if (values.groupIds.length > 0) payload.groupIds = values.groupIds
return payload
}
@@ -47,9 +51,19 @@ const CreateCustomer: FC = () => {
const navigate = useNavigate()
const { mutate: createUser, isPending } = useCreateUser()
const { mutate: singleUpload, isPending: isUploading } = useSingleUpload()
const { data: groupsData } = useGetUserGroups()
const [avatarFile, setAvatarFile] = useState<File[]>([])
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState('')
const groupOptions = useMemo(
() =>
(groupsData?.data || []).map((group) => ({
value: group.id,
label: group.name,
})),
[groupsData],
)
useEffect(() => {
if (avatarFile.length > 0) {
const url = URL.createObjectURL(avatarFile[0])
@@ -67,6 +81,7 @@ const CreateCustomer: FC = () => {
birthDate: '',
marriageDate: '',
gender: '',
groupIds: [],
},
validationSchema: Yup.object().shape({
phone: Yup.string().required('شماره تلفن الزامی است'),
@@ -75,6 +90,7 @@ const CreateCustomer: FC = () => {
birthDate: Yup.string(),
marriageDate: Yup.string(),
gender: Yup.string(),
groupIds: Yup.array().of(Yup.string()),
}),
onSubmit: (values) => {
const submitCustomer = (avatarUrl?: string) => {
@@ -197,6 +213,21 @@ const CreateCustomer: FC = () => {
/>
</div>
<div className='mt-5'>
<MultiSelect
label='گروه‌ها'
items={groupOptions}
value={formik.values.groupIds}
onChange={(values) => formik.setFieldValue('groupIds', values)}
placeholder='انتخاب گروه‌ها'
error_text={
formik.touched.groupIds && formik.errors.groupIds
? String(formik.errors.groupIds)
: ''
}
/>
</div>
<div className='mt-5'>
<UploadBox
label='تصویر پروفایل'
+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 { TickCircle } from 'iconsax-react'
import { toast } from 'react-toastify'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import Button from '@/components/Button'
import Input from '@/components/Input'
import MultiSelect from '@/components/MultiSelect'
import Select from '@/components/Select'
import DatePicker from '@/components/DatePicker'
import UploadBox from '@/components/UploadBox'
import { useSingleUpload } from '@/pages/uploader/hooks/useUploaderData'
import { 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 { Pages } from '@/config/Pages'
import type { ErrorType } from '@/helpers/types'
@@ -21,6 +23,7 @@ type UpdateCustomerFormValues = {
birthDate: string
marriageDate: string
gender: string
groupIds: string[]
}
type CustomerUpdateLocationState = {
@@ -33,11 +36,27 @@ const UpdateCustomer: FC = () => {
const { id } = useParams<{ id: string }>()
const { mutate: updateUser, isPending: isUpdating } = useUpdateUser()
const { mutate: singleUpload, isPending: isUploading } = useSingleUpload()
const { data: userGroupsData } = useGetUserGroupsForUser(id || '')
const { data: allGroupsData } = useGetUserGroups()
const [avatarFile, setAvatarFile] = useState<File[]>([])
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState('')
const customer = (location.state as CustomerUpdateLocationState | null)?.customer
const initialGroupIds = useMemo(
() => (userGroupsData?.data || []).map((group) => group.id),
[userGroupsData],
)
const groupOptions = useMemo(
() =>
(allGroupsData?.data || []).map((group) => ({
value: group.id,
label: group.name,
})),
[allGroupsData],
)
useEffect(() => {
if (!customer?.avatarUrl) {
setAvatarPreviewUrl('')
@@ -64,6 +83,7 @@ const UpdateCustomer: FC = () => {
customer?.gender === undefined || customer?.gender === null
? ''
: String(customer.gender),
groupIds: initialGroupIds,
},
enableReinitialize: true,
onSubmit: (values) => {
@@ -73,7 +93,9 @@ const UpdateCustomer: FC = () => {
}
const submitUpdate = (avatarUrl?: string) => {
const payload: UpdateUserType = {}
const payload: UpdateUserType = {
groupIds: values.groupIds,
}
if (values.firstName.trim()) payload.firstName = values.firstName.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 (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('حداقل یک فیلد را برای ویرایش وارد کنید')
return
}
@@ -188,6 +221,16 @@ const UpdateCustomer: FC = () => {
/>
</div>
<div className='mt-5'>
<MultiSelect
label='گروه‌ها'
items={groupOptions}
value={formik.values.groupIds}
onChange={(values) => formik.setFieldValue('groupIds', values)}
placeholder='انتخاب گروه‌ها'
/>
</div>
<div className='mt-5'>
<UploadBox
label='تصویر پروفایل'
@@ -1,11 +1,18 @@
import Status from "@/components/Status";
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";
const getGroupNames = (groups: Campaign["groups"]) => {
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> = {
@@ -13,6 +20,25 @@ const sentTypeLabels: Record<string, string> = {
sms: "پیامک",
};
const statusLabels: Record<string, string> = {
pending: "در انتظار",
processing: "در حال ارسال",
completed: "تکمیل شده",
failed: "ناموفق",
partial: "ناقص",
};
const getStatusVariant = (status: string): "success" | "error" | "warning" | "info" | "pending" => {
const variantMap: Record<string, "success" | "error" | "warning" | "info" | "pending"> = {
pending: "pending",
processing: "info",
completed: "success",
failed: "error",
partial: "warning",
};
return variantMap[status] || "pending";
};
export const getCampaignTableColumns = (): ColumnType<Campaign>[] => {
return [
{
@@ -40,15 +66,49 @@ export const getCampaignTableColumns = (): ColumnType<Campaign>[] => {
title: "گروه‌ها",
render: (item: Campaign) => getGroupNames(item.groups),
},
{
key: "status",
title: "وضعیت",
render: (item: Campaign) => (
<Status variant={getStatusVariant(item.status)} label={statusLabels[item.status] || item.status} />
),
},
{
key: "totalCount",
title: "تعداد کل",
render: (item: Campaign) => formatFaNumber(item.totalCount),
},
{
key: "sentCount",
title: "ارسال موفق",
render: (item: Campaign) => formatFaNumber(item.sentCount),
},
{
key: "failedCount",
title: "ارسال ناموفق",
render: (item: Campaign) => formatFaNumber(item.failedCount),
},
{
key: "totalCost",
title: "هزینه کل",
render: (item: Campaign) => formatPrice(item.totalCost),
},
{
key: "refunded",
title: "بازگشتی",
render: (item: Campaign) => formatPrice(item.refunded),
},
{
key: "createdAt",
title: "تاریخ ایجاد",
render: (item: Campaign) => formatOptionalDate(item.createdAt),
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 CampaignStatus = "pending" | "processing" | "completed" | "failed" | "partial";
export type CampaignGroup = UserGroup & {
restaurant?: string;
count?: number;
};
export type Campaign = {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
restaurant?: string;
title: string;
groups: UserGroup[] | string[];
groups: CampaignGroup[] | string[];
sentType: CampaignSentType;
discountCode: string;
ocasion: string;
ocasion?: string;
totalCount: number;
totalCost: number;
refunded: number;
sentCount: number;
failedCount: number;
status: CampaignStatus | string;
};
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 = () => {
return useMutation({
mutationFn: api.searchUserByPhone,
@@ -36,8 +44,9 @@ export const useUpdateUser = () => {
return useMutation({
mutationFn: ({ userId, params }: { userId: string; params: UpdateUserType }) =>
api.updateUser(userId, params),
onSuccess: () => {
onSuccess: (_, { userId }) => {
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["user-groups-for-user", userId] });
},
});
};
@@ -5,6 +5,7 @@ import type {
CreateUserResponse,
CreateUserType,
GetCustomersResponse,
GetUserGroupsForUserResponse,
GetUsersParams,
ImportUsersResponse,
SearchUserByPhoneResponse,
@@ -40,6 +41,13 @@ export const updateUser = async (userId: string, params: UpdateUserType) => {
return data;
};
export const getUserGroups = async (userId: string) => {
const { data } = await axios.get<GetUserGroupsForUserResponse>(
`/admin/users/${userId}/groups`,
);
return data;
};
export const createUserAddress = async (
userId: string,
params: CreateUserAddressType,
+5
View File
@@ -1,5 +1,6 @@
import type { IResponse } from "@/types/response.types";
import type { RowDataType } from "@/components/types/TableTypes";
import type { UserGroup } from "@/pages/customers/groups/types/Types";
export type User = {
id: string;
@@ -111,6 +112,7 @@ export type CreateUserType = {
marriageDate?: string;
gender?: boolean;
avatarUrl?: string;
groupIds?: string[];
};
export type CreateUserResult = {
@@ -143,6 +145,9 @@ export type UpdateUserType = {
marriageDate?: string;
gender?: boolean;
avatarUrl?: string;
groupIds?: string[];
};
export type UpdateUserResponse = IResponse<User>;
export type GetUserGroupsForUserResponse = IResponse<UserGroup[]>;
+126 -301
View File
@@ -1,310 +1,135 @@
import { type FC, useState } from 'react'
import { useFormik } from 'formik'
import * as Yup from 'yup'
import { TickCircle } from 'iconsax-react'
import { toast } from 'react-toastify'
import Input from '@/components/Input'
import Select from '@/components/Select'
import Textarea from '@/components/Textarea'
import Button from '@/components/Button'
import { Checkbox } from '@/components/ui/checkbox'
import CreateFoodSidebar from './components/CreateFoodSidebar'
import WeekDaysSection from './components/WeekDaysSection'
import MealTimesSection from './components/MealTimesSection'
import type { CreateFoodType } from './types/Types'
import { useCreateFood, useGetCategories } from './hooks/useFoodData'
import { useMultipleUpload } from '../uploader/hooks/useUploaderData'
import { Pages } from '@/config/Pages'
import type { ErrorType } from '@/helpers/types'
import Button from "@/components/Button";
import type { ErrorType } from "@/helpers/types";
import { useFormik } from "formik";
import { TickCircle } from "iconsax-react";
import { type FC, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import * as Yup from "yup";
import { useMultipleUpload } from "../uploader/hooks/useUploaderData";
import BasicInfoSection from "./components/BasicInfoSection";
import CreateFoodSidebar from "./components/CreateFoodSidebar";
import FoodContentSection from "./components/FoodContentSection";
import MealTimesSection from "./components/MealTimesSection";
import ServiceOptionsSection from "./components/ServiceOptionsSection";
import WeekDaysSection from "./components/WeekDaysSection";
import { useCreateFood, useGetCategories } from "./hooks/useFoodData";
import type { CreateFoodType } from "./types/Types";
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 [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 formik = useFormik<CreateFoodType>({
initialValues: {
title: "",
desc: "",
content: [],
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 => ({
label: category.title,
value: category.id
})) || []
createFood(foodData, {
onSuccess: () => {
toast.success("غذا با موفقیت ایجاد شد");
navigate(-1);
},
onError: (error: ErrorType) => {
toast.error(error?.response?.data?.error?.message[0] || "خطا در ایجاد غذا");
},
});
};
const formik = useFormik<CreateFoodType>({
initialValues: {
title: '',
desc: '',
content: [],
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)
}
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();
}
},
});
createFood(foodData, {
onSuccess: () => {
toast.success('غذا با موفقیت ایجاد شد')
window.location.href = Pages.foods.list
},
onError: (error: ErrorType) => {
toast.error(error?.response?.data?.error?.message[0] || 'خطا در ایجاد غذا')
}
})
}
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>
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>
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 { useFormik } from 'formik'
import * as Yup from 'yup'
import { TickCircle } from 'iconsax-react'
import { toast } from 'react-toastify'
import { useNavigate, useParams } from 'react-router-dom'
import Button from '@/components/Button'
import CreateFoodSidebar from './components/CreateFoodSidebar'
import BasicInfoSection from './components/BasicInfoSection'
import FoodContentSection from './components/FoodContentSection'
import MealTimesSection from './components/MealTimesSection'
import WeekDaysSection from './components/WeekDaysSection'
import ServiceOptionsSection from './components/ServiceOptionsSection'
import type { CreateFoodType } from './types/Types'
import { useUpdateFood, useGetFoodDetails, useGetCategories } from './hooks/useFoodData'
import { useMultipleUpload } from '../uploader/hooks/useUploaderData'
import { Pages } from '@/config/Pages'
import type { ErrorType } from '@/helpers/types'
import { extractErrorMessage } from '@/config/func'
import Button from "@/components/Button";
import { extractErrorMessage } from "@/config/func";
import type { ErrorType } from "@/helpers/types";
import { useFormik } from "formik";
import { TickCircle } from "iconsax-react";
import { type FC, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import * as Yup from "yup";
import { useMultipleUpload } from "../uploader/hooks/useUploaderData";
import BasicInfoSection from "./components/BasicInfoSection";
import CreateFoodSidebar from "./components/CreateFoodSidebar";
import FoodContentSection from "./components/FoodContentSection";
import MealTimesSection from "./components/MealTimesSection";
import ServiceOptionsSection from "./components/ServiceOptionsSection";
import WeekDaysSection from "./components/WeekDaysSection";
import { useGetCategories, useGetFoodDetails, useUpdateFood } from "./hooks/useFoodData";
import type { CreateFoodType } from "./types/Types";
const UpdateFood: FC = () => {
const { id } = useParams<{ id: string }>()
const { data: foodData, isLoading } = useGetFoodDetails(id || '')
const { data: categoriesData } = useGetCategories()
const [isActive, setIsActive] = useState<boolean>(true)
const [isSpecial, setIsSpecial] = useState<boolean>(false)
const [imageFiles, setImageFiles] = useState<File[]>([])
const [existingImages, setExistingImages] = useState<string[]>([])
const { mutate: updateFood, isPending } = useUpdateFood()
const { mutate: multipleUpload, isPending: isUploading } = useMultipleUpload()
const navigate = useNavigate()
const categories = categoriesData?.data?.map(category => ({
label: category.title,
value: category.id
})) || []
const { id } = useParams<{ id: string }>();
const { data: foodData, isLoading } = useGetFoodDetails(id || "");
const { data: categoriesData } = useGetCategories();
const [isActive, setIsActive] = useState<boolean>(true);
const [isSpecial, setIsSpecial] = useState<boolean>(false);
const [imageFiles, setImageFiles] = useState<File[]>([]);
const [existingImages, setExistingImages] = useState<string[]>([]);
const { mutate: updateFood, isPending } = useUpdateFood();
const { mutate: multipleUpload, isPending: isUploading } = useMultipleUpload();
const navigate = useNavigate();
const categories =
categoriesData?.data?.map((category) => ({
label: category.title,
value: category.id,
})) || [];
const food = foodData?.data
const food = foodData?.data;
const formik = useFormik<CreateFoodType>({
initialValues: {
title: '',
desc: '',
title: "",
desc: "",
content: [],
categoryId: '',
categoryId: "",
price: 0,
discount: 0,
prepareTime: 0,
@@ -54,72 +54,73 @@ const UpdateFood: FC = () => {
isSpecialOffer: false,
dailyStock: 0,
availableStock: 0,
order: 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, 'موجودی فعلی باید مثبت باشد'),
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())
mealTypes: Yup.array().of(Yup.string()),
}),
onSubmit: (values) => {
if (!id) {
toast.error('شناسه غذا یافت نشد')
return
toast.error("شناسه غذا یافت نشد");
return;
}
const submitFood = (newImageUrls: string[] = []) => {
const allImages = [...values.images, ...newImageUrls]
const allImages = [...values.images, ...newImageUrls];
const foodData: CreateFoodType = {
...values,
isActive,
isSpecialOffer: isSpecial,
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 }, {
onSuccess: () => {
toast.success('غذا با موفقیت به‌روزرسانی شد')
navigate(Pages.foods.list)
updateFood(
{ id, params: foodData },
{
onSuccess: () => {
toast.success("غذا با موفقیت به‌روزرسانی شد");
navigate(-1);
},
onError: (error: ErrorType) => {
toast.error(extractErrorMessage(error));
},
},
onError: (error: ErrorType) => {
toast.error(extractErrorMessage(error))
}
})
}
);
};
if (imageFiles.length > 0) {
multipleUpload(imageFiles, {
onSuccess: (response) => {
const imageUrls = response?.data?.map(item => item.url) || []
submitFood(imageUrls)
const imageUrls = response?.data?.map((item) => item.url) || [];
submitFood(imageUrls);
},
onError: (error: ErrorType) => {
toast.error(error?.response?.data?.error?.message[0] || 'خطا در آپلود تصاویر')
}
})
toast.error(error?.response?.data?.error?.message[0] || "خطا در آپلود تصاویر");
},
});
} else {
submitFood()
submitFood();
}
}
})
},
});
useEffect(() => {
if (food) {
formik.setValues({
title: food.title || '',
desc: food.desc || '',
title: food.title || "",
desc: food.desc || "",
content: food.content || [],
categoryId: food.category?.id || '',
categoryId: food.category?.id || "",
price: food.price || 0,
discount: food.discount || 0,
prepareTime: food.prepareTime || 0,
@@ -132,59 +133,53 @@ const UpdateFood: FC = () => {
isSpecialOffer: food.isSpecialOffer || false,
dailyStock: food.inventory?.totalStock || 0,
availableStock: food.inventory?.availableStock || 0,
order: food.order || 0
})
setIsActive(food.isActive || false)
setIsSpecial(food.isSpecialOffer || false)
setExistingImages(food.images || [])
order: food.order || 0,
});
setIsActive(food.isActive || false);
setIsSpecial(food.isSpecialOffer || false);
setExistingImages(food.images || []);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [food])
}, [food]);
useEffect(() => {
formik.setFieldValue('images', existingImages)
formik.setFieldValue("images", existingImages);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [existingImages])
}, [existingImages]);
if (isLoading) {
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>
)
);
}
if (!food) {
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>
)
);
}
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 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' />
<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'>
<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} />
@@ -193,7 +188,7 @@ const UpdateFood: FC = () => {
</div>
</div>
<div className='w-full lg:w-auto'>
<div className="w-full lg:w-auto">
<CreateFoodSidebar
isActive={isActive}
isSpecial={isSpecial}
@@ -206,7 +201,7 @@ const UpdateFood: FC = () => {
</div>
</div>
</div>
)
}
);
};
export default UpdateFood
export default UpdateFood;
+51 -2
View File
@@ -1,16 +1,53 @@
import { type FC } from 'react'
import type { FormikProps } from 'formik'
import { Magicpen } from 'iconsax-react'
import Input from '@/components/Input'
import Select from '@/components/Select'
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 { useGenerateFoodDescription } from '../hooks/useFoodData'
type BasicInfoSectionProps = {
formik: FormikProps<CreateFoodType>
categories: Array<{ label: string; value: string }>
}
const parseDescription = (data: { answer?: string } | undefined): string => {
return data?.answer?.trim() || ''
}
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 (
<>
<Input
@@ -94,9 +131,22 @@ const BasicInfoSection: FC<BasicInfoSectionProps> = ({ formik, categories }) =>
</div>
<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
name='desc'
label='توضیحات غذا'
placeholder=''
value={formik.values.desc}
onChange={formik.handleChange}
@@ -120,4 +170,3 @@ const BasicInfoSection: FC<BasicInfoSectionProps> = ({ formik, categories }) =>
}
export default BasicInfoSection
@@ -1,18 +1,70 @@
import { type FC } from 'react'
import type { FormikProps } from 'formik'
import { Magicpen } from 'iconsax-react'
import Input from '@/components/Input'
import Button from '@/components/Button'
import { extractErrorMessage } from '@/config/func'
import { toast } from 'react-toastify'
import type { CreateFoodType } from '../types/Types'
import { useGenerateFoodContent } from '../hooks/useFoodData'
type FoodContentSectionProps = {
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 { 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 (
<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'>
{formik.values.content.map((item, index) => (
<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 {
CreateCategoryType,
CreateFoodType,
GenerateFoodAiParams,
GenerateFoodContentResponse,
GenerateFoodDescriptionResponse,
GetCategoriesResponseType,
GetFoodDetailsResponseType,
GetFoodsParams,
@@ -90,3 +93,23 @@ export const setStockForFood = async (foodId: string, params: SetStockType) => {
);
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;
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 Filters from '@/components/Filters';
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 { useDailyOrderReportFilters } from './hooks/useDailyOrderReportFilters';
import { getDailyOrderReportTableColumns } from './components/DailyOrderReportTableColumns';
@@ -15,6 +18,7 @@ import type { RowDataType } from '@/components/types/TableTypes';
const DailyOrderReport: FC = () => {
const { t } = useTranslation('global');
const { checkPermission, isLoading: isPermissionsLoading } = usePermissions();
const { filters, apiParams, handleFiltersChange } = useDailyOrderReportFilters();
const { data: reportData, isLoading } = useGetDailyOrderReport(apiParams);
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 (
<div className='mt-5'>
<div className='flex justify-between items-center'>
+306 -151
View File
@@ -3,75 +3,76 @@ import {
useGetOrderById,
useVerifyPayment,
useChangeOrderStatus,
useRefundPayment
useRefundPayment,
useAddPayment,
useUpdateOrderFees,
useAddOrderItem,
useUpdateOrderItemQuantity,
useRemoveOrderItem,
useSendPleaseComeSms,
} 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 { Pages } from '@/config/Pages'
import { OrderStatus, PaymentStatusEnum } from './enum/Enum'
import { PaymentMethodEnum } from '@/pages/paymentMethods/enum/Enum'
import { DeliveryMethodEnum } from '@/pages/shipmentMethod/enum/Enum'
import Button from '@/components/Button'
import ModalConfrim from '@/components/ModalConfrim'
import CustomerInfo from './components/CustomerInfo'
import OrderItemsDetails from './components/OrderItemsDetails'
import PaymentsList from './components/PaymentsList'
import PaymentInfo from './components/PaymentInfo'
import PaymentVerificationBox from './components/PaymentVerificationBox'
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 { extractErrorMessage } from '@/config/func'
import { printOrderReceipt } from './print/orderReceiptPrint'
import type { UpdateOrderFeesType } from './types/Types'
const OrderDetails: FC = () => {
const { id } = useParams()
const navigate = useNavigate()
const { data: orderData, isLoading } = useGetOrderById(id!)
const { mutate: verifyPayment, isPending: isVerifyingPayment } = useVerifyPayment()
const { mutate: changeOrderStatus, isPending: isChangingStatus } = useChangeOrderStatus()
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 [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 getStatusColor = (status: string): string => {
const colorMap: Record<string, string> = {
'pendingPayment': '#FFEECC',
'paid': '#E3F2FD',
'preparing': '#FFF3E0',
'ready': '#E3F2FD',
'shipped': '#E3F2FD',
'completed': '#E8F5E9',
'canceled': '#FFEBEE',
pendingPayment: '#FFEECC',
paid: '#E3F2FD',
preparing: '#FFF3E0',
ready: '#E3F2FD',
shipped: '#E3F2FD',
completed: '#E8F5E9',
canceled: '#FFEBEE',
}
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) {
return (
<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 isCreditCardPayment = order.paymentMethod?.method === PaymentMethodEnum.CreditCard
const isPendingPayment = order.status === OrderStatus.PENDING_PAYMENT
const isOrderPaid = order.status === OrderStatus.PAID
const isOrderPaid = Number(order.paidAmount ?? 0) > 0 || order.payments?.some(
(payment) => payment.status === PaymentStatusEnum.Paid
)
const isPreparing = order.status === OrderStatus.PREPARING
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 = [
OrderStatus.DELIVERED_TO_WAITER,
OrderStatus.DELIVERED_TO_RECEPTIONIST,
OrderStatus.SHIPPED,
].includes(order.status as OrderStatus)
// بررسی وضعیت پرداخت از payments array یا paymentStatus
// ابتدا از paymentStatus استفاده می‌کنیم، اگر نبود از اولین payment در payments array استفاده می‌کنیم
const paymentStatus = order.paymentStatus || order.payments?.[0]?.status
// بررسی وضعیت pending (هم enum و هم string literal)
const isPaymentPending = paymentStatus === PaymentStatusEnum.Pending || paymentStatus === 'pending'
const canSendPleaseComeSms = Boolean(order.user?.phone)
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 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 showCancelButton = !isCanceled
const handleVerifyPayment = () => {
const paymentId = order.paymentId || order.payments?.[0]?.id
if (paymentId) {
verifyPayment(paymentId, {
onSuccess: () => {
// Success handled by query invalidation
},
onError: (error) => {
toast.error(extractErrorMessage(error))
}
})
}
const handleVerifyPayment = (paymentId: string) => {
setVerifyingPaymentId(paymentId)
verifyPayment(paymentId, {
onSuccess: () => {
setVerifyingPaymentId(null)
},
onError: (error) => {
setVerifyingPaymentId(null)
toast.error(extractErrorMessage(error))
},
})
}
const handleSendToKitchen = () => {
changeOrderStatus({
orderId: order.id,
status: OrderStatus.PREPARING
}, {
onError: (error) => {
toast.error(extractErrorMessage(error))
changeOrderStatus(
{
orderId: order.id,
status: OrderStatus.PREPARING,
},
{
onError: (error) => {
toast.error(extractErrorMessage(error))
},
}
})
)
}
const handleDeliverToCourier = () => {
changeOrderStatus({
orderId: order.id,
status: OrderStatus.SHIPPED
}, {
onError: (error) => {
toast.error(extractErrorMessage(error))
changeOrderStatus(
{
orderId: order.id,
status: OrderStatus.SHIPPED,
},
{
onError: (error) => {
toast.error(extractErrorMessage(error))
},
}
})
)
}
const handleDeliverToWaiter = () => {
changeOrderStatus({
orderId: order.id,
status: OrderStatus.DELIVERED_TO_WAITER
}, {
onError: (error) => {
toast.error(extractErrorMessage(error))
changeOrderStatus(
{
orderId: order.id,
status: OrderStatus.DELIVERED_TO_WAITER,
},
{
onError: (error) => {
toast.error(extractErrorMessage(error))
},
}
})
)
}
const handleDeliverToReceptionist = () => {
changeOrderStatus({
orderId: order.id,
status: OrderStatus.DELIVERED_TO_RECEPTIONIST
}, {
onError: (error) => {
toast.error(extractErrorMessage(error))
changeOrderStatus(
{
orderId: order.id,
status: OrderStatus.DELIVERED_TO_RECEPTIONIST,
},
{
onError: (error) => {
toast.error(extractErrorMessage(error))
},
}
})
)
}
const handleCancelOrder = (description?: string) => {
changeOrderStatus({
orderId: order.id,
status: OrderStatus.CANCELED,
params: description ? { description } : undefined
}, {
onError: (error) => {
toast.error(extractErrorMessage(error))
changeOrderStatus(
{
orderId: order.id,
status: OrderStatus.CANCELED,
params: description ? { description } : undefined,
},
{
onError: (error) => {
toast.error(extractErrorMessage(error))
},
}
})
)
setShowCancelModal(false)
}
@@ -208,61 +215,167 @@ const OrderDetails: FC = () => {
)
}
const handleRefund = (text?: string) => {
if (!text) return
refundPayment({
orderId: order.id,
params: { description: text }
}, {
onError: (error) => {
toast.error(extractErrorMessage(error))
const handleRefund = (amount: number, description: string) => {
refundPayment(
{
orderId: order.id,
params: { amount, description },
},
{
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 = () => {
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 (
<div className='mt-5'>
<div className='mt-5 pb-8'>
<div className='flex justify-between items-center'>
<h1 className='text-lg font-light'>
سفارش {formatFaNumber(order.orderNumber)}
</h1>
{/* <div>
<Select
items={statusItems}
value={order.status}
placeholder={t(`order_status.${order.status}`)}
className='w-[200px]'
style={{ backgroundColor: getStatusColor(order.status) }}
/>
</div> */}
<Button
className='w-fit px-6 bg-transparent text-primary border-primary border'
onClick={() => navigate(Pages.orders.list)}
>
<div className='flex gap-2 items-center'>
<ArrowRight2 color='#000' size={18} />
<span>بازگشت</span>
</div>
</Button>
</div>
<div className='flex gap-7 mt-9'>
<div className='flex-1 space-y-8'>
<div className='flex flex-col lg:flex-row gap-5 lg:gap-7 mt-6 sm:mt-9'>
<div className='flex-1 min-w-0 space-y-5 sm:space-y-8 order-2 lg:order-1'>
<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 className='w-full lg:w-[330px] shrink-0 order-1 lg:order-2'>
<PaymentInfo
order={order}
getPaymentStatusColor={getPaymentStatusColor}
getPaymentStatusTextColor={getPaymentStatusTextColor}
getPaymentStatusText={getPaymentStatusText}
canEditFees={canEditFees}
onEditFees={() => setShowEditFeesModal(true)}
isRefunding={isRefunding}
onRefund={(defaultAmount, maxAmount) =>
setRefundModal({ defaultAmount, maxAmount })
}
/>
{showPaymentReceiptBox && (
<PaymentVerificationBox
order={order}
isVerifyingPayment={isVerifyingPayment}
onVerifyPayment={handleVerifyPayment}
showVerifyButton={showVerifyPaymentButton}
/>
)}
<OrderActions
onPrint={handlePrintOrder}
showSendToKitchenButton={showSendToKitchenButton}
@@ -273,18 +386,36 @@ const OrderDetails: FC = () => {
isCustomerPickup={isCustomerPickup}
isCanceled={isCanceled}
canCompleteOrder={canCompleteOrder}
canSendPleaseComeSms={canSendPleaseComeSms}
isChangingStatus={isChangingStatus}
isSendingPleaseComeSms={isSendingPleaseComeSms}
onSendToKitchen={handleSendToKitchen}
onDeliverToCourier={handleDeliverToCourier}
onDeliverToWaiter={handleDeliverToWaiter}
onDeliverToReceptionist={handleDeliverToReceptionist}
onCompleteOrder={handleCompleteOrder}
onCancelOrder={() => setShowCancelModal(true)}
onSendPleaseComeSms={() => setShowPleaseComeSmsModal(true)}
/>
</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
isOpen={showCancelModal}
close={() => setShowCancelModal(false)}
@@ -294,17 +425,41 @@ const OrderDetails: FC = () => {
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
isOpen={showRefundModal}
close={() => setShowRefundModal(false)}
onConfrim={handleRefund}
isloading={isRefunding}
label='آیا از ریفاند این سفارش مطمئن هستید؟'
isHasDescription
isOpen={!!itemIdToRemove}
close={() => setItemIdToRemove(null)}
onConfrim={handleConfirmRemoveItem}
isloading={isRemovingItem}
label='آیا از حذف این آیتم مطمئن هستید؟'
/>
<SendPleaseComeSmsModal
isOpen={showPleaseComeSmsModal}
onClose={() => setShowPleaseComeSmsModal(false)}
isLoading={isSendingPleaseComeSms}
customerName={order.user?.firstName || ''}
restaurantName={order.restaurant?.name || ''}
onConfirm={handleConfirmPleaseComeSms}
/>
</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 Filters from '@/components/Filters';
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 { useFoodOrderReportFilters } from './hooks/useFoodOrderReportFilters';
import { getFoodOrderReportTableColumns } from './components/FoodOrderReportTableColumns';
@@ -15,6 +18,7 @@ import type { RowDataType } from '@/components/types/TableTypes';
const FoodOrderReport: FC = () => {
const { t } = useTranslation('global');
const { checkPermission, isLoading: isPermissionsLoading } = usePermissions();
const { filters, apiParams, handleFiltersChange } = useFoodOrderReportFilters();
const { data: reportData, isLoading } = useGetFoodOrderReport(apiParams);
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 (
<div className='mt-5'>
<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 { Add } from 'iconsax-react'
import { Link } from 'react-router-dom'
@@ -6,12 +6,15 @@ import Table from '@/components/Table'
import Filters from '@/components/Filters'
import Button from '@/components/Button'
import { Pages } from '@/config/Pages'
import { useGetOrders } from './hooks/useOrderData'
import { useGetOrders, useSendPleaseComeSms } from './hooks/useOrderData'
import { useOrderFilters } from './hooks/useOrderFilters'
import { getOrderTableColumns } from './components/OrderTableColumns'
import { useOrderFiltersFields } from './components/OrderFiltersFields'
import SendPleaseComeSmsModal from './components/SendPleaseComeSmsModal'
import type { Order } from './types/Types'
import type { RowDataType } from '@/components/types/TableTypes'
import { toast } from 'react-toastify'
import { extractErrorMessage } from '@/config/func'
const OrdersList: FC = () => {
const { t } = useTranslation('global')
@@ -24,13 +27,32 @@ const OrdersList: FC = () => {
} = useOrderFilters()
const { data: ordersData, isLoading } = useGetOrders(apiParams)
const { mutate: sendPleaseComeSms, isPending: isSendingSms } = useSendPleaseComeSms()
const [smsOrder, setSmsOrder] = useState<Order | null>(null)
const orders = ordersData?.data || []
const columns = getOrderTableColumns({ t })
const columns = getOrderTableColumns({
t,
onSendPleaseComeSms: (order) => setSmsOrder(order),
})
const filterFields = useOrderFiltersFields()
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 (
<div className='mt-5'>
<div className='flex justify-between items-center'>
@@ -64,8 +86,17 @@ const OrdersList: FC = () => {
onPageChange: handlePageChange,
}}
/>
<SendPleaseComeSmsModal
isOpen={!!smsOrder}
onClose={() => setSmsOrder(null)}
isLoading={isSendingSms}
customerName={smsOrder?.user?.firstName || ''}
restaurantName={smsOrder?.restaurant?.name || ''}
onConfirm={handleConfirmSendSms}
/>
</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 { Order } from '../types/Types'
import { formatOptionalDate, formatFaNumber } from '@/helpers/func'
import { formatFaNumber } from '@/helpers/func'
interface CustomerInfoProps {
order: Order
@@ -14,7 +14,7 @@ const CustomerInfo: FC<CustomerInfoProps> = ({ order }) => {
: '-'
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='text-lg font-light flex items-center gap-2'>
<User color='#000' size={20} />
@@ -22,7 +22,7 @@ const CustomerInfo: FC<CustomerInfoProps> = ({ order }) => {
</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='text-description'>
شماره سفارش:
@@ -53,46 +53,7 @@ const CustomerInfo: FC<CustomerInfoProps> = ({ order }) => {
{order.user?.phone || '-'}
</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 && (
<div className='flex gap-2'>
@@ -103,13 +64,13 @@ const CustomerInfo: FC<CustomerInfoProps> = ({ order }) => {
</div>
{order.userAddress && (
<div className='mt-8 space-y-4 text-[13px] font-light'>
<div className='flex gap-2 items-start'>
<div className='text-description flex items-center gap-1'>
<div className='mt-6 sm:mt-8 space-y-4 text-[13px] font-light'>
<div className='flex flex-col sm:flex-row gap-1 sm:gap-2 items-start'>
<div className='text-description flex items-center gap-1 shrink-0'>
<Location size={14} />
آدرس کامل:
</div>
<div className='flex-1'>{fullAddress}</div>
<div className='flex-1 min-w-0 break-words'>{fullAddress}</div>
</div>
{order.userAddress.fullName && (
+140 -123
View File
@@ -1,145 +1,162 @@
import Button from '@/components/Button'
import { type FC, useState, useEffect } from 'react'
import Button from "@/components/Button";
import { type FC, useState, useEffect } from "react";
interface OrderActionsProps {
onPrint: () => void
showSendToKitchenButton: boolean
showCancelButton: boolean
isPreparing: boolean
isCourierDelivery: boolean
isDineInOrCarDelivery: boolean
isCustomerPickup: boolean
isCanceled: boolean
canCompleteOrder: boolean
isChangingStatus: boolean
onSendToKitchen: () => void
onDeliverToCourier: () => void
onDeliverToWaiter: () => void
onDeliverToReceptionist: () => void
onCompleteOrder: () => void
onCancelOrder: () => void
onPrint: () => void;
showSendToKitchenButton: boolean;
showCancelButton: boolean;
isPreparing: boolean;
isCourierDelivery: boolean;
isDineInOrCarDelivery: boolean;
isCustomerPickup: boolean;
isCanceled: boolean;
canCompleteOrder: boolean;
canSendPleaseComeSms: boolean;
isChangingStatus: boolean;
isSendingPleaseComeSms?: boolean;
onSendToKitchen: () => void;
onDeliverToCourier: () => void;
onDeliverToWaiter: () => void;
onDeliverToReceptionist: () => void;
onCompleteOrder: () => void;
onCancelOrder: () => void;
onSendPleaseComeSms: () => void;
}
const OrderActions: FC<OrderActionsProps> = ({
onPrint,
showSendToKitchenButton,
showCancelButton,
isPreparing,
isCourierDelivery,
isDineInOrCarDelivery,
isCustomerPickup,
isCanceled,
canCompleteOrder,
isChangingStatus,
onSendToKitchen,
onDeliverToCourier,
onDeliverToWaiter,
onDeliverToReceptionist,
onCompleteOrder,
onCancelOrder
onPrint,
showSendToKitchenButton,
showCancelButton,
isPreparing,
isCourierDelivery,
isDineInOrCarDelivery,
isCustomerPickup,
isCanceled,
canCompleteOrder,
canSendPleaseComeSms,
isChangingStatus,
isSendingPleaseComeSms,
onSendToKitchen,
onDeliverToCourier,
onDeliverToWaiter,
onDeliverToReceptionist,
onCompleteOrder,
onCancelOrder,
onSendPleaseComeSms,
}) => {
const [loadingAction, setLoadingAction] = useState<string | null>(null)
const [loadingAction, setLoadingAction] = useState<string | null>(null);
useEffect(() => {
if (!isChangingStatus) {
setLoadingAction(null)
}
}, [isChangingStatus])
const handleSendToKitchen = () => {
setLoadingAction('sendToKitchen')
onSendToKitchen()
useEffect(() => {
if (!isChangingStatus) {
setLoadingAction(null);
}
}, [isChangingStatus]);
const handleDeliverToCourier = () => {
setLoadingAction('deliverToCourier')
onDeliverToCourier()
}
const handleSendToKitchen = () => {
setLoadingAction("sendToKitchen");
onSendToKitchen();
};
const handleDeliverToWaiter = () => {
setLoadingAction('deliverToWaiter')
onDeliverToWaiter()
}
const handleDeliverToCourier = () => {
setLoadingAction("deliverToCourier");
onDeliverToCourier();
};
const handleDeliverToReceptionist = () => {
setLoadingAction('deliverToReceptionist')
onDeliverToReceptionist()
}
const handleDeliverToWaiter = () => {
setLoadingAction("deliverToWaiter");
onDeliverToWaiter();
};
const handleCancelOrder = () => {
setLoadingAction('cancelOrder')
onCancelOrder()
}
const handleDeliverToReceptionist = () => {
setLoadingAction("deliverToReceptionist");
onDeliverToReceptionist();
};
const handleCompleteOrder = () => {
setLoadingAction('completeOrder')
onCompleteOrder()
}
const handleCancelOrder = () => {
setLoadingAction("cancelOrder");
onCancelOrder();
};
return (
<div className='mt-6 flex flex-col gap-3'>
<Button
label='چاپ'
onClick={onPrint}
className='bg-slate-700 hover:bg-slate-800'
/>
const handleCompleteOrder = () => {
setLoadingAction("completeOrder");
onCompleteOrder();
};
{!isCanceled && showSendToKitchenButton && (
<Button
label='ارسال به آشپزخانه'
onClick={handleSendToKitchen}
isloading={isChangingStatus && loadingAction === 'sendToKitchen'}
className='bg-orange-600 hover:bg-orange-700'
/>
)}
return (
<div className="mt-5 sm:mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-1 gap-3">
<div className="flex gap-3">
<Button
label="چاپ"
onClick={onPrint}
className="border bg-white border-slate-700 text-slate-700 hover:bg-slate-100"
/>
{!isCanceled && isPreparing && isCourierDelivery && (
<Button
label='تحویل به پیک'
onClick={handleDeliverToCourier}
isloading={isChangingStatus && loadingAction === 'deliverToCourier'}
className='bg-blue-600 hover:bg-blue-700'
/>
)}
{canSendPleaseComeSms && (
<Button
label="ارسال پیام"
onClick={onSendPleaseComeSms}
isloading={isSendingPleaseComeSms}
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 && (
<Button
label='تحویل به گارسون'
onClick={handleDeliverToWaiter}
isloading={isChangingStatus && loadingAction === 'deliverToWaiter'}
className='bg-purple-600 hover:bg-purple-700'
/>
)}
{!isCanceled && showSendToKitchenButton && (
<Button
label="ارسال به آشپزخانه"
onClick={handleSendToKitchen}
isloading={isChangingStatus && loadingAction === "sendToKitchen"}
className="bg-orange-600 hover:bg-orange-700"
/>
)}
{!isCanceled && isPreparing && isCustomerPickup && (
<Button
label='تحویل به رسپشنیست'
onClick={handleDeliverToReceptionist}
isloading={isChangingStatus && loadingAction === 'deliverToReceptionist'}
className='bg-indigo-600 hover:bg-indigo-700'
/>
)}
{!isCanceled && isPreparing && isCourierDelivery && (
<Button
label="تحویل به پیک"
onClick={handleDeliverToCourier}
isloading={isChangingStatus && loadingAction === "deliverToCourier"}
className="bg-blue-600 hover:bg-blue-700"
/>
)}
{!isCanceled && canCompleteOrder && (
<Button
label='اتمام سفارش'
onClick={handleCompleteOrder}
isloading={isChangingStatus && loadingAction === 'completeOrder'}
className='bg-green-600 hover:bg-green-700'
/>
)}
{!isCanceled && isPreparing && isDineInOrCarDelivery && (
<Button
label="تحویل به گارسون"
onClick={handleDeliverToWaiter}
isloading={isChangingStatus && loadingAction === "deliverToWaiter"}
className="bg-purple-600 hover:bg-purple-700"
/>
)}
{!isCanceled && showCancelButton && (
<Button
label='لغو سفارش'
onClick={handleCancelOrder}
isloading={isChangingStatus && loadingAction === 'cancelOrder'}
className='bg-transparent border border-red-500 text-red-500 hover:bg-red-50'
/>
)}
</div>
)
}
{!isCanceled && isPreparing && isCustomerPickup && (
<Button
label="تحویل به رسپشنیست"
onClick={handleDeliverToReceptionist}
isloading={
isChangingStatus && loadingAction === "deliverToReceptionist"
}
className="bg-indigo-600 hover:bg-indigo-700"
/>
)}
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: 'وضعیت سفارش',
defaultValue: [],
options: [
{ label: 'در انتظار پرداخت', value: OrderStatus.PENDING_PAYMENT },
{ label: 'پرداخت شده', value: OrderStatus.PAID },
{ label: 'در حال آماده سازی', value: OrderStatus.PREPARING },
{ label: 'آماده', value: OrderStatus.READY },
{ label: 'تحویل به پیشخدمت', value: OrderStatus.DELIVERED_TO_WAITER },
{ label: 'در انتظار پرداخت', value: OrderStatus.NEW },
{ label: 'در حال آماده سازی', value: OrderStatus.PREPARING },
{ label: 'تحویل به پیشخدمت', value: OrderStatus.DELIVERED_TO_WAITER },
{ label: 'تحویل به پذیرش', value: OrderStatus.DELIVERED_TO_RECEPTIONIST },
{ label: 'ارسال شده', value: OrderStatus.SHIPPED },
{ label: 'تکمیل شده', value: OrderStatus.COMPLETED },
+105 -17
View File
@@ -1,13 +1,15 @@
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 { ColumnType, RowDataType } from '@/components/types/TableTypes'
import type { Order, OrderItem as OrderItemType } from '../types/Types'
import { formatOptionalDate, formatPrice, formatFaNumber } from '@/helpers/func'
import { useTranslation } from 'react-i18next'
import OrderSummary from './OrderSummary'
interface OrderItemTable extends RowDataType {
id: string
image: string
title: string
quantity: number
@@ -19,9 +21,35 @@ interface OrderItemTable extends RowDataType {
interface OrderItemsDetailsProps {
order: Order
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 orderItems: OrderItemTable[] = order.items.map((item: OrderItemType) => ({
@@ -63,7 +91,32 @@ const OrderItemsDetails: FC<OrderItemsDetailsProps> = ({ order, getStatusColor }
title: 'تعداد',
key: 'quantity',
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: 'قیمت واحد',
@@ -81,17 +134,46 @@ const OrderItemsDetails: FC<OrderItemsDetailsProps> = ({ order, getStatusColor }
title: 'مبلغ کل',
key: 'amount',
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 (
<div className='w-full bg-white rounded-4xl p-8'>
<div className='text-lg font-light mb-6 flex items-center gap-2'>
<ShoppingCart color='#000' size={20} />
جزییات سفارش
<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'>
<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 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='text-description'>زمان سفارش:</div>
<div className='flex items-center gap-1'>
@@ -119,6 +201,14 @@ const OrderItemsDetails: FC<OrderItemsDetailsProps> = ({ order, getStatusColor }
</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='text-description'>نوع تحویل:</div>
<div>{order.deliveryMethod?.description || '-'}</div>
@@ -146,7 +236,6 @@ const OrderItemsDetails: FC<OrderItemsDetailsProps> = ({ order, getStatusColor }
className='mt-0'
/>
<OrderSummary />
{order.couponDetail && (
<div className='mt-6 pt-6 border-t border-dashed border-border'>
@@ -187,15 +276,15 @@ const OrderItemsDetails: FC<OrderItemsDetailsProps> = ({ order, getStatusColor }
</div>
<div className='space-y-3'>
{order.history.map((historyItem, index) => (
<div key={index} className='flex justify-between items-center text-[13px] bg-gray-50 p-3 rounded-lg'>
<div className='flex items-center gap-2'>
<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 min-w-0'>
<div
className='size-2 rounded-full'
className='size-2 rounded-full shrink-0'
style={{ backgroundColor: getStatusColor(historyItem.status) }}
></div>
<span>{t(`order_status.${historyItem.status}`) || historyItem.status}</span>
<span className='truncate'>{t(`order_status.${historyItem.status}`) || historyItem.status}</span>
</div>
<div className='text-description'>
<div className='text-description shrink-0 pr-4 sm:pr-0'>
{formatOptionalDate(historyItem.changedAt, {
year: 'numeric',
month: '2-digit',
@@ -214,4 +303,3 @@ const OrderItemsDetails: FC<OrderItemsDetailsProps> = ({ order, getStatusColor }
}
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 type { ColumnType } from '@/components/types/TableTypes'
import type { Order } from '../types/Types'
import { formatPrice, formatOptionalDate } from '@/helpers/func'
import Status from '@/components/Status'
import { Link } from 'react-router-dom'
import { Pages } from '@/config/Pages'
import { formatFaNumber } from '@/helpers/func'
import type { TFunction } from 'i18next'
import { Eye, MessageText } from "iconsax-react";
import type { ColumnType } from "@/components/types/TableTypes";
import type { Order } from "../types/Types";
import { formatPrice, formatOptionalDate } from "@/helpers/func";
import Status from "@/components/Status";
import { Link } from "react-router-dom";
import { Pages } from "@/config/Pages";
import { formatFaNumber } from "@/helpers/func";
import type { TFunction } from "i18next";
interface GetOrderTableColumnsParams {
onDelete?: (id: string) => void
isDeleting?: boolean
t: TFunction
onDelete?: (id: string) => void;
isDeleting?: boolean;
onSendPleaseComeSms?: (order: Order) => void;
t: TFunction;
}
const getStatusVariant = (status: string): 'success' | 'error' | 'warning' | 'info' | 'pending' => {
const variantMap: Record<string, 'success' | 'error' | 'warning' | 'info' | 'pending'> = {
'pendingPayment': 'pending',
'paid': 'info',
'preparing': 'warning',
'ready': 'info',
'shipped': 'info',
'completed': 'success',
'canceled': 'error',
}
return variantMap[status] || 'pending'
}
const getStatusVariant = (
status: string,
): "success" | "error" | "warning" | "info" | "pending" => {
const variantMap: Record<
string,
"success" | "error" | "warning" | "info" | "pending"
> = {
new: "pending",
preparing: "warning",
shipped: "info",
completed: "success",
canceled: "error",
};
return variantMap[status] || "pending";
};
export const getOrderTableColumns = ({ t }: GetOrderTableColumnsParams): ColumnType<Order>[] => {
return [
{
key: 'orderNumber',
title: 'شماره سفارش',
render: (item: Order) => {
return formatFaNumber(item.orderNumber)
}
},
{
key: 'customerName',
title: 'نام مشتری',
render: (item: Order) => {
const name = [item.user.firstName, item.user.lastName].filter(Boolean).join(' ')
return name || '-'
}
},
{
key: 'total',
title: 'مبلغ سفارش',
render: (item: Order) => {
return formatPrice(item.total)
}
},
{
key: 'createdAt',
title: 'زمان سفارش',
render: (item: Order) => {
return formatOptionalDate(item.createdAt, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
},
{
key: 'deliveryMethod',
title: 'نوع تحویل',
render: (item: Order) => {
return item.deliveryMethod?.description || '-'
}
},
{
key: 'paymentMethod',
title: 'نوع پرداخت',
render: (item: Order) => {
return item.paymentMethod?.description || '-'
}
},
{
key: 'status',
title: 'وضعیت',
render: (item: Order) => {
return (
<Status
variant={getStatusVariant(item.status)}
label={t(`order_status.${item.status}`)}
/>
)
}
},
{
key: 'details',
title: '',
render: (item: Order) => {
return (
<Link to={Pages.orders.detail + item.id}>
<Eye size={20} color="#8C90A3" className="cursor-pointer" />
</Link>
)
}
},
]
}
const getShiftAdminName = (item: Order): string => {
const admin = item.cashShift?.admin;
if (!admin) return "-";
const name = [admin.firstName, admin.lastName].filter(Boolean).join(" ");
return name || admin.phone || "-";
};
export const getOrderTableColumns = ({
t,
onSendPleaseComeSms,
}: GetOrderTableColumnsParams): ColumnType<Order>[] => {
return [
{
key: "orderNumber",
title: "شماره",
render: (item: Order) => {
return formatFaNumber(item.orderNumber);
},
},
{
key: "customerName",
title: "نام مشتری",
render: (item: Order) => {
const name = [item.user?.firstName, item.user?.lastName]
.filter(Boolean)
.join(" ");
return name || "-";
},
},
{
key: "shiftAdmin",
title: "شیفت",
render: (item: Order) => getShiftAdminName(item),
},
{
key: "createdAt",
title: "تاریخ",
render: (item: Order) => {
if (!item.createdAt) return "-";
return (
<div className="flex flex-col whitespace-nowrap">
<span className="text-[11px] text-description">
{formatOptionalDate(item.createdAt, {
year: "numeric",
month: "2-digit",
day: "2-digit",
})}
</span>
<span className="text-[15px] font-medium">
{formatOptionalDate(item.createdAt, {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
);
},
},
{
key: "methods",
title: "تحویل / پرداخت",
render: (item: Order) => {
const rows = [
{
label: "نوع تحویل",
value: item.deliveryMethod?.description || "-",
},
{
label: "نوع پرداخت",
value: item.paymentMethod?.description || "-",
},
];
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 { DeliveryMethodEnum } from "@/pages/shipmentMethod/enum/Enum";
import { Edit2, Refresh } from "iconsax-react";
import type { FC } from "react";
import type { Order } from "../types/Types";
import { OrderStatus } from "../enum/Enum";
interface PaymentInfoProps {
order: Order;
getPaymentStatusColor: (status: string) => string;
getPaymentStatusTextColor: (status: string) => string;
getPaymentStatusText: (status: string) => string;
canEditFees?: boolean;
onEditFees?: () => void;
onRefund?: (defaultAmount: number, maxAmount: number) => void;
isRefunding?: boolean;
}
const PaymentInfo: FC<PaymentInfoProps> = ({ order, getPaymentStatusColor, getPaymentStatusTextColor, getPaymentStatusText }) => {
const paymentStatus = order.payments?.[0]?.status;
const getDeliveryMethodText = (method: string): string => {
const methodMap: Record<string, string> = {
[DeliveryMethodEnum.DineIn]: "سرو در رستوران",
[DeliveryMethodEnum.CustomerPickup]: "دریافت از محل",
[DeliveryMethodEnum.DeliveryCar]: "تحویل به خودرو",
[DeliveryMethodEnum.DeliveryCourier]: "بیرون بر",
};
return methodMap[method] || method;
const getPaymentMethodText = (method: string): string => {
const methodMap: Record<string, string> = {
[PaymentMethodEnum.Cash]: "پرداخت نقدی",
[PaymentMethodEnum.Online]: "پرداخت آنلاین",
[PaymentMethodEnum.CreditCard]: "پرداخت کارت به کارت",
[PaymentMethodEnum.Wallet]: "کیف پول",
};
return methodMap[method] || method;
};
const getPaymentMethodText = (method: string): string => {
const methodMap: Record<string, string> = {
[PaymentMethodEnum.Cash]: "پرداخت نقدی",
[PaymentMethodEnum.Online]: "پرداخت آنلاین",
[PaymentMethodEnum.CreditCard]: "پرداخت کارت به کارت",
[PaymentMethodEnum.Wallet]: "کیف پول",
};
return methodMap[method] || method;
};
const PaymentInfo: FC<PaymentInfoProps> = ({
order,
canEditFees = false,
onEditFees,
onRefund,
isRefunding = false,
}) => {
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 (
<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="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 className="font-light">خلاصه </div>
</div>
<div className="mt-10">
<div className="flex text-[13px] font-light justify-between items-center border-b border-border border-dashed pb-4">
<div className="text-description">روش ارسال</div>
<div>{order.deliveryMethod ? getDeliveryMethodText(order.deliveryMethod.method) : "-"}</div>
<div className="mt-5">
<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 shrink-0">روش پرداخت</div>
<div className="min-w-0 break-words">{order.paymentMethod ? getPaymentMethodText(order.paymentMethod.method) : "-"}</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 && (
<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>
@@ -77,25 +82,11 @@ const PaymentInfo: FC<PaymentInfoProps> = ({ order, getPaymentStatusColor, getPa
</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="text-description">جمع آیتمها</div>
<div>{formatPrice(order.subTotal)}</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 && (
<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>
@@ -103,16 +94,22 @@ const PaymentInfo: FC<PaymentInfoProps> = ({ order, getPaymentStatusColor, getPa
</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="text-description font-medium">جمع تخفیفها</div>
<div className="text-green-600 font-medium">-{formatPrice(order.totalDiscount)}</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 flex items-center gap-1">
هزینه ارسال
{editFeesButton}
</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="text-description">هزینه ارسال</div>
<div>{formatPrice(order.deliveryFee)}</div>
<div className="text-description">هزینه بستهبندی</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>
{order.tax > 0 && (
@@ -122,15 +119,35 @@ const PaymentInfo: FC<PaymentInfoProps> = ({ order, getPaymentStatusColor, getPa
</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>{formatFaNumber(order.totalItems)}</div>
</div>
<div className="flex mt-4 text-[13px] font-bold justify-between items-center">
<div className="flex mt-4 text-[13px] font-bold justify-between items-center border-b border-border border-dashed pb-4">
<div>مبلغ کل</div>
<div>{formatPrice(order.total)}</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>
);
@@ -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 {
PENDING_PAYMENT = "pendingPayment",
PAID = "paid",
NEW = "new",
PREPARING = "preparing",
READY = "ready",
DELIVERED_TO_WAITER = "deliveredToWaiter",
DELIVERED_TO_RECEPTIONIST = "deliveredToReceptionist",
SHIPPED = "shipped",
+90 -1
View File
@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
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 = () => {
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,
CreateOrderType,
CreateOrderResponse,
UpdateOrderFeesType,
AddOrderItemType,
UpdateOrderItemQuantityType,
} from "../types/Types";
export interface GetOrdersParams {
@@ -21,9 +24,15 @@ export interface ChangeOrderStatusParams {
}
export interface RefundPaymentParams {
amount: number;
description: string;
}
export interface AddPaymentParams {
amount: number;
description?: string;
}
export const createOrder = async (
params: CreateOrderType
): Promise<CreateOrderResponse> => {
@@ -100,6 +109,57 @@ export const refundPayment = async (
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 {
from?: string;
to?: string;
@@ -131,3 +191,10 @@ export const getDailyOrderReport = async (
);
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;
updatedAt: string;
deletedAt: string | null;
user: OrderUser;
user: OrderUser | null;
restaurant: OrderRestaurant;
deliveryMethod: OrderDeliveryMethod;
userAddress: UserAddress | null;
@@ -207,17 +207,46 @@ export interface Order extends RowDataType {
subTotal: number;
tax: number;
deliveryFee: number;
packingFee: number;
prepareTime?: number | null;
total: number;
paidAmount: number;
balance: number;
refundedAmount: number;
totalItems: number;
description: string;
tableNumber: string | null;
status: string;
paymentStatus?: string;
cashShift?: {
id: string;
admin?: {
id: string;
firstName?: string;
lastName?: string;
phone?: string;
} | null;
} | null;
history: OrderHistory[];
payments: OrderPayment[];
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 = {
total: 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 { useParams } from 'react-router-dom'
import PageLoading from '@/components/PageLoading'
import Input from '@/components/Input'
import Status from '@/components/Status'
import Select from '@/components/Select'
import Button from '@/components/Button'
import { Star1, User, ShoppingCart, DocumentText, Like1, Dislike } from 'iconsax-react'
import { formatOptionalDate, formatPrice, formatFaNumber } from '@/helpers/func'
import { useTranslation } from 'react-i18next'
import { toast } from 'react-toastify'
import { extractErrorMessage } from '@/config/func'
import ReviewStatusHeader from './components/ReviewStatusHeader'
import ReviewChatView from './components/ReviewChatView'
import ReviewChatSidebar from './components/ReviewChatSidebar'
const getParentId = (parent: string | { id: string } | null | undefined) => {
if (!parent) return null
return typeof parent === 'string' ? parent : parent.id
}
const DetailReview: FC = () => {
const { t } = useTranslation('global')
const { id } = useParams()
const { data: reviewData, isLoading } = useGetReviewById(id!)
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) {
return (
@@ -26,8 +47,7 @@ const DetailReview: FC = () => {
)
}
const review = reviewData?.data
if (!review) {
if (!review || !id) {
return (
<div className='mt-5'>
<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 = () => {
if (selectedStatus && selectedStatus !== review.status && id) {
updateStatusMutation.mutate(
{ id, status: selectedStatus },
{
onSuccess: () => {
setSelectedStatus('')
},
}
)
}
}
if (!selectedStatus || selectedStatus === review.status) return
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 (
<div className='mt-5'>
<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={getStatusVariant(review.status)}
label={statusLabels[review.status] || review.status}
/>
<div className='flex items-center gap-2'>
<div>
<Select
items={statusOptions}
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>
<ReviewStatusHeader
status={review.status}
selectedStatus={selectedStatus}
onStatusChange={setSelectedStatus}
onSave={handleStatusChange}
isSaving={updateStatusMutation.isPending}
/>
<div className='flex gap-6 items-start'>
<div className='flex-1 min-w-0'>
<ReviewChatView reviewId={id} review={review} replies={replies} />
</div>
<ReviewChatSidebar review={review} />
</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 { Link } from 'react-router-dom'
import { Pages } from '@/config/Pages'
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'
}
import { getReviewStatusVariant, reviewStatusLabels } from '../utils/reviewStatus'
export const getReviewTableColumns = (): ColumnType<Review>[] => {
return [
@@ -21,7 +13,7 @@ export const getReviewTableColumns = (): ColumnType<Review>[] => {
key: 'user',
title: 'کاربر',
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 || '-'
}
},
@@ -63,15 +55,10 @@ export const getReviewTableColumns = (): ColumnType<Review>[] => {
key: 'status',
title: 'وضعیت',
render: (item: Review) => {
const statusLabels: Record<string, string> = {
'pending': 'در انتظار',
'approved': 'تایید شده',
'rejected': 'رد شده',
}
return (
<Status
variant={getStatusVariant(item.status)}
label={statusLabels[item.status] || item.status}
variant={getReviewStatusVariant(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 type { GetReviewsResponse, GetReviewByIdResponse } from "../types/Types";
import type {
GetReviewsResponse,
GetReviewByIdResponse,
ReplyReviewResponse,
} from "../types/Types";
export const getReviews = async (
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 });
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;
title: string;
desc: string;
content: string | null;
content: string[];
price: number;
points: number | null;
order: number | null;
prepareTime: number | null;
sat: boolean;
sun: boolean;
mon: boolean;
breakfast: boolean;
noon: boolean;
dinner: boolean;
stock: number;
stockDefault: number;
order: number;
prepareTime: number;
weekDays: number[];
mealTypes: string[];
isActive: boolean;
images: string[];
inPlaceServe: boolean;
pickupServe: boolean;
rate: number;
score: number;
discount: number;
isSpecialOffer: boolean;
}
@@ -44,8 +37,7 @@ export interface ReviewUser {
referrer: string | null;
isActive: boolean;
gender: boolean;
wallet: number;
points: number;
avatarUrl: string | null;
phone: string;
}
@@ -75,6 +67,16 @@ export interface ReviewOrder {
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 {
id: string;
createdAt: string;
@@ -95,15 +97,21 @@ export interface ReviewDetail {
createdAt: string;
updatedAt: string;
deletedAt: string | null;
order: ReviewOrder;
isBuyer: boolean;
order?: ReviewOrder;
food: ReviewFood;
user: ReviewUser;
user?: ReviewUser | null;
comment: string;
rating: number;
positivePoints: string[];
negativePoints: string[];
status: string;
parent?: string | { id: string } | null;
admin?: ReviewAdmin | null;
}
export type ReviewReply = ReviewDetail;
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
MANAGE_ORDERS = "manage_orders",
VIEW_ORDER_REPORTS = "view_order_reports",
MANAGE_SHIFTS = "manage_shifts",
VIEW_ALL_SHIFTS = "view_all_shifts",
// User & Admin Management
MANAGE_ADMINS = "manage_admins",
+69 -1
View File
@@ -4,6 +4,7 @@ import Input from '@/components/Input'
import Textarea from '@/components/Textarea'
import UploadBox from '@/components/UploadBox'
import DefaulModal from '@/components/DefaulModal'
import SwitchComponent from '@/components/Switch'
import LocationPicker from './components/LocationPicker'
import LocationInput from './components/LocationInput'
import { TickCircle } from 'iconsax-react'
@@ -27,10 +28,31 @@ const validationSchema = yup.object({
.min(1, 'حداقل یک شماره تماس الزامی است')
.of(yup.string().required('شماره تماس الزامی است')),
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('آدرس الزامی است'),
latitude: yup.number().required('انتخاب موقعیت روی نقشه الزامی است'),
longitude: yup.number().required('انتخاب موقعیت روی نقشه الزامی است'),
description: yup.string().nullable(),
establishedYear: yup
.number()
.nullable()
.transform((value, originalValue) => (originalValue === '' || originalValue === null ? null : value))
.typeError('سال تاسیس باید عدد باشد'),
})
const GeneralSettings: FC = () => {
@@ -46,12 +68,15 @@ const GeneralSettings: FC = () => {
menuColor: '#000000',
phones: [''],
instagram: '',
domain: '',
address: '',
latitude: 0,
longitude: 0,
description: '',
logo: '',
establishedYear: null,
serviceArea: undefined,
enableAiChat: false,
},
validationSchema,
onSubmit: (values) => {
@@ -104,12 +129,15 @@ const GeneralSettings: FC = () => {
? [restaurantData.phone]
: [''],
instagram: restaurantData.instagram || '',
domain: restaurantData.domain || '',
address: restaurantData.address || '',
latitude: restaurantData.latitude || 0,
longitude: restaurantData.longitude || 0,
description: restaurantData.description || '',
logo: restaurantData.logo || '',
establishedYear: restaurantData.establishedYear ?? null,
serviceArea: restaurantData.serviceArea || undefined,
enableAiChat: restaurantData.enableAiChat ?? false,
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -148,6 +176,22 @@ const GeneralSettings: FC = () => {
/>
</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'>
<UploadBox
label='لوگو'
@@ -180,6 +224,22 @@ const GeneralSettings: FC = () => {
/>
</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'>
<Textarea
label='آدرس'
@@ -220,7 +280,15 @@ const GeneralSettings: FC = () => {
onPolygonSelect={(polygon) => formik.setFieldValue('serviceArea', polygon)}
/>
</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'>
<Button
+8 -1
View File
@@ -1,8 +1,9 @@
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 NotificationsList from "../notifications/List";
import DesignSettings from "./components/DesignSettings";
import QrCodeSettings from "./components/QrCodeSettings";
import ScoreSetting from "./components/ScoreSetting";
import { SettingTabEnum } from "./enum/Enum";
import GeneralSettings from "./GeneralSettings";
@@ -37,6 +38,11 @@ const Setting: FC = () => {
label: "تنظیمات طراحی",
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)}
active={activeTab}
@@ -48,6 +54,7 @@ const Setting: FC = () => {
{activeTab === SettingTabEnum.FINANCIAL && <ScoreSetting />}
{activeTab === SettingTabEnum.NOTIFICATION && <NotificationsList />}
{activeTab === SettingTabEnum.DESIGN && <DesignSettings />}
{activeTab === SettingTabEnum.QR_CODE && <QrCodeSettings />}
</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",
NOTIFICATION = "notification",
DESIGN = "design",
QR_CODE = "qr_code",
}
+4
View File
@@ -17,7 +17,10 @@ export type UpdateRestaurantType = {
latitude?: number;
longitude?: number;
description?: string;
establishedYear?: number | null;
domain?: string;
serviceArea?: GeoJSONPolygon;
enableAiChat?: boolean;
score?: {
purchaseAmount: string;
purchaseScore: string;
@@ -49,6 +52,7 @@ export type Restaurant = {
longitude: number | null;
serviceArea: GeoJSONPolygon;
isActive: boolean;
enableAiChat: boolean;
establishedYear: number | null;
phoneNumber: string | null;
phone?: string;
+4
View File
@@ -19,6 +19,7 @@ import CreateOrder from '@/pages/orders/Create'
import OrderDetails from '@/pages/orders/Details'
import FoodOrderReport from '@/pages/orders/FoodOrderReport'
import DailyOrderReport from '@/pages/orders/DailyOrderReport'
import CashShiftsList from '@/pages/cashShifts/List'
import CommentsList from '@/pages/comments/List'
import CommentsDetails from '@/pages/comments/Details'
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 CreateCampaign from '@/pages/customers/campaigns/Create'
import WalletTransactions from '@/pages/wallet/Transactions'
import AiChatsList from '@/pages/aiChats/List'
const MainRouter: FC = () => {
const { hasSubMenu } = useSharedStore()
@@ -93,6 +95,7 @@ const MainRouter: FC = () => {
<Route path={Pages.orders.add} element={<CreateOrder />} />
<Route path={Pages.orders.foodReport} element={<FoodOrderReport />} />
<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.comments.list} element={<CommentsList />} />
<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.wallet.transactions} element={<WalletTransactions />} />
<Route path={Pages.aiChats.list} element={<AiChatsList />} />
</Routes>
</div>
</div>
+13 -3
View File
@@ -1,6 +1,6 @@
import { usePermissions } from '@/hooks/usePermissions'
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 { useTranslation } from 'react-i18next'
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
icon={<DocumentText variant={isActive('orders') ? 'Bold' : 'Outline'} color={isActive('orders') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
title={t('sidebar.orders')}
isActive={isActive('orders')}
link={Pages.orders.list}
link={checkPermission(PermissionEnum.MANAGE_ORDERS) ? Pages.orders.list : Pages.orders.foodReport}
name='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) && (
<SideBarItem
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 { useLocation } from 'react-router-dom'
import { Pages } from '../../config/Pages'
import { usePermissions } from '@/hooks/usePermissions'
import { PermissionEnum } from '@/pages/roles/enum/Enum'
const OrdersSubMenu: FC = () => {
const location = useLocation()
const { t } = useTranslation('global')
const { checkPermission } = usePermissions()
const isActive = (name: string) => {
return location.pathname.includes(name)
@@ -28,26 +31,41 @@ const OrdersSubMenu: FC = () => {
</div>
<div className='flex flex-col mt-10 gap-4'>
<SubMenuItem
title={t('submenu.orders_list')}
isActive={isActive('list')}
link={Pages.orders.list}
/>
<SubMenuItem
title={t('submenu.orders_food_report')}
isActive={isActive('report/by-food')}
link={Pages.orders.foodReport}
/>
<SubMenuItem
title={t('submenu.orders_daily_report')}
isActive={isActive('report/by-day')}
link={Pages.orders.dailyReport}
/>
<SubMenuItem
title={t('submenu.add_order')}
isActive={isActive('add')}
link={Pages.orders.add}
/>
{checkPermission(PermissionEnum.MANAGE_ORDERS) && (
<SubMenuItem
title={t('submenu.orders_list')}
isActive={isActive('list')}
link={Pages.orders.list}
/>
)}
{checkPermission(PermissionEnum.VIEW_ORDER_REPORTS) && (
<SubMenuItem
title={t('submenu.orders_food_report')}
isActive={isActive('report/by-food')}
link={Pages.orders.foodReport}
/>
)}
{checkPermission(PermissionEnum.VIEW_ORDER_REPORTS) && (
<SubMenuItem
title={t('submenu.orders_daily_report')}
isActive={isActive('report/by-day')}
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>
)
-1
View File
@@ -25,7 +25,6 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Path Mapping */
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
-1
View File
@@ -5,7 +5,6 @@
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}