This commit is contained in:
hamid zarghami
2026-02-22 16:46:58 +03:30
parent 8cb42dbd7e
commit 15008c6b52
10 changed files with 681 additions and 17 deletions
+4
View File
@@ -9,6 +9,10 @@ export const Paths = {
myRequest: '/my-requests',
newRequest: '/new-request',
},
orderDetails: '/order/',
order: {
myOrders: '/orders',
},
tickets: {
list: '/tickets/list',
create: '/tickets/create',
+106
View File
@@ -0,0 +1,106 @@
import Tabs from '@/components/Tabs'
import { useState, type FC } from 'react'
import { TabMyRequestsEnum } from './enum/OrderEnum'
import Filters from '@/components/Filters'
import { Eye } from 'iconsax-react'
import Table from '@/components/Table'
import { useGetMyOrders } from './hooks/useOrderData'
import moment from 'moment-jalaali'
import { Link } from 'react-router-dom'
import { Paths } from '@/config/Paths'
const MyOrders: FC = () => {
const [page, setPage] = useState<number>(1)
const [activeTab, setActiveTab] = useState<TabMyRequestsEnum>(
TabMyRequestsEnum.ALL,
)
const { data } = useGetMyOrders(page, activeTab)
return (
<div className="mt-5">
<h1>سفارشات من</h1>
<div className="mt-10">
<Tabs
items={[
{ label: 'همه', value: TabMyRequestsEnum.ALL },
{ label: 'در حال انجام', value: TabMyRequestsEnum.IN_PROGRESS },
{ label: 'تکمیل داده شده', value: TabMyRequestsEnum.DELIVERED },
{ label: 'کنسل شده', value: TabMyRequestsEnum.CANCELLED },
]}
activeTab={activeTab}
onTabChange={(tab) => setActiveTab(tab as TabMyRequestsEnum)}
/>
</div>
<div className="mt-16 flex justify-between items-center">
<Filters
fields={[
{ type: 'input', name: 'search', placeholder: 'جستجو' },
{ type: 'date', name: 'date', placeholder: 'تاریخ' },
{
type: 'select',
name: 'status',
placeholder: 'وضعیت',
options: [
{ label: 'همه', value: 'all' },
{ label: 'در حال انجام', value: 'in_progress' },
{ label: 'تایید شده', value: 'confirmed' },
{ label: 'تحویل داده شده', value: 'delivered' },
{ label: 'کنسل شده', value: 'cancelled' },
],
},
]}
onChange={() => {}}
/>
</div>
<div className="mt-10">
<Table
columns={[
{
key: 'orderNumber',
title: 'شماره',
},
{
key: 'title',
title: 'عنوان',
},
{
key: 'createdAt',
title: 'زمان ثبت سفارش',
render: (item) => {
return (
<div>{moment(item.createdAt).format('jYYYY-jMM-jDD')}</div>
)
},
},
{
key: 'status',
title: 'وضعیت سفارش',
},
{
key: 'actions',
title: '',
render: (item) => {
return (
<Link to={Paths.orderDetails + item.id}>
<Eye size={20} color="#000000" />
</Link>
)
},
},
]}
data={data?.data}
pagination={{
currentPage: page,
onPageChange: setPage,
totalPages: data?.meta?.totalPages || 1,
}}
/>
</div>
</div>
)
}
export default MyOrders
+166
View File
@@ -0,0 +1,166 @@
import { type FC } from 'react'
import { Receipt1, Paperclip2, Calendar } from 'iconsax-react'
import { useParams } from 'react-router-dom'
import moment from 'moment-jalaali'
import { useGetOrderDetails } from './hooks/useOrderData'
import TicketSection from './components/TicketSection'
import { getFileNameAndExtensionFromUrl } from '@/config/func'
import { RequestStatusEnum } from './enum/OrderEnum'
const statusLabels: Record<string, string> = {
[RequestStatusEnum.CREATED]: 'ایجاد شده',
[RequestStatusEnum.INVOICED]: 'فاکتور شده',
[RequestStatusEnum.WAITING_to_CONFIRM_INVOICE]: 'در انتظار تایید فاکتور',
[RequestStatusEnum.DESIGN_INITIATED]: 'شروع طراحی',
[RequestStatusEnum.DESIGN_FINISHED]: 'طراحی تمام شده',
[RequestStatusEnum.DESIGN_REVISION]: 'بازنگری طراحی',
[RequestStatusEnum.READY_TO_PRINTING]: 'آماده چاپ',
[RequestStatusEnum.IN_PRINTING]: 'در حال چاپ',
[RequestStatusEnum.AFTER_PRINT_COVER]: 'پس از چاپ جلد',
[RequestStatusEnum.AFTER_PRINT_CARTONING]: 'کارتن‌سازی',
[RequestStatusEnum.AFTER_PRINT_BOXING]: 'بسته‌بندی',
[RequestStatusEnum.READY_FOR_DELIVERY]: 'آماده ارسال',
[RequestStatusEnum.DELIVERED]: 'تحویل شده',
[RequestStatusEnum.FINISHED]: 'پایان یافته',
[RequestStatusEnum.CANCELED]: 'لغو شده',
}
const OrderDetail: FC = () => {
const { id } = useParams()
const { data } = useGetOrderDetails(id!)
const order = data?.data
const handleOpenLink = (url: string) => {
window.open(url, '_blank')
}
return (
<div className="w-full min-h-screen bg-[#eceef6] p-6">
<div className="flex items-start justify-between mb-6">
<div className="text-sm text-[#8C90A3]">سفارش #{order?.orderNumber}</div>
</div>
<div className="bg-white rounded-2xl p-6 mb-6">
<div className="flex justify-between items-center">
<h2 className="text-lg font-light mb-6">اطلاعات سفارش</h2>
<a href="#" className="flex items-center gap-2 text-[#0037FF] text-sm">
<Receipt1 size={20} color="#3B82F6" />
<span>پیش فاکتور</span>
</a>
</div>
<div className="flex flex-col gap-10">
<div>
<div className="flex items-center gap-6">
{order?.product && (
<div className="size-[86px] rounded-lg overflow-hidden flex-shrink-0">
<img
src={order.product.images?.[0]}
className="size-full object-cover"
alt=""
/>
</div>
)}
<div className="flex-1 flex gap-x-16 gap-y-4 pr-4 flex-wrap">
<div className="flex items-center gap-2">
<div className="text-xs text-desc">عنوان:</div>
<div className="text-sm font-medium text-black">
{order?.title ?? '-'}
</div>
</div>
<div className="flex items-center gap-2">
<div className="text-xs text-desc">محصول:</div>
<div className="text-sm font-medium text-black">
{order?.product?.title ?? '-'}
</div>
</div>
<div className="flex items-center gap-2">
<div className="text-xs text-desc">تخمین زمان:</div>
<div className="text-sm font-medium text-black">
{order?.estimatedDays ?? '-'} روز
</div>
</div>
<div className="flex items-center gap-2">
<div className="text-xs text-desc">وضعیت:</div>
<div className="text-sm font-medium text-black">
{order?.status
? statusLabels[order.status] ?? order.status
: '-'}
</div>
</div>
<div className="flex items-center gap-2">
<Calendar size={16} className="text-desc" />
<div className="text-xs text-desc">تاریخ ثبت:</div>
<div className="text-sm font-medium text-black">
{order?.createdAt
? moment(order.createdAt).format('jYYYY/jMM/jDD HH:mm')
: '-'}
</div>
</div>
</div>
</div>
{order?.user && (
<div className="mt-6 pt-6 border-t border-dashed border-desc">
<div className="text-sm font-medium mb-3 text-desc">
اطلاعات کاربر
</div>
<div className="flex gap-x-12 gap-y-2 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-xs text-desc">نام:</span>
<span className="text-sm text-black">
{order.user.firstName} {order.user.lastName}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-desc">تلفن:</span>
<span className="text-sm text-black">
{order.user.phone ?? '-'}
</span>
</div>
{order.user.addresse && (
<div className="flex items-center gap-2">
<span className="text-xs text-desc">آدرس:</span>
<span className="text-sm text-black">
{order.user.addresse}
</span>
</div>
)}
</div>
</div>
)}
{order?.attachments && order.attachments.length > 0 && (
<div className="mt-6 pt-6 border-t border-dashed border-desc">
<div className="text-sm font-medium mb-3 text-desc flex items-center gap-2">
<Paperclip2 size={18} />
اتچمنتها
</div>
<div className="flex flex-wrap gap-3">
{order.attachments.map((url) => {
const { fileName } = getFileNameAndExtensionFromUrl(url)
return (
<button
key={url}
type="button"
onClick={() => handleOpenLink(url)}
className="flex cursor-pointer items-center gap-2 px-3 py-2 rounded-lg bg-[#F5F7FC] hover:bg-[#eceef6] transition-colors text-[#0047FF] text-sm"
>
<Paperclip2 size={18} color="#0047FF" />
<span>{fileName || 'فایل ضمیمه'}</span>
</button>
)
})}
</div>
</div>
)}
</div>
</div>
</div>
<TicketSection />
</div>
)
}
export default OrderDetail
@@ -0,0 +1,233 @@
import Button from '@/components/Button'
import UploadBox from '@/components/UploadBox'
import { Microphone, Paperclip2 } from 'iconsax-react'
import { useState, type FC } from 'react'
import { useVoiceRecorder } from '@/hooks/useVoiceRecorder'
import { Play, Pause } from 'iconsax-react'
import { useMultiUpload, useSingleUpload } from '@/pages/uploader/hooks/useUploader'
import type { AddTicketType } from '@/pages/request/type/Types'
import { useAddTicket, useGetTickets } from '../hooks/useOrderData'
import { useParams } from 'react-router-dom'
import { toast } from '@/shared/toast'
import { extractErrorMessage, getFileNameAndExtensionFromUrl } from '@/config/func'
import VoicePlayer from '@/components/VoicePlayer'
const TicketSection: FC = () => {
const { id } = useParams()
const [message, setMessage] = useState('')
const [files, setFiles] = useState<File[]>([])
const addTicket = useAddTicket()
const { data, refetch } = useGetTickets(id!)
const singleUpload = useSingleUpload()
const multiUpload = useMultiUpload()
const {
isRecording,
isPlaying,
audioUrl,
audioRef,
togglePlayPause,
startRecording,
stopRecording,
progress,
audioFile,
resetRecorder,
} = useVoiceRecorder()
const handleSubmit = async () => {
const params: AddTicketType = {
attachments: [],
content: message,
}
if (audioFile) {
await singleUpload.mutateAsync(audioFile, {
onSuccess: (data) => {
params.attachments.push({
type: 'voice',
url: data?.data?.url,
})
},
})
}
if (files.length) {
await multiUpload.mutateAsync(files, {
onSuccess: (data) => {
data?.data?.map((item) => {
params.attachments.push({
type: 'uploads_attach',
url: item.url,
})
})
},
})
}
addTicket.mutate(
{ requestId: id!, params },
{
onSuccess: () => {
toast('پیام شما با موفقیت ارسال شد')
refetch()
setMessage('')
setFiles([])
resetRecorder()
},
onError: (error) => {
toast(extractErrorMessage(error), 'error')
},
},
)
}
const handleOpenLink = (url: string) => {
window.open(url, '_blank')
}
return (
<div className="bg-white rounded-2xl p-6">
{data?.data?.map((item) => {
if (item.user)
return (
<div
key={item.id}
className="bg-[#F5F7FC] rounded-4xl rounded-tr-none mt-6 p-6 max-w-[55%]"
>
<div className="text-sm font-light text-black leading-6">
{item.content}
</div>
<div className="flex gap-3 flex-wrap mt-3">
{item.attachments
?.filter((a) => a.type !== 'voice')
.map((attach) => (
<div
key={attach.url}
onClick={() => handleOpenLink(attach.url)}
className="flex cursor-pointer items-center gap-1.5 text-[#0047FF]"
>
<Paperclip2 size={20} color="#0047FF" />
<div className="text-xs">
{getFileNameAndExtensionFromUrl(attach.url).fileName}
</div>
</div>
))}
</div>
{item.attachments
?.filter((a) => a.type === 'voice')
.map((voice) => (
<VoicePlayer key={voice.url} url={voice.url} />
))}
</div>
)
if (item.admin)
return (
<div className="flex justify-end">
<div className="bg-[#F5F7FC] rounded-4xl rounded-tl-none mt-6 p-6 max-w-[55%]">
<div className="flex gap-1 text-sm mb-2">
<div className="font-bold">طراح : </div>
<div>
{item.admin?.firstName + ' ' + item?.admin?.lastName}
</div>
</div>
<div className="text-sm font-light text-black leading-6">
{item.content}
</div>
<div className="flex gap-3 flex-wrap mt-3">
{item.attachments
?.filter((a) => a.type !== 'voice')
.map((attach) => (
<div
key={attach.url}
onClick={() => handleOpenLink(attach.url)}
className="flex cursor-pointer items-center gap-1.5 text-[#0047FF]"
>
<Paperclip2 size={20} color="#0047FF" />
<div className="text-xs">
{getFileNameAndExtensionFromUrl(attach.url).fileName}
</div>
</div>
))}
</div>
{item.attachments
?.filter((a) => a.type === 'voice')
.map((voice) => (
<VoicePlayer key={voice.url} url={voice.url} />
))}
</div>
</div>
)
})}
<div className="mt-6">
<div className="text-sm mb-2 text-black">پیام شما</div>
<div className="relative">
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
className="w-full h-40 bg-white border border-border rounded-xl p-4 text-sm resize-none outline-none"
placeholder=""
/>
<button
onClick={isRecording ? stopRecording : startRecording}
className="absolute left-4 bottom-4 bg-[#FFF1D7] size-8 rounded-lg flex items-center justify-center"
>
<Microphone
size={20}
color={isRecording ? '#EF4444' : 'black'}
variant={isRecording ? 'Bold' : 'Outline'}
/>
</button>
</div>
{audioUrl && (
<div className="w-full h-12 bg-white border border-border rounded-xl flex items-center px-3 gap-3 mt-3">
<button
onClick={togglePlayPause}
className="w-8 h-8 rounded-full bg-black flex items-center justify-center flex-shrink-0"
>
{isPlaying ? (
<Pause size={16} color="#fff" variant="Bold" />
) : (
<Play size={16} color="#fff" variant="Bold" />
)}
</button>
<div className="flex-1 flex items-center gap-[2px] h-8">
{Array.from({ length: 60 }).map((_, i) => {
const passed = i / 60 <= progress
return (
<div
key={i}
className="w-[2px] rounded-full transition-all"
style={{
height: `${Math.random() * 18 + 4}px`,
backgroundColor: passed ? '#0047FF' : '#E5E7EB',
}}
/>
)
})}
</div>
<audio ref={audioRef} src={audioUrl} className="hidden" />
</div>
)}
</div>
<div className="mt-6">
<UploadBox label="فایل های ضمیمه" isMultiple={true} onChange={setFiles} />
</div>
<div className="flex mt-10 justify-end">
<Button
label="ارسال"
onClick={handleSubmit}
className="w-[150px]"
isLoading={
singleUpload.isPending ||
multiUpload.isPending ||
addTicket.isPending
}
/>
</div>
</div>
)
}
export default TicketSection
+1
View File
@@ -0,0 +1 @@
export { TabMyRequestsEnum, RequestStatusEnum } from '@/pages/request/enum/RequestEnum'
+42
View File
@@ -0,0 +1,42 @@
import { useMutation, useQuery } from '@tanstack/react-query'
import * as api from '../service/OrderService'
import type { TabMyRequestsEnum } from '../enum/OrderEnum'
import type { AddTicketType } from '../type/Types'
export const useGetMyOrders = (
page: number = 1,
statuses: TabMyRequestsEnum,
) => {
return useQuery({
queryKey: ['my-orders', page, statuses],
queryFn: () => api.getMyOrders(page, statuses),
})
}
export const useGetOrderDetails = (id: string) => {
return useQuery({
queryKey: ['order', id],
queryFn: () => api.getOrderDetails(id),
enabled: !!id,
})
}
export const useGetTickets = (orderId: string) => {
return useQuery({
queryKey: ['tickets', orderId],
queryFn: () => api.getTickets(orderId),
enabled: !!orderId,
})
}
export const useAddTicket = () => {
return useMutation({
mutationFn: ({
requestId,
params,
}: {
requestId: string
params: AddTicketType
}) => api.addTicket(requestId, params),
})
}
+60
View File
@@ -0,0 +1,60 @@
import axios from '@/config/axios'
import type {
AddTicketType,
MyOrdersResponseType,
OrderDetailResponseType,
TicketsResponseType,
} from '../type/Types'
import { RequestStatusEnum, TabMyRequestsEnum } from '../enum/OrderEnum'
export const getMyOrders = async (
page: number = 1,
active: TabMyRequestsEnum,
) => {
const statuses =
active === TabMyRequestsEnum.IN_PROGRESS
? [
RequestStatusEnum.DESIGN_FINISHED,
RequestStatusEnum.DESIGN_INITIATED,
RequestStatusEnum.DESIGN_REVISION,
RequestStatusEnum.IN_PRINTING,
RequestStatusEnum.AFTER_PRINT_BOXING,
RequestStatusEnum.AFTER_PRINT_CARTONING,
RequestStatusEnum.AFTER_PRINT_COVER,
RequestStatusEnum.READY_FOR_DELIVERY,
RequestStatusEnum.READY_TO_PRINTING,
]
: active === TabMyRequestsEnum.CANCELLED
? [RequestStatusEnum.CANCELED]
: active === TabMyRequestsEnum.DELIVERED
? [RequestStatusEnum.DELIVERED]
: undefined
let query = `?page=${page}`
if (statuses) {
query += `&statuses=${statuses}`
}
const { data } = await axios.get<MyOrdersResponseType>(
`/public/orders${query}`,
)
return data
}
export const getOrderDetails = async (id: string) => {
const { data } = await axios.get<OrderDetailResponseType>(
`/public/orders/${id}`,
)
return data
}
export const getTickets = async (id: string) => {
const { data } = await axios.get<TicketsResponseType>(
`/public/ticket/ref/${id}`,
)
return data
}
export const addTicket = async (id: string, params: AddTicketType) => {
const { data } = await axios.post(`/public/ticket/ref/${id}`, params)
return data
}
+57
View File
@@ -0,0 +1,57 @@
import type { BaseResponse } from '@/shared/types/Types'
import type { RowDataType } from '@/components/types/TableTypes'
export type {
AddTicketType,
TicketType,
TicketsResponseType,
} from '@/pages/request/type/Types'
export type OrderProductType = {
id: string
createdAt: string
deletedAt: string | null
category: string
title: string
desc: string
quantities: number[]
linkUrl: string | null
isActive: boolean
images?: string[]
order: number
}
export type OrderUserType = {
id: string
createdAt: string
deletedAt: string | null
firstName: string | null
lastName: string | null
isActive: boolean
gender: boolean
maxCredit: number
addresse: string | null
phone: string
}
export interface OrderType extends RowDataType {
id: string
createdAt: string
deletedAt: string | null
invoiceItem: string
product: OrderProductType | null
print: string | null
title: string
type: string
user: OrderUserType
designer: unknown
creator: string
orderNumber: number
status: string
estimatedDays: number
attachments: string[]
}
export type MyOrdersResponseType = BaseResponse<OrderType[]>
export type OrderDetailResponseType = BaseResponse<OrderType>
+4
View File
@@ -8,6 +8,8 @@ import MyRequests from '@/pages/request/MyRequests'
import ProformaInvoice from '@/pages/invoice/ProformaInvoice'
import NewRequest from '@/pages/request/NewRequest'
import RequestDetail from '@/pages/request/RequestDetail'
import MyOrders from '@/pages/order/MyOrders'
import OrderDetail from '@/pages/order/OrderDetail'
import TicketList from '@/pages/ticket/TicketList'
import CreateTicket from '@/pages/ticket/CreateTicket'
import TicketDetail from '@/pages/ticket/Detail'
@@ -31,9 +33,11 @@ const MainRouter: FC = () => {
<Route path="/" element={<Home />} />
<Route path={Paths.home} element={<Home />} />
<Route path={Paths.myRequests} element={<MyRequests />} />
<Route path={Paths.order.myOrders} element={<MyOrders />} />
<Route path={Paths.proformaInvoice} element={<ProformaInvoice />} />
<Route path={Paths.newRequest} element={<NewRequest />} />
<Route path={`${Paths.requestDetails}:id`} element={<RequestDetail />} />
<Route path={`${Paths.orderDetails}:id`} element={<OrderDetail />} />
<Route path={Paths.tickets.list} element={<TicketList />} />
<Route path={Paths.tickets.create} element={<CreateTicket />} />
<Route path={`${Paths.tickets.detail}:id`} element={<TicketDetail />} />
+8 -17
View File
@@ -1,13 +1,12 @@
import { type FC } from 'react'
import LogoImage from '@/assets/images/logo.svg'
import { AddSquare, DocumentText, Element3, Home2, Messages3, NotificationStatus, Receipt21, Teacher } from 'iconsax-react'
import { DocumentText, Element3, Home2, Messages3, NotificationStatus, Receipt21, ShoppingCart, Teacher } from 'iconsax-react'
import SideBarItem from './SidebarItem'
import { Link, useLocation } from 'react-router-dom'
import { Paths } from '../config/Paths'
import { useSharedStore } from './store/useSharedStore'
import { clx } from '../helpers/utils'
import { t } from '../locale'
import Button from '@/components/Button'
const SideBar: FC = () => {
@@ -59,6 +58,13 @@ const SideBar: FC = () => {
link={Paths.request.myRequest}
/>
<SideBarItem
icon={<ShoppingCart size={iconSizeSideBar} color='#4F5260' />}
title={t('sidebar.myOrders')}
isActive={isActive('/orders')}
link={Paths.order.myOrders}
/>
<SideBarItem
icon={<Receipt21 size={iconSizeSideBar} color='#4F5260' />}
title={t('sidebar.preFactors')}
@@ -105,21 +111,6 @@ const SideBar: FC = () => {
<div className='flex-1 items-end mt-14 pb-8'>
<div className='bg-[#F5F7FC] rounded-2xl p-4 text-center'>
<div className='text-[15px] font-medium'>{t('sidebar.submitYourOrder')}</div>
<div className='mt-2.5 text-[13px] text-[#7B7E8B]'>{t('sidebar.submitYourOrderDescription')}</div>
<Link to={Paths.newRequest}>
<Button
className='flex items-center mt-2.5'
>
<div className='flex gap-1'>
<AddSquare size={iconSizeSideBar} color='black' />
<div>{t('sidebar.newOrder')}</div>
</div>
</Button>
</Link>
</div>
{/* <div className='text-xs text-[#8C90A3]'>
<SideBarItem
icon={<Logout variant={isActive('logout') ? 'Bold' : 'Outline'} color={isActive('logout') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}