order detail + types + components
This commit is contained in:
@@ -0,0 +1,74 @@
|
|||||||
|
import { Pause, Play } from "iconsax-react"
|
||||||
|
import { useEffect, useRef, useState, type FC } from "react"
|
||||||
|
|
||||||
|
type VoicePlayerProps = {
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const VoicePlayer: FC<VoicePlayerProps> = ({ url }) => {
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
|
const [progress, setProgress] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (!audio) return
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
if (!audio.duration) return
|
||||||
|
setProgress(audio.currentTime / audio.duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.addEventListener('timeupdate', update)
|
||||||
|
audio.addEventListener('ended', () => {
|
||||||
|
setIsPlaying(false)
|
||||||
|
setProgress(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener('timeupdate', update)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
if (!audioRef.current) return
|
||||||
|
if (isPlaying) audioRef.current.pause()
|
||||||
|
else audioRef.current.play()
|
||||||
|
setIsPlaying(!isPlaying)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-12 bg-white border border-border rounded-xl flex items-center px-3 gap-3 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
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={url} className="hidden" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VoicePlayer
|
||||||
@@ -27,7 +27,8 @@ export const Paths = {
|
|||||||
values: '/form-builder/values/'
|
values: '/form-builder/values/'
|
||||||
},
|
},
|
||||||
order: {
|
order: {
|
||||||
list: '/order/list'
|
list: '/order/list',
|
||||||
|
details: '/order/detail/'
|
||||||
},
|
},
|
||||||
features: {
|
features: {
|
||||||
list: '/features/list',
|
list: '/features/list',
|
||||||
|
|||||||
+22
-1
@@ -56,7 +56,7 @@ export interface IGeneralError {
|
|||||||
|
|
||||||
export const extractErrorMessage = (
|
export const extractErrorMessage = (
|
||||||
error: Error | unknown,
|
error: Error | unknown,
|
||||||
fallbackMessage: string = "خطایی رخ داده است",
|
fallbackMessage: string = "خطایی رخ داده است"
|
||||||
): string => {
|
): string => {
|
||||||
try {
|
try {
|
||||||
const axiosError = error as { response?: { data: IGeneralError } };
|
const axiosError = error as { response?: { data: IGeneralError } };
|
||||||
@@ -65,3 +65,24 @@ export const extractErrorMessage = (
|
|||||||
return fallbackMessage;
|
return fallbackMessage;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getFileNameAndExtensionFromUrl = (
|
||||||
|
url: string
|
||||||
|
): { fileName: string; extension: string } => {
|
||||||
|
try {
|
||||||
|
const cleanUrl = url.split("?")[0].split("#")[0];
|
||||||
|
const lastPart = cleanUrl.substring(cleanUrl.lastIndexOf("/") + 1);
|
||||||
|
|
||||||
|
if (!lastPart || !lastPart.includes(".")) {
|
||||||
|
return { fileName: "", extension: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastDotIndex = lastPart.lastIndexOf(".");
|
||||||
|
const fileName = lastPart.substring(0, lastDotIndex);
|
||||||
|
const extension = lastPart.substring(lastDotIndex + 1);
|
||||||
|
|
||||||
|
return { fileName, extension };
|
||||||
|
} catch {
|
||||||
|
return { fileName: "", extension: "" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
type UseVoiceRecorderOptions = {
|
||||||
|
onRecordingComplete?: (audioBlob: Blob) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useVoiceRecorder = (options?: UseVoiceRecorderOptions) => {
|
||||||
|
const { onRecordingComplete } = options || {};
|
||||||
|
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||||
|
const [audioFile, setAudioFile] = useState<File | null>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
|
const animationFrameRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const startRecording = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
const mediaRecorder = new MediaRecorder(stream);
|
||||||
|
|
||||||
|
mediaRecorderRef.current = mediaRecorder;
|
||||||
|
chunksRef.current = [];
|
||||||
|
|
||||||
|
mediaRecorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size > 0) {
|
||||||
|
chunksRef.current.push(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.onstop = () => {
|
||||||
|
const audioBlob = new Blob(chunksRef.current, { type: "audio/webm" });
|
||||||
|
const file = new File([audioBlob], "voice-message.webm", {
|
||||||
|
type: "audio/webm",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
|
||||||
|
setAudioUrl(url);
|
||||||
|
setAudioFile(file);
|
||||||
|
onRecordingComplete?.(audioBlob);
|
||||||
|
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaRecorder.start();
|
||||||
|
setIsRecording(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Microphone access error:", err);
|
||||||
|
}
|
||||||
|
}, [onRecordingComplete]);
|
||||||
|
|
||||||
|
const stopRecording = useCallback(() => {
|
||||||
|
if (mediaRecorderRef.current && isRecording) {
|
||||||
|
mediaRecorderRef.current.stop();
|
||||||
|
setIsRecording(false);
|
||||||
|
}
|
||||||
|
}, [isRecording]);
|
||||||
|
|
||||||
|
const resetRecorder = useCallback(() => {
|
||||||
|
if (mediaRecorderRef.current && isRecording) {
|
||||||
|
mediaRecorderRef.current.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current.currentTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioUrl) {
|
||||||
|
URL.revokeObjectURL(audioUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
chunksRef.current = [];
|
||||||
|
setIsRecording(false);
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
setDuration(0);
|
||||||
|
setAudioUrl(null);
|
||||||
|
setAudioFile(null);
|
||||||
|
}, [audioUrl, isRecording]);
|
||||||
|
|
||||||
|
const togglePlayPause = useCallback(() => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
} else {
|
||||||
|
audioRef.current.play();
|
||||||
|
}
|
||||||
|
setIsPlaying((prev) => !prev);
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
const updateProgress = useCallback(() => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
|
||||||
|
setCurrentTime(audioRef.current.currentTime);
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
animationFrameRef.current = requestAnimationFrame(updateProgress);
|
||||||
|
}
|
||||||
|
}, [isPlaying]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
|
||||||
|
const audio = audioRef.current;
|
||||||
|
|
||||||
|
const onLoaded = () => setDuration(audio.duration);
|
||||||
|
const onEnded = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.addEventListener("loadedmetadata", onLoaded);
|
||||||
|
audio.addEventListener("ended", onEnded);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener("loadedmetadata", onLoaded);
|
||||||
|
audio.removeEventListener("ended", onEnded);
|
||||||
|
};
|
||||||
|
}, [audioUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPlaying) updateProgress();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isPlaying, updateProgress]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// state
|
||||||
|
isRecording,
|
||||||
|
isPlaying,
|
||||||
|
audioUrl,
|
||||||
|
audioFile,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
|
||||||
|
// refs
|
||||||
|
audioRef,
|
||||||
|
|
||||||
|
// actions
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
togglePlayPause,
|
||||||
|
resetRecorder,
|
||||||
|
|
||||||
|
// helpers
|
||||||
|
progress: duration ? currentTime / duration : 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -4,6 +4,8 @@ import Table from '@/components/Table'
|
|||||||
import { Edit2, Eye, Receipt21, Setting2 } from 'iconsax-react'
|
import { Edit2, Eye, Receipt21, Setting2 } from 'iconsax-react'
|
||||||
import { useGetOrders } from './hooks/useOrderData'
|
import { useGetOrders } from './hooks/useOrderData'
|
||||||
import moment from 'moment-jalaali'
|
import moment from 'moment-jalaali'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { Paths } from '@/config/Paths'
|
||||||
|
|
||||||
const OrdersList: FC = () => {
|
const OrdersList: FC = () => {
|
||||||
|
|
||||||
@@ -94,10 +96,14 @@ const OrdersList: FC = () => {
|
|||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
title: '',
|
title: '',
|
||||||
render: () => {
|
render: (item) => {
|
||||||
return (
|
return (
|
||||||
<div className='flex gap-2 items-center'>
|
<div className='flex gap-2 items-center'>
|
||||||
<Eye size={20} color='#8C90A3' />
|
<Link
|
||||||
|
to={Paths.order.details + item.id}
|
||||||
|
>
|
||||||
|
<Eye size={20} color='#8C90A3' />
|
||||||
|
</Link>
|
||||||
<Edit2 size={20} color='#0037FF' />
|
<Edit2 size={20} color='#0037FF' />
|
||||||
<Receipt21 size={20} color='#FF8000' />
|
<Receipt21 size={20} color='#FF8000' />
|
||||||
<Setting2 size={20} color='#00B89F' />
|
<Setting2 size={20} color='#00B89F' />
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { type FC } from 'react'
|
||||||
|
import { Receipt1 } from 'iconsax-react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { useGetOrderDetails } from './hooks/useOrderData'
|
||||||
|
import TicketSection from './components/TicketSection'
|
||||||
|
|
||||||
|
const OrderDetail: FC = () => {
|
||||||
|
|
||||||
|
const { id } = useParams()
|
||||||
|
const { data } = useGetOrderDetails(id!)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full min-h-screen bg-[#eceef6] p-6">
|
||||||
|
{/* Header Section */}
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div className="text-sm text-[#8C90A3]">
|
||||||
|
سفارش #{data?.data?.orderNumber}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Order 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>
|
||||||
|
|
||||||
|
<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'>
|
||||||
|
{
|
||||||
|
data?.data?.items?.map((item) => {
|
||||||
|
return (
|
||||||
|
<div key={item.product.id}>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
{/* Product Image */}
|
||||||
|
<div className='size-[86px] rounded-lg overflow-hidden'>
|
||||||
|
<img src={item.product?.images?.[0]} className='size-full' />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Order Details */}
|
||||||
|
<div className="flex-1 flex gap-20 pr-10">
|
||||||
|
<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 className='flex items-center gap-2'>
|
||||||
|
<div className="text-xs text-desc">نام طرح:</div>
|
||||||
|
<div className="text-sm font-medium text-black">{'orderData.designer'}</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<div className="text-xs text-desc">تعداد:</div>
|
||||||
|
<div className="text-sm font-medium text-black">{item.quantity}</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<div className="text-xs text-desc">تخمین زمان:</div>
|
||||||
|
<div className="text-sm font-medium text-black">{data?.data?.estimatedDays}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Order 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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Section - White Card */}
|
||||||
|
<TicketSection />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OrderDetail
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import Button from '@/components/Button'
|
||||||
|
import Input from '@/components/Input'
|
||||||
|
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 '../types/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({ orderId: id!, params: 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">
|
||||||
|
{/* Designer Name */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<Input
|
||||||
|
label="نام طراح"
|
||||||
|
placeholder="عباس حسینی"
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* attachments (non-voice) */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* voice messages */}
|
||||||
|
{item.attachments
|
||||||
|
?.filter((a) => a.type === 'voice')
|
||||||
|
.map((voice) => (
|
||||||
|
<VoicePlayer key={voice.url} url={voice.url} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
else 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>
|
||||||
|
|
||||||
|
{/* attachments (non-voice) */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* voice messages */}
|
||||||
|
{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,5 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import * as api from "../service/OrderService";
|
import * as api from "../service/OrderService";
|
||||||
|
import type { AddTicketType } from "../types/Types";
|
||||||
|
|
||||||
export const useGetOrders = () => {
|
export const useGetOrders = () => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -7,3 +8,31 @@ export const useGetOrders = () => {
|
|||||||
queryFn: api.getOrders,
|
queryFn: api.getOrders,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useGetOrderDetails = (id: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["order", id],
|
||||||
|
queryFn: () => api.getOrderDetail(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: ({
|
||||||
|
orderId,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
orderId: string;
|
||||||
|
params: AddTicketType;
|
||||||
|
}) => api.addTicket(orderId, params),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,31 @@
|
|||||||
import axios from "@/config/axios";
|
import axios from "@/config/axios";
|
||||||
import type { OrderResponseType } from "../types/Types";
|
import type {
|
||||||
|
AddTicketType,
|
||||||
|
OrderDetailResponseType,
|
||||||
|
OrderResponseType,
|
||||||
|
TicketsResponseType,
|
||||||
|
} from "../types/Types";
|
||||||
|
|
||||||
export const getOrders = async () => {
|
export const getOrders = async () => {
|
||||||
const { data } = await axios.get<OrderResponseType>(`/admin/orders`);
|
const { data } = await axios.get<OrderResponseType>(`/admin/orders`);
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getOrderDetail = async (id: string) => {
|
||||||
|
const { data } = await axios.get<OrderDetailResponseType>(
|
||||||
|
`/admin/orders/${id}`
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTickets = async (orderId: string) => {
|
||||||
|
const { data } = await axios.get<TicketsResponseType>(
|
||||||
|
`/admin/ticket/order/${orderId}`
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addTicket = async (orderId: string, params: AddTicketType) => {
|
||||||
|
const { data } = await axios.post(`/admin/ticket/order/${orderId}`, params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { BaseResponse } from "@/shared/types/Types";
|
import type { BaseResponse } from "@/shared/types/Types";
|
||||||
import type { OrderStatusEnum } from "../enum/OrderEnum";
|
import type { OrderStatusEnum } from "../enum/OrderEnum";
|
||||||
import type { RowDataType } from "@/components/types/TableTypes";
|
import type { RowDataType } from "@/components/types/TableTypes";
|
||||||
|
import type { ProductType } from "@/pages/product/types/Types";
|
||||||
|
|
||||||
export interface OrderType extends RowDataType {
|
export interface OrderType extends RowDataType {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -75,3 +76,58 @@ export type UserType = {
|
|||||||
maxCredit: number;
|
maxCredit: number;
|
||||||
phone: string;
|
phone: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AttachmentsType = {
|
||||||
|
url: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AddTicketType = {
|
||||||
|
content: string;
|
||||||
|
attachments: AttachmentsType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TicketType = {
|
||||||
|
admin?: {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
phone: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
attachments: AttachmentsType[];
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
id: number;
|
||||||
|
user?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface MyOrderType extends RowDataType {
|
||||||
|
balance: number;
|
||||||
|
createdAt: string;
|
||||||
|
discount: number;
|
||||||
|
enableTax: boolean;
|
||||||
|
estimatedDays?: number;
|
||||||
|
orderNumber: number;
|
||||||
|
paidAmount: number;
|
||||||
|
paymentMethod: string;
|
||||||
|
status: string;
|
||||||
|
subTotal: number;
|
||||||
|
taxAmount: number;
|
||||||
|
total: number;
|
||||||
|
items: {
|
||||||
|
adminDescription?: string;
|
||||||
|
attachments: {
|
||||||
|
type: string;
|
||||||
|
url: string;
|
||||||
|
}[];
|
||||||
|
description: string;
|
||||||
|
product: ProductType;
|
||||||
|
quantity: number;
|
||||||
|
subTotal: number;
|
||||||
|
total: number;
|
||||||
|
unitPrice: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TicketsResponseType = BaseResponse<TicketType[]>;
|
||||||
|
export type OrderDetailResponseType = BaseResponse<MyOrderType>;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import FormBuilderList from '@/pages/formBuilder/List'
|
|||||||
import FormBuilderCreate from '@/pages/formBuilder/Create'
|
import FormBuilderCreate from '@/pages/formBuilder/Create'
|
||||||
import FormBuilderUpdate from '@/pages/formBuilder/Update'
|
import FormBuilderUpdate from '@/pages/formBuilder/Update'
|
||||||
import FormBuilderValues from '@/pages/formBuilder/AttributeValues'
|
import FormBuilderValues from '@/pages/formBuilder/AttributeValues'
|
||||||
|
import OrderDetail from '@/pages/order/OrderDetail'
|
||||||
|
|
||||||
const MainRouter: FC = () => {
|
const MainRouter: FC = () => {
|
||||||
return (
|
return (
|
||||||
@@ -71,6 +72,8 @@ const MainRouter: FC = () => {
|
|||||||
<Route path={Paths.formBuilder.values + ':id'} element={<FormBuilderValues />} />
|
<Route path={Paths.formBuilder.values + ':id'} element={<FormBuilderValues />} />
|
||||||
|
|
||||||
<Route path={Paths.order.list} element={<OrdersList />} />
|
<Route path={Paths.order.list} element={<OrdersList />} />
|
||||||
|
<Route path={Paths.order.details + ':id'} element={<OrderDetail />} />
|
||||||
|
|
||||||
<Route path={Paths.features.list} element={<FeaturesList />} />
|
<Route path={Paths.features.list} element={<FeaturesList />} />
|
||||||
<Route path={Paths.features.create} element={<CreateFeature />} />
|
<Route path={Paths.features.create} element={<CreateFeature />} />
|
||||||
<Route path={Paths.service.print} element={<PrintService />} />
|
<Route path={Paths.service.print} element={<PrintService />} />
|
||||||
|
|||||||
Reference in New Issue
Block a user