Compare commits

...

5 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
17 changed files with 668 additions and 300 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.
+1 -1
View File
@@ -6,7 +6,7 @@ import { NotificationSubject } from '@/types/notification.types'
import NotificationItem from '@/components/NotificationItem'
import { useQueryClient } from '@tanstack/react-query'
const NOTIFICATION_SOUND_SRC = '/mixkit-correct-answer-tone-2870.wav'
const NOTIFICATION_SOUND_SRC = '/Qoddusi_improved.mp3'
interface NotificationItemData extends NotificationPayload {
id: string
-30
View File
@@ -1,5 +1,4 @@
import { type FC, useMemo, useState } from "react";
import { EmptyWallet } from "iconsax-react";
import Table from "@/components/Table";
import Filters from "@/components/Filters";
import DefaulModal from "@/components/DefaulModal";
@@ -31,11 +30,6 @@ const AiChatsList: FC = () => {
const chats = chatsData?.data || [];
const totalPages = chatsData?.meta?.totalPages || 1;
const totalCost = chatsData?.meta?.totalCost;
const pageCost = useMemo(
() => chats.reduce((sum, chat) => sum + (chat.cost || 0), 0),
[chats]
);
const filterFields = useAiChatFiltersFields();
const columns = useMemo(
@@ -60,30 +54,6 @@ const AiChatsList: FC = () => {
<h1 className="text-lg font-light">لیست چتهای AI</h1>
</div>
<div className="mt-6 bg-[#F5F5F5] rounded-2xl px-6 py-4 flex flex-wrap items-center gap-6 w-fit">
<div className="flex items-center gap-3">
<EmptyWallet size={22} color="black" />
<div>
<div className="text-xs text-description">
{typeof totalCost === "number"
? "مجموع هزینه کل"
: "مجموع هزینه این صفحه"}
</div>
<div className="text-sm font-medium min-w-[100px]">
{formatPrice(typeof totalCost === "number" ? totalCost : pageCost)}
</div>
</div>
</div>
{typeof chatsData?.meta?.total === "number" && (
<div>
<div className="text-xs text-description">تعداد سوالات</div>
<div className="text-sm font-medium">
{chatsData.meta.total.toLocaleString("fa-IR")}
</div>
</div>
)}
</div>
<div className="mt-8">
<Filters
fields={filterFields}
+43 -1
View File
@@ -9,12 +9,16 @@ import {
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'
@@ -25,6 +29,7 @@ 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'
@@ -32,6 +37,7 @@ 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()
@@ -41,8 +47,10 @@ const OrderDetails: FC = () => {
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 [showPleaseComeSmsModal, setShowPleaseComeSmsModal] = useState(false)
const [refundModal, setRefundModal] = useState<{ defaultAmount: number; maxAmount: number } | null>(null)
const [showAddPaymentModal, setShowAddPaymentModal] = useState(false)
const [showEditFeesModal, setShowEditFeesModal] = useState(false)
@@ -98,6 +106,7 @@ const OrderDetails: FC = () => {
OrderStatus.DELIVERED_TO_RECEPTIONIST,
OrderStatus.SHIPPED,
].includes(order.status as OrderStatus)
const canSendPleaseComeSms = Boolean(order.user?.phone)
const isCourierDelivery = order.deliveryMethod?.method === DeliveryMethodEnum.DeliveryCourier
const isDineInOrCarDelivery =
order.deliveryMethod?.method === DeliveryMethodEnum.DineIn ||
@@ -306,12 +315,33 @@ const OrderDetails: FC = () => {
)
}
const handleConfirmPleaseComeSms = () => {
sendPleaseComeSms(order.id, {
onSuccess: (response) => {
toast.success(response.message || 'پیامک در صف ارسال قرار گرفت')
setShowPleaseComeSmsModal(false)
},
onError: (error) => {
toast.error(extractErrorMessage(error))
},
})
}
return (
<div className='mt-5 pb-8'>
<div className='flex justify-between items-center'>
<h1 className='text-lg font-light'>
سفارش {formatFaNumber(order.orderNumber)}
</h1>
<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 flex-col lg:flex-row gap-5 lg:gap-7 mt-6 sm:mt-9'>
@@ -356,13 +386,16 @@ 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>
@@ -416,6 +449,15 @@ const OrderDetails: FC = () => {
isloading={isRemovingItem}
label='آیا از حذف این آیتم مطمئن هستید؟'
/>
<SendPleaseComeSmsModal
isOpen={showPleaseComeSmsModal}
onClose={() => setShowPleaseComeSmsModal(false)}
isLoading={isSendingPleaseComeSms}
customerName={order.user?.firstName || ''}
restaurantName={order.restaurant?.name || ''}
onConfirm={handleConfirmPleaseComeSms}
/>
</div>
)
}
+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
+141 -123
View File
@@ -1,144 +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-5 sm:mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-1 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 sm:col-span-2 lg:col-span-1'
/>
)}
</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;
+182 -139
View File
@@ -1,146 +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'> = {
'new': 'pending',
'preparing': 'warning',
'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";
};
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 || '-'
}
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 }: 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: 'details',
title: '',
render: (item: Order) => {
return (
<Link to={Pages.orders.detail + item.id}>
<Eye size={20} color="#8C90A3" className="cursor-pointer" />
</Link>
)
}
},
]
}
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>
);
},
},
];
};
@@ -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
+6
View File
@@ -155,3 +155,9 @@ export const useRemoveOrderItem = () => {
},
});
};
export const useSendPleaseComeSms = () => {
return useMutation({
mutationFn: (orderId: string) => api.sendPleaseComeSms(orderId),
});
};
+7
View File
@@ -191,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;
};
+46 -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,6 +28,22 @@ 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('انتخاب موقعیت روی نقشه الزامی است'),
@@ -51,6 +68,7 @@ const GeneralSettings: FC = () => {
menuColor: '#000000',
phones: [''],
instagram: '',
domain: '',
address: '',
latitude: 0,
longitude: 0,
@@ -58,6 +76,7 @@ const GeneralSettings: FC = () => {
logo: '',
establishedYear: null,
serviceArea: undefined,
enableAiChat: false,
},
validationSchema,
onSubmit: (values) => {
@@ -110,6 +129,7 @@ const GeneralSettings: FC = () => {
? [restaurantData.phone]
: [''],
instagram: restaurantData.instagram || '',
domain: restaurantData.domain || '',
address: restaurantData.address || '',
latitude: restaurantData.latitude || 0,
longitude: restaurantData.longitude || 0,
@@ -117,6 +137,7 @@ const GeneralSettings: FC = () => {
logo: restaurantData.logo || '',
establishedYear: restaurantData.establishedYear ?? null,
serviceArea: restaurantData.serviceArea || undefined,
enableAiChat: restaurantData.enableAiChat ?? false,
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -203,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='آدرس'
@@ -243,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",
}
+3
View File
@@ -18,7 +18,9 @@ export type UpdateRestaurantType = {
longitude?: number;
description?: string;
establishedYear?: number | null;
domain?: string;
serviceArea?: GeoJSONPolygon;
enableAiChat?: boolean;
score?: {
purchaseAmount: string;
purchaseScore: string;
@@ -50,6 +52,7 @@ export type Restaurant = {
longitude: number | null;
serviceArea: GeoJSONPolygon;
isActive: boolean;
enableAiChat: boolean;
establishedYear: number | null;
phoneNumber: string | null;
phone?: string;