@@ -1,53 +1,119 @@
|
|||||||
import { type FC } from 'react'
|
import { type FC } from 'react'
|
||||||
import { useGetRequestDetail } from './hooks/useRequestData'
|
|
||||||
import { Link, useParams } from 'react-router-dom'
|
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 { Paths } from '@/config/Paths'
|
||||||
import BackButton from '@/components/BackButton'
|
import BackButton from '@/components/BackButton'
|
||||||
import Button from '@/components/Button'
|
import Button from '@/components/Button'
|
||||||
import RefreshButton from '@/components/RefreshButton'
|
import RefreshButton from '@/components/RefreshButton'
|
||||||
import { TickSquare } from 'iconsax-react'
|
|
||||||
import TicketSection from '../order/components/TicketSection'
|
|
||||||
|
|
||||||
const RequestDetail: FC = () => {
|
const RequestDetail: FC = () => {
|
||||||
const { id } = useParams()
|
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 (
|
return (
|
||||||
<div className="w-full min-h-screen bg-[#eceef6] p-6">
|
<div className="pb-8">
|
||||||
{/* Header Section */}
|
<div className="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<BackButton to={Paths.requests.list} />
|
<BackButton to={Paths.requests.list} />
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<RefreshButton onClick={() => refetch()} isLoading={isFetching} />
|
<RefreshButton onClick={() => refetch()} isLoading={isFetching} />
|
||||||
<Link to={Paths.perfomaInvoice.create + `?requestId=${id}`}>
|
<Link to={Paths.perfomaInvoice.create + `?requestId=${id}`}>
|
||||||
<Button className="w-fit px-5">
|
<Button className="w-fit px-5">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<TickSquare size={20} color="black" />
|
<TickSquare size={20} color="black" />
|
||||||
<div>ثبت پیش فاکتور</div>
|
<span>ثبت پیش فاکتور</span>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-[#8C90A3] mb-6">
|
<div className="flex flex-wrap items-start justify-between gap-4 mb-6">
|
||||||
درخواست #{data?.data?.requestNumber}
|
<div>
|
||||||
</div>
|
<h1 className="text-lg font-light text-black">
|
||||||
|
درخواست #{request.requestNumber}
|
||||||
{/* Request Information Section */}
|
</h1>
|
||||||
<div className="bg-white rounded-2xl p-6 mb-6">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 mt-2 text-xs text-[#8C90A3]">
|
||||||
<div className='flex justify-between items-center'>
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-lg font-light mb-6">اطلاعات درخواست</h2>
|
<Calendar size={14} />
|
||||||
</div>
|
<span>
|
||||||
<div className='flex flex-col gap-10'>
|
تاریخ ثبت:{' '}
|
||||||
{data?.data?.items?.map((item) => (
|
{moment(request.createdAt).format('jYYYY/jMM/jDD HH:mm')}
|
||||||
<RequestItem key={item.id} item={item} />
|
</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>
|
||||||
</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>
|
</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 { UserType } from "@/pages/order/types/Types";
|
||||||
import type { ProductType } from "@/pages/product/types/Types";
|
import type { ProductType } from "@/pages/product/types/Types";
|
||||||
import type { BaseResponse } from "@/shared/types/Types";
|
import type { BaseResponse } from "@/shared/types/Types";
|
||||||
|
import type { AttachmentsType } from "@/pages/order/types/Types";
|
||||||
|
|
||||||
export type RequestItemAttributeType = {
|
export type RequestItemAttributeType = {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -69,7 +70,7 @@ export type RequestDetailItemType = {
|
|||||||
attributes: RequestItemAttributeType[];
|
attributes: RequestItemAttributeType[];
|
||||||
request: string;
|
request: string;
|
||||||
description: string;
|
description: string;
|
||||||
attachments: string[] | unknown[];
|
attachments: AttachmentsType[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RequestDetailType = {
|
export type RequestDetailType = {
|
||||||
|
|||||||
Reference in New Issue
Block a user