up
deploy to danak / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-07-09 18:00:08 +03:30
parent a1995fabb8
commit 7fd8b4c7e5
6 changed files with 577 additions and 145 deletions
+94 -28
View File
@@ -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
+2 -1
View File
@@ -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 = {