diff --git a/src/pages/requests/Detail.tsx b/src/pages/requests/Detail.tsx
index e0a38f6..ff88bae 100644
--- a/src/pages/requests/Detail.tsx
+++ b/src/pages/requests/Detail.tsx
@@ -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 (
+
+ در حال بارگذاری...
+
+ )
+ }
+
+ if (isError || !request) {
+ return (
+
+ درخواست یافت نشد
+
+
+ )
+ }
return (
-
- {/* Header Section */}
-
+
+
+
refetch()} isLoading={isFetching} />
-
+
-
- درخواست #{data?.data?.requestNumber}
-
-
- {/* Request Information Section */}
-
-
-
اطلاعات درخواست
-
-
- {data?.data?.items?.map((item) => (
-
- ))}
+
+
+
+ درخواست #{request.requestNumber}
+
+
+
+
+
+ تاریخ ثبت:{' '}
+ {moment(request.createdAt).format('jYYYY/jMM/jDD HH:mm')}
+
+
+ {items.length > 0 && (
+ <>
+
|
+
{items.length} قلم
+ >
+ )}
+ {customerName && (
+ <>
+
|
+
+ >
+ )}
+ {request.user?.phone && (
+ <>
+
|
+
{request.user.phone}
+ >
+ )}
+
-
+
+ اطلاعات درخواست
+
+ {items.length === 0 ? (
+ قلمی برای این درخواست ثبت نشده است.
+ ) : (
+
+ {items.map((item, index) => (
+
+ ))}
+
+ )}
+
+
+
)
}
diff --git a/src/pages/requests/components/RequestDetailItem.tsx b/src/pages/requests/components/RequestDetailItem.tsx
new file mode 100644
index 0000000..44dd161
--- /dev/null
+++ b/src/pages/requests/components/RequestDetailItem.tsx
@@ -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
= ({ 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[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 (
+ 0 ? 'pt-8 mt-8 border-t border-[#EBEDF5]' : ''}>
+
+
+

{
+ e.currentTarget.src = placeholderProductImage
+ }}
+ />
+
+
+
+
+
+ عنوان:
+ {item.product?.title}
+
+ {total > 1 && (
+
+ قلم {index + 1} از {total}
+
+ )}
+
+
+
+
شرح درخواست
+
+ {item.description || '—'}
+
+
+
+ {attributeRows.length > 0 && (
+
+
ویژگیها
+
+ {attributeRows.map((field) => (
+
+
+ {field.name}:
+
+
+ {field.value || '—'}
+
+
+ ))}
+
+
+ )}
+
+ {fileAttachments.length > 0 && (
+
+
+
+ {fileAttachments.map((att) =>
+ isImageUrl(att.url) ? (
+
+
+
+ ) : (
+
+ )
+ )}
+
+
+ )}
+
+ {voiceAttachments.length > 0 && (
+
+ {voiceAttachments.map((att) => (
+
+ ))}
+
+ )}
+
+
+
+ )
+}
+
+export default RequestDetailItem
diff --git a/src/pages/requests/components/RequestItem.tsx b/src/pages/requests/components/RequestItem.tsx
deleted file mode 100644
index c388860..0000000
--- a/src/pages/requests/components/RequestItem.tsx
+++ /dev/null
@@ -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 = ({ item }) => {
- const { data: fields } = useGetEntityField(String(item.product?.id ?? ''))
- const attributes = item.attributes ?? []
-
- const getAttributeFieldId = (attribute: NonNullable[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 (
-
-
- {/* Product Image */}
-
-

{
- e.currentTarget.src = placeholderProductImage
- }}
- />
-
-
- {/* Request Details */}
-
-
-
عنوان:
-
{item.product?.title}
-
-
-
-
- {/* Description */}
- {item.description && (
-
-
شرح درخواست:
-
- {item.description}
-
-
- )}
-
- {/* Attributes */}
- {attributeRows.length ? (
- <>
-
ویژگی ها
- {
- attributeRows.map((field) => (
-
-
{field.name}:
-
{field.value || '-'}
-
- ))
- }
- >
- ) : null}
-
- {/* Attachments */}
- {item.attachments?.length ? (
-
-
ضمایم:
-
- {item.attachments
- ?.filter((url): url is string => typeof url === 'string')
- .map((url, index) => (
-
- ))}
-
-
- ) : null}
-
- )
-}
-
-export default RequestItem
diff --git a/src/pages/requests/components/TicketMessage.tsx b/src/pages/requests/components/TicketMessage.tsx
new file mode 100644
index 0000000..d8a386e
--- /dev/null
+++ b/src/pages/requests/components/TicketMessage.tsx
@@ -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 = ({
+ 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 (
+
+
+
+ {senderLabel}:
+ {senderName ? {senderName} : null}
+
+
+ {content && (
+
{content}
+ )}
+
+ {fileAttachments.length > 0 && (
+
+ {fileAttachments.map((attach) => (
+
+ ))}
+
+ )}
+
+ {voiceAttachments.length > 0 && (
+
+ {voiceAttachments.map((voice) => (
+
+ ))}
+
+ )}
+
+ {createdAt && (
+
+ {moment(createdAt).format('jYYYY/jMM/jDD HH:mm')}
+
+ )}
+
+
+ )
+}
+
+export default TicketMessage
diff --git a/src/pages/requests/components/TicketSection.tsx b/src/pages/requests/components/TicketSection.tsx
new file mode 100644
index 0000000..8d9e964
--- /dev/null
+++ b/src/pages/requests/components/TicketSection.tsx
@@ -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 = ({ customerName }) => {
+ const { id } = useParams()
+ const [message, setMessage] = useState('')
+ const [files, setFiles] = useState([])
+ 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 (
+
+
+
+
گفتگو با مشتری
+
+ پیامها و فایلهای مرتبط با درخواست
+
+
+
refetch()} isLoading={isFetching} />
+
+
+
+
+ {isLoadingTickets && tickets.length === 0 && (
+
در حال بارگذاری پیامها...
+ )}
+
+ {!isLoadingTickets && tickets.length === 0 && (
+
+
هنوز پیامی ثبت نشده است.
+
+ اولین پیام را در فرم زیر ارسال کنید.
+
+
+ )}
+
+ {tickets.map((item) => {
+ if (item.user) {
+ return (
+
+ )
+ }
+
+ if (item.admin) {
+ return (
+
+ )
+ }
+
+ return null
+ })}
+
+
+
+
پیام شما
+
+
+
+
+ {audioUrl && (
+
+
+
+
+ {Array.from({ length: 60 }).map((_, i) => {
+ const passed = i / 60 <= progress
+ return (
+
+ )
+ })}
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default TicketSection
diff --git a/src/pages/requests/types/Types.ts b/src/pages/requests/types/Types.ts
index 5aa7762..a9df77d 100644
--- a/src/pages/requests/types/Types.ts
+++ b/src/pages/requests/types/Types.ts
@@ -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 = {