@@ -1,53 +1,119 @@
|
||||
import { type FC } from 'react'
|
||||
import { useGetRequestDetail } from './hooks/useRequestData'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import RequestItem from './components/RequestItem'
|
||||
import moment from 'moment-jalaali'
|
||||
import { Calendar, Profile2User, TickSquare } from 'iconsax-react'
|
||||
import { useGetRequestDetail } from './hooks/useRequestData'
|
||||
import RequestDetailItem from './components/RequestDetailItem'
|
||||
import TicketSection from './components/TicketSection'
|
||||
import { Paths } from '@/config/Paths'
|
||||
import BackButton from '@/components/BackButton'
|
||||
import Button from '@/components/Button'
|
||||
import RefreshButton from '@/components/RefreshButton'
|
||||
import { TickSquare } from 'iconsax-react'
|
||||
import TicketSection from '../order/components/TicketSection'
|
||||
|
||||
const RequestDetail: FC = () => {
|
||||
const { id } = useParams()
|
||||
const { data, refetch, isFetching } = useGetRequestDetail(id!)
|
||||
const { data, refetch, isFetching, isPending, isError } = useGetRequestDetail(id!)
|
||||
const request = data?.data
|
||||
const items = request?.items ?? []
|
||||
|
||||
const customerName =
|
||||
[request?.user?.firstName, request?.user?.lastName].filter(Boolean).join(' ') ||
|
||||
request?.user?.phone ||
|
||||
undefined
|
||||
|
||||
if (isPending && !request) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[240px]">
|
||||
<span className="text-sm text-[#8C90A3]">در حال بارگذاری...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !request) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[240px] gap-4">
|
||||
<span className="text-sm text-[#8C90A3]">درخواست یافت نشد</span>
|
||||
<BackButton to={Paths.requests.list} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen bg-[#eceef6] p-6">
|
||||
{/* Header Section */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="pb-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||
<BackButton to={Paths.requests.list} />
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<RefreshButton onClick={() => refetch()} isLoading={isFetching} />
|
||||
<Link to={Paths.perfomaInvoice.create + `?requestId=${id}`}>
|
||||
<Button className="w-fit px-5">
|
||||
<div className="flex gap-2 items-center">
|
||||
<TickSquare size={20} color="black" />
|
||||
<div>ثبت پیش فاکتور</div>
|
||||
</div>
|
||||
</Button>
|
||||
<Button className="w-fit px-5">
|
||||
<div className="flex gap-2 items-center">
|
||||
<TickSquare size={20} color="black" />
|
||||
<span>ثبت پیش فاکتور</span>
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-[#8C90A3] mb-6">
|
||||
درخواست #{data?.data?.requestNumber}
|
||||
</div>
|
||||
|
||||
{/* Request Information Section */}
|
||||
<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>
|
||||
</div>
|
||||
<div className='flex flex-col gap-10'>
|
||||
{data?.data?.items?.map((item) => (
|
||||
<RequestItem key={item.id} item={item} />
|
||||
))}
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="text-lg font-light text-black">
|
||||
درخواست #{request.requestNumber}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 mt-2 text-xs text-[#8C90A3]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar size={14} />
|
||||
<span>
|
||||
تاریخ ثبت:{' '}
|
||||
{moment(request.createdAt).format('jYYYY/jMM/jDD HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
{items.length > 0 && (
|
||||
<>
|
||||
<span className="text-[#D8DCE8]">|</span>
|
||||
<span>{items.length} قلم</span>
|
||||
</>
|
||||
)}
|
||||
{customerName && (
|
||||
<>
|
||||
<span className="text-[#D8DCE8]">|</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Profile2User size={14} />
|
||||
<span>{customerName}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{request.user?.phone && (
|
||||
<>
|
||||
<span className="text-[#D8DCE8]">|</span>
|
||||
<span dir="ltr">{request.user.phone}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TicketSection />
|
||||
<section className="bg-white rounded-2xl p-6 md:p-8 mb-6">
|
||||
<h2 className="text-sm text-black mb-6">اطلاعات درخواست</h2>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-[#8C90A3]">قلمی برای این درخواست ثبت نشده است.</p>
|
||||
) : (
|
||||
<div>
|
||||
{items.map((item, index) => (
|
||||
<RequestDetailItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
total={items.length}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<TicketSection customerName={customerName} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { type FC } from 'react'
|
||||
import { Paperclip2 } from 'iconsax-react'
|
||||
import placeholderProductImage from '@/assets/images/placeholder-product.svg'
|
||||
import { getFileNameAndExtensionFromUrl } from '@/config/func'
|
||||
import VoicePlayer from '@/components/VoicePlayer'
|
||||
import { useGetEntityField } from '@/pages/formBuilder/hooks/useFormBuilderData'
|
||||
import type { RequestDetailItemType } from '../types/Types'
|
||||
|
||||
type Props = {
|
||||
item: RequestDetailItemType
|
||||
index: number
|
||||
total: number
|
||||
}
|
||||
|
||||
const isImageUrl = (url: string) => /\.(jpe?g|png|gif|webp|bmp|svg)(\?|$)/i.test(url)
|
||||
|
||||
const RequestDetailItem: FC<Props> = ({ item, index, total }) => {
|
||||
const { data: fields } = useGetEntityField(String(item.product?.id ?? ''))
|
||||
const attributes = item.attributes ?? []
|
||||
const productImage = item.product?.images?.[0]
|
||||
|
||||
const getAttributeFieldId = (attribute: NonNullable<RequestDetailItemType['attributes']>[number]) =>
|
||||
attribute.fieldId ?? attribute.attributeId ?? attribute.field?.id
|
||||
|
||||
const fieldRows =
|
||||
fields?.data?.map((field) => ({
|
||||
id: field.id,
|
||||
name: field.name,
|
||||
value: attributes.find(
|
||||
(attribute) => String(getAttributeFieldId(attribute)) === String(field.id)
|
||||
)?.value,
|
||||
})) ?? []
|
||||
|
||||
const matchedFieldIds = new Set(fieldRows.map((field) => String(field.id)))
|
||||
const extraAttributeRows = attributes
|
||||
.filter((attribute) => {
|
||||
const fieldId = getAttributeFieldId(attribute)
|
||||
return fieldId && !matchedFieldIds.has(String(fieldId))
|
||||
})
|
||||
.map((attribute) => ({
|
||||
id: String(getAttributeFieldId(attribute)),
|
||||
name: attribute.field?.name ?? String(getAttributeFieldId(attribute)),
|
||||
value: attribute.value,
|
||||
}))
|
||||
|
||||
const attributeRows = [...fieldRows, ...extraAttributeRows]
|
||||
const voiceAttachments = item.attachments?.filter((a) => a.type === 'voice') ?? []
|
||||
const fileAttachments = item.attachments?.filter((a) => a.type !== 'voice') ?? []
|
||||
|
||||
const handleOpenLink = (url: string) => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={index > 0 ? 'pt-8 mt-8 border-t border-[#EBEDF5]' : ''}>
|
||||
<div className="flex items-start gap-5">
|
||||
<div className="size-20 rounded-xl overflow-hidden bg-[#F5F7FC] shrink-0">
|
||||
<img
|
||||
src={productImage || placeholderProductImage}
|
||||
alt={item.product?.title ?? 'محصول'}
|
||||
className="size-full object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = placeholderProductImage
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-[#8C90A3]">عنوان:</span>
|
||||
<span className="text-sm font-medium text-black">{item.product?.title}</span>
|
||||
</div>
|
||||
{total > 1 && (
|
||||
<span className="text-xs text-[#8C90A3] bg-[#F5F7FC] rounded-full px-2.5 py-0.5">
|
||||
قلم {index + 1} از {total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 pt-5 border-t border-dashed border-[#D8DCE8]">
|
||||
<div className="text-xs text-[#8C90A3] mb-2">شرح درخواست</div>
|
||||
<p className="text-sm text-black leading-7 whitespace-pre-wrap">
|
||||
{item.description || '—'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{attributeRows.length > 0 && (
|
||||
<div className="mt-5 pt-5 border-t border-dashed border-[#D8DCE8]">
|
||||
<div className="text-xs text-[#8C90A3] mb-3">ویژگیها</div>
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
{attributeRows.map((field) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex items-center gap-2 bg-[#F5F7FC] rounded-lg px-3 py-2"
|
||||
>
|
||||
<span className="text-xs text-[#8C90A3] shrink-0">
|
||||
{field.name}:
|
||||
</span>
|
||||
<span className="text-sm text-black truncate">
|
||||
{field.value || '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileAttachments.length > 0 && (
|
||||
<div className="mt-5 pt-5 border-t border-dashed border-[#D8DCE8]">
|
||||
<div className="text-xs text-[#8C90A3] mb-3 flex items-center gap-1.5">
|
||||
<Paperclip2 size={16} />
|
||||
پیوستها
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{fileAttachments.map((att) =>
|
||||
isImageUrl(att.url) ? (
|
||||
<a
|
||||
key={att.url}
|
||||
href={att.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block size-20 rounded-xl overflow-hidden border border-[#EBEDF5] hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<img
|
||||
src={att.url}
|
||||
alt="پیوست"
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
key={att.url}
|
||||
type="button"
|
||||
onClick={() => handleOpenLink(att.url)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[#F5F7FC] hover:bg-[#eceef6] transition-colors text-[#0047FF] text-xs"
|
||||
>
|
||||
<Paperclip2 size={16} color="#0047FF" />
|
||||
<span>
|
||||
{getFileNameAndExtensionFromUrl(att.url).fileName ||
|
||||
'فایل ضمیمه'}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{voiceAttachments.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{voiceAttachments.map((att) => (
|
||||
<VoicePlayer key={att.url} url={att.url} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default RequestDetailItem
|
||||
@@ -1,116 +0,0 @@
|
||||
import { type FC } from 'react'
|
||||
import type { RequestDetailItemType } from '../types/Types'
|
||||
import { useGetEntityField } from '@/pages/formBuilder/hooks/useFormBuilderData'
|
||||
import { Paperclip2 } from 'iconsax-react'
|
||||
import placeholderProductImage from '@/assets/images/placeholder-product.svg'
|
||||
|
||||
type Props = {
|
||||
item: RequestDetailItemType
|
||||
}
|
||||
|
||||
const RequestItem: FC<Props> = ({ item }) => {
|
||||
const { data: fields } = useGetEntityField(String(item.product?.id ?? ''))
|
||||
const attributes = item.attributes ?? []
|
||||
|
||||
const getAttributeFieldId = (attribute: NonNullable<RequestDetailItemType['attributes']>[number]) =>
|
||||
attribute.fieldId ?? attribute.attributeId ?? attribute.field?.id
|
||||
|
||||
const fieldRows = fields?.data?.map((field) => ({
|
||||
id: field.id,
|
||||
name: field.name,
|
||||
value: attributes.find((attribute) => String(getAttributeFieldId(attribute)) === String(field.id))?.value,
|
||||
})) ?? []
|
||||
|
||||
const matchedFieldIds = new Set(fieldRows.map((field) => String(field.id)))
|
||||
const extraAttributeRows = attributes
|
||||
.filter((attribute) => {
|
||||
const fieldId = getAttributeFieldId(attribute)
|
||||
return fieldId && !matchedFieldIds.has(String(fieldId))
|
||||
})
|
||||
.map((attribute) => ({
|
||||
id: String(getAttributeFieldId(attribute)),
|
||||
name: attribute.field?.name ?? String(getAttributeFieldId(attribute)),
|
||||
value: attribute.value,
|
||||
}))
|
||||
const attributeRows = [...fieldRows, ...extraAttributeRows]
|
||||
|
||||
const handleOpenAttachment = (url: string) => {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
const productImage = item.product?.images?.[0]
|
||||
|
||||
return (
|
||||
<div key={item.product?.id} className='border border-border rounded-3xl p-6'>
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Product Image */}
|
||||
<div className='size-[86px] rounded-lg overflow-hidden bg-gray-100'>
|
||||
<img
|
||||
src={productImage || placeholderProductImage}
|
||||
alt={item.product?.title ?? 'محصول'}
|
||||
className='size-full object-cover'
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = placeholderProductImage
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Request Details */}
|
||||
<div className="flex-1 flex gap-20 pr-10 flex-wrap">
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className="text-xs text-desc">عنوان:</div>
|
||||
<div className="text-sm font-medium text-black">{item.product?.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{item.description && (
|
||||
<div className="mt-6 pt-6 border-t border-dashed border-desc">
|
||||
<div className="text-sm font-medium mb-2 text-desc">شرح درخواست:</div>
|
||||
<p className="text-sm font-light text-black leading-6">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attributes */}
|
||||
{attributeRows.length ? (
|
||||
<>
|
||||
<div className='font-bold mb-5 mt-7'>ویژگی ها</div>
|
||||
{
|
||||
attributeRows.map((field) => (
|
||||
<div className='flex gap-3 items-center mt-3' key={field.id}>
|
||||
<div className='text-sm text-gray-500'>{field.name}:</div>
|
||||
<div className='text-sm -mt-px'>{field.value || '-'}</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Attachments */}
|
||||
{item.attachments?.length ? (
|
||||
<div className='mt-6 pt-6 border-t border-dashed border-desc'>
|
||||
<div className='text-sm font-medium mb-2 text-desc'>ضمایم:</div>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{item.attachments
|
||||
?.filter((url): url is string => typeof url === 'string')
|
||||
.map((url, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleOpenAttachment(url as string)}
|
||||
className="flex cursor-pointer items-center gap-1.5 text-[#0047FF] hover:underline"
|
||||
>
|
||||
<Paperclip2 size={20} color="#0047FF" />
|
||||
<span className="text-xs">فایل {index + 1}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RequestItem
|
||||
@@ -0,0 +1,84 @@
|
||||
import { type FC } from 'react'
|
||||
import moment from 'moment-jalaali'
|
||||
import { Paperclip2 } from 'iconsax-react'
|
||||
import { getFileNameAndExtensionFromUrl } from '@/config/func'
|
||||
import VoicePlayer from '@/components/VoicePlayer'
|
||||
import type { AttachmentsType } from '@/pages/order/types/Types'
|
||||
|
||||
type Props = {
|
||||
content: string
|
||||
attachments?: AttachmentsType[]
|
||||
senderName?: string
|
||||
senderLabel?: string
|
||||
createdAt?: string
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
const TicketMessage: FC<Props> = ({
|
||||
content,
|
||||
attachments = [],
|
||||
senderName,
|
||||
senderLabel = 'پشتیبان',
|
||||
createdAt,
|
||||
isAdmin = false,
|
||||
}) => {
|
||||
const fileAttachments = attachments.filter((a) => a.type !== 'voice')
|
||||
const voiceAttachments = attachments.filter((a) => a.type === 'voice')
|
||||
|
||||
const handleOpenLink = (url: string) => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={isAdmin ? 'flex justify-end' : ''}>
|
||||
<div
|
||||
className={`bg-[#F5F7FC] rounded-3xl p-5 max-w-[min(100%,520px)] ${
|
||||
isAdmin ? 'rounded-tl-none' : 'rounded-tr-none'
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-1 text-xs mb-2 text-[#8C90A3]">
|
||||
<span className="font-medium text-black">{senderLabel}:</span>
|
||||
{senderName ? <span>{senderName}</span> : null}
|
||||
</div>
|
||||
|
||||
{content && (
|
||||
<p className="text-sm text-black leading-7 whitespace-pre-wrap">{content}</p>
|
||||
)}
|
||||
|
||||
{fileAttachments.length > 0 && (
|
||||
<div className="flex gap-3 flex-wrap mt-3">
|
||||
{fileAttachments.map((attach) => (
|
||||
<button
|
||||
key={attach.url}
|
||||
type="button"
|
||||
onClick={() => handleOpenLink(attach.url)}
|
||||
className="flex cursor-pointer items-center gap-1.5 text-[#0047FF] hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Paperclip2 size={18} color="#0047FF" />
|
||||
<span className="text-xs">
|
||||
{getFileNameAndExtensionFromUrl(attach.url).fileName}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{voiceAttachments.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{voiceAttachments.map((voice) => (
|
||||
<VoicePlayer key={voice.url} url={voice.url} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createdAt && (
|
||||
<div className="mt-3 text-[11px] text-[#8C90A3] text-left" dir="ltr">
|
||||
{moment(createdAt).format('jYYYY/jMM/jDD HH:mm')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TicketMessage
|
||||
@@ -0,0 +1,234 @@
|
||||
import Button from '@/components/Button'
|
||||
import RefreshButton from '@/components/RefreshButton'
|
||||
import UploadBox from '@/components/UploadBox'
|
||||
import { Microphone, Play, Pause } from 'iconsax-react'
|
||||
import { useState, type FC } from 'react'
|
||||
import { useVoiceRecorder } from '@/hooks/useVoiceRecorder'
|
||||
import { useMultiUpload, useSingleUpload } from '@/pages/uploader/hooks/useUploader'
|
||||
import type { AddTicketType } from '@/pages/order/types/Types'
|
||||
import { useAddTicket, useGetTickets } from '@/pages/order/hooks/useOrderData'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { toast } from '@/shared/toast'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
import TicketMessage from './TicketMessage'
|
||||
|
||||
type Props = {
|
||||
customerName?: string
|
||||
}
|
||||
|
||||
const TicketSection: FC<Props> = ({ customerName }) => {
|
||||
const { id } = useParams()
|
||||
const [message, setMessage] = useState('')
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const addTicket = useAddTicket()
|
||||
const { data, refetch, isFetching, isPending: isLoadingTickets } = useGetTickets(id!)
|
||||
const singleUpload = useSingleUpload()
|
||||
const multiUpload = useMultiUpload()
|
||||
|
||||
const {
|
||||
isRecording,
|
||||
isPlaying,
|
||||
audioUrl,
|
||||
audioRef,
|
||||
togglePlayPause,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
progress,
|
||||
audioFile,
|
||||
resetRecorder,
|
||||
} = useVoiceRecorder()
|
||||
|
||||
const tickets = data?.data ?? []
|
||||
const isSubmitting =
|
||||
singleUpload.isPending || multiUpload.isPending || addTicket.isPending
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!message.trim() && !audioFile && files.length === 0) {
|
||||
toast('لطفاً پیام یا فایل ضمیمه وارد کنید', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
const params: AddTicketType = {
|
||||
attachments: [],
|
||||
content: message.trim(),
|
||||
}
|
||||
|
||||
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?.forEach((item) => {
|
||||
params.attachments.push({
|
||||
type: 'uploads_attach',
|
||||
url: item.url,
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
addTicket.mutate(
|
||||
{ orderId: id!, params },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast('پیام شما با موفقیت ارسال شد')
|
||||
refetch()
|
||||
setMessage('')
|
||||
setFiles([])
|
||||
resetRecorder()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast(extractErrorMessage(error), 'error')
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl bg-white">
|
||||
<div className="flex items-center justify-between border-b border-[#EBEDF5] px-6 py-4 md:px-8">
|
||||
<div>
|
||||
<h2 className="text-sm text-black">گفتگو با مشتری</h2>
|
||||
<p className="mt-1 text-xs text-[#8C90A3]">
|
||||
پیامها و فایلهای مرتبط با درخواست
|
||||
</p>
|
||||
</div>
|
||||
<RefreshButton onClick={() => refetch()} isLoading={isFetching} />
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="space-y-4 min-h-[80px]">
|
||||
{isLoadingTickets && tickets.length === 0 && (
|
||||
<p className="text-xs text-[#8C90A3]">در حال بارگذاری پیامها...</p>
|
||||
)}
|
||||
|
||||
{!isLoadingTickets && tickets.length === 0 && (
|
||||
<div className="rounded-xl bg-[#F5F7FC] px-4 py-6 text-center">
|
||||
<p className="text-sm text-[#8C90A3]">هنوز پیامی ثبت نشده است.</p>
|
||||
<p className="text-xs text-[#8C90A3] mt-1">
|
||||
اولین پیام را در فرم زیر ارسال کنید.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tickets.map((item) => {
|
||||
if (item.user) {
|
||||
return (
|
||||
<TicketMessage
|
||||
key={item.id}
|
||||
content={item.content}
|
||||
attachments={item.attachments}
|
||||
createdAt={item.createdAt}
|
||||
senderLabel="مشتری"
|
||||
senderName={customerName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.admin) {
|
||||
return (
|
||||
<TicketMessage
|
||||
key={item.id}
|
||||
content={item.content}
|
||||
attachments={item.attachments}
|
||||
createdAt={item.createdAt}
|
||||
isAdmin
|
||||
senderLabel="پشتیبان"
|
||||
senderName={`${item.admin.firstName} ${item.admin.lastName}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-[#EBEDF5]">
|
||||
<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-32 bg-white border border-border rounded-xl p-4 text-sm resize-none outline-none focus:border-[#0047FF] transition-colors"
|
||||
placeholder="متن پیام خود را بنویسید..."
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
className="absolute left-4 bottom-4 bg-[#FFF1D7] size-8 rounded-lg flex items-center justify-center hover:opacity-90 transition-opacity"
|
||||
aria-label={isRecording ? 'توقف ضبط' : 'ضبط صدا'}
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
onClick={togglePlayPause}
|
||||
className="w-8 h-8 rounded-full bg-black flex items-center justify-center shrink-0"
|
||||
aria-label={isPlaying ? 'توقف' : 'پخش'}
|
||||
>
|
||||
{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 onChange={setFiles} />
|
||||
</div>
|
||||
|
||||
<div className="flex mt-8 justify-end">
|
||||
<Button
|
||||
label="ارسال پیام"
|
||||
onClick={handleSubmit}
|
||||
className="w-[150px]"
|
||||
isLoading={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TicketSection
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { UserType } from "@/pages/order/types/Types";
|
||||
import type { ProductType } from "@/pages/product/types/Types";
|
||||
import type { BaseResponse } from "@/shared/types/Types";
|
||||
import type { AttachmentsType } from "@/pages/order/types/Types";
|
||||
|
||||
export type RequestItemAttributeType = {
|
||||
value: string;
|
||||
@@ -69,7 +70,7 @@ export type RequestDetailItemType = {
|
||||
attributes: RequestItemAttributeType[];
|
||||
request: string;
|
||||
description: string;
|
||||
attachments: string[] | unknown[];
|
||||
attachments: AttachmentsType[];
|
||||
};
|
||||
|
||||
export type RequestDetailType = {
|
||||
|
||||
Reference in New Issue
Block a user