ticket detail

This commit is contained in:
hamid zarghami
2026-02-24 15:40:50 +03:30
parent 5b938d1525
commit cb2a6daa29
9 changed files with 421 additions and 269 deletions
+130 -266
View File
@@ -1,294 +1,158 @@
import { type FC, Fragment, useState } from 'react'
import Input from '../../components/Input'
import Textarea from '@/components/Textarea'
import UploadBox from '../../components/UploadBox'
import Button from '../../components/Button'
import { CloseCircle, InfoCircle, Paperclip2, Send2 } from 'iconsax-react'
import { useNavigate, useParams } from 'react-router-dom'
import { type MessageType, type AttachmentType } from './types/TicketTypes'
import { useGetTicketById, useCreateOrReplyTicket } from './hooks/useTicketData'
import moment from 'moment-jalaali'
import { clx } from '../../helpers/utils'
import { fa } from '@/locale/fa'
import { Paths } from '@/config/Paths'
import { type FC, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import Input from "@/components/Input";
import { Paths } from "@/config/Paths";
import { clx } from "@/helpers/utils";
import { t } from "@/locale";
import { getTicketDisplayName } from "./types/TicketTypes";
import type { TicketDetailType } from "./types/TicketTypes";
import { useCreateOrReplyTicket, useGetTicketById } from "./hooks/useTicketData";
import { buildThreadItems, getStatusClass } from "./detailUtils";
import MessageBubble from "./MessageBubble";
import ReplyForm from "./ReplyForm";
import TicketInfoSidebar from "./TicketInfoSidebar";
import TicketDetailActions from "./TicketDetailActions";
const STATUS_CLOSED = "closed";
const TicketDetail: FC = () => {
const t = (key: string) => {
const keys = key.split('.');
let value: unknown = fa;
for (const k of keys) {
value = (value as Record<string, unknown>)?.[k];
}
return (value as string) || key;
}
const navigate = useNavigate()
const { id } = useParams<{ id: string }>()
const [content, setContent] = useState('')
const [files, setFiles] = useState<File[]>([])
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const [content, setContent] = useState("");
const [files, setFiles] = useState<File[]>([]);
const getTicket = useGetTicketById(id ?? '')
const addMessage = useCreateOrReplyTicket()
const getTicket = useGetTicketById(id ?? "");
const addMessage = useCreateOrReplyTicket();
const raw = getTicket.data?.data
const ticket = raw?.ticket ?? raw
const messages: MessageType[] = raw?.messages ?? raw?.ticket?.messages ?? []
const ticket = getTicket.data?.data;
const threadItems = useMemo(
() => (ticket ? buildThreadItems(ticket) : []),
[ticket]
);
const isClosed =
ticket?.status?.toLowerCase() === STATUS_CLOSED ||
ticket?.status?.toUpperCase() === "CLOSED";
const handleCloseTicket = () => navigate(Paths.tickets.list);
const handleSendReply = () => {
if (!id || !content.trim()) return
if (!id || !ticket || !content.trim()) return;
addMessage.mutate(
{
parentId: id,
subject: (ticket as { subject?: string })?.subject ?? '',
subject: ticket.subject,
content: content.trim(),
attachments: files.length ? files.map(() => ({})) : [],
},
{
onSuccess: () => {
setContent('')
setFiles([])
getTicket.refetch()
setContent("");
setFiles([]);
getTicket.refetch();
},
}
)
);
};
const handleScrollToReply = () => {
document.getElementById("content")?.scrollIntoView({ behavior: "smooth" });
document.getElementById("content")?.focus();
};
if (getTicket.isPending) {
return (
<div className="mt-4">
<div className="flex gap-1">{t("ticket.ticket_number")}</div>
<div className="mt-6">Loading...</div>
</div>
);
}
const hadleCloseTicket = () => {
navigate(Paths.tickets.list)
if (!ticket) {
return (
<div className="mt-4">
<div className="flex gap-1">{t("ticket.ticket_number")}</div>
<div className="mt-6 text-description">{t("ticket.info_ticket")}</div>
</div>
);
}
const handleSendAnswer = () => {
const messageEndRef = document.getElementById('content')
if (messageEndRef) {
messageEndRef.scrollIntoView({ behavior: 'smooth' })
messageEndRef.focus()
}
}
const extendedTicket = ticket as TicketDetailType & {
danakService?: { name?: string };
numericId?: string;
};
return (
<div className='mt-4'>
<div className='flex gap-1'>
<div>{t('ticket.ticket_number')}</div>
<div className="mt-4">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-description">{t("ticket.ticket_number")}:</span>
<span className="font-medium">{extendedTicket.numericId ?? ticket.id}</span>
<span
className={clx(
"text-xs px-3 py-1 rounded-xl",
getStatusClass(ticket.status)
)}
>
{t(`ticket.${ticket.status}`) || t(`ticket.${ticket.status?.toUpperCase()}`)}
</span>
</div>
{
getTicket.isPending ?
<div>Loading...</div>
:
<div className='flex xl:flex-row flex-col-reverse gap-6 xl:mt-8 mt-6'>
<div className='flex-1 bg-white py-8 xl:px-10 px-4 rounded-3xl'>
<div className="flex xl:flex-row flex-col-reverse gap-6 xl:mt-8 mt-6">
<div className="flex-1 bg-white py-8 xl:px-10 px-4 rounded-3xl">
<Input
label={t("ticket.subject")}
value={ticket.subject}
readOnly
/>
<div className="gap-6 rowTwoInput mt-5">
{extendedTicket.danakService && (
<Input
label={t('ticket.subject')}
value={(ticket as { subject?: string })?.subject}
label={t("ticket.select_your_service")}
value={extendedTicket.danakService.name}
readOnly
/>
<div className='gap-6 rowTwoInput mt-5'>
<Input
label={t('ticket.select_your_service')}
value={(ticket as { danakService?: { name?: string } })?.danakService?.name}
readOnly
/>
<Input
label={t('ticket.user_name')}
value={[(ticket as { user?: { firstName?: string; lastName?: string } })?.user?.firstName, (ticket as { user?: { lastName?: string } })?.user?.lastName].filter(Boolean).join(' ')}
readOnly
/>
</div>
{
messages.map((item: MessageType) => {
if (item?.author?.roles[0]?.name === 'user') {
return (
<div key={item.id} className='mt-6 xl:text-sm text-xs bg-[#F6F7FA] p-6 rounded-3xl rounded-tr-none xl:max-w-[70%] max-w-[90%]'>
<div className='leading-7'>
{item.content}
</div>
<div className='flex dltr end mt-6 text-xs text-description'>
{moment(item.createdAt).format('jYYYY-jMM-jDD HH:mm')}
</div>
<div className='flex gap-2 flex-wrap text-sm'>
{
item.attachments && item?.attachments?.map((attachment: AttachmentType, index: number) => {
return (
<div className='text-[#0047FF] mt-2 flex gap-1 items-center' key={attachment.id}>
<Paperclip2 size={20} color='#0047FF' />
<a href={attachment.attachmentUrl} target='_blank' rel='noreferrer'>
{t('attach') + ' ' + Number(index + 1)}
</a>
</div>
)
})
}
</div>
</div>
)
} else {
return (
<div className='w-full flex justify-end' key={item.id}>
<div className='mt-6 xl:text-sm text-xs bg-[#EBEDF5] p-6 rounded-3xl rounded-tl-none xl:max-w-[70%] max-w-[90%]'>
<div className='flex gap-1'>
<div className='font-bold'>
{t('ticket.expert')}
</div>
<div>{item.author?.firstName + ' ' + item?.author?.lastName}</div>
</div>
<div className='leading-7 mt-4'>
{item.content}
</div>
<div className='flex dltr end mt-6 text-xs text-description'>
{moment(item.createdAt).format('jYYYY-jMM-jDD HH:mm')}
</div>
</div>
<div className='flex gap-2 flex-wrap text-sm'>
{
item.attachments && item?.attachments?.map((attachment: AttachmentType, index: number) => {
return (
<div className='text-[#0047FF] mt-2 flex gap-1 items-center' key={attachment.id}>
<Paperclip2 size={20} color='#0047FF' />
<a href={attachment.attachmentUrl} target='_blank' rel='noreferrer'>
{t('attach') + ' ' + Number(index + 1)}
</a>
</div>
)
})
}
</div>
</div>
)
}
})
}
{
(ticket as { status?: string })?.status !== 'CLOSED' &&
<Fragment>
<div className='mt-9'>
<Textarea
id='content'
placeholder={t('ticket.your_description')}
value={content}
onChange={(e) => setContent(e.target.value)}
/>
</div>
<div className='mt-7'>
<UploadBox
label={t('ticket.attachment')}
onChange={setFiles}
isMultiple
/>
</div>
<div className='flex justify-end mt-6'>
<Button
className='xl:max-w-[100px]'
onClick={handleSendReply}
isLoading={addMessage.isPending}
>
<div className='flex gap-2 items-center'>
<Send2 size={20} color='black' />
<div>
{t('ticket.send')}
</div>
</div>
</Button>
</div>
</Fragment>
}
<div className='h-8'></div>
</div>
<div className={`h-fit lg:sticky left-0 lg:top-0 xl:w-[300px] w-full`}>
{
(ticket as { status?: string })?.status !== 'CLOSED' &&
<div className='bg-white xl:p-6 p-4 rounded-3xl flex gap-4'>
<Button
label={t('ticket.send_answer')}
className='text-xs xl:h-10 h-8'
onClick={handleSendAnswer}
/>
<Button
className='bg-[#D52903] xl:h-10 h-8'
onClick={hadleCloseTicket}
>
<div className='flex gap-2 text-xs items-center'>
<CloseCircle
color='white'
size={20}
/>
<div className='text-white'>
{t('ticket.close_ticket')}
</div>
</div>
</Button>
</div>
}
<div className={clx(
'bg-white p-6 rounded-3xl mt-8',
(ticket as { status?: string })?.status === 'CLOSED' && 'mt-0'
)}>
<div className='flex justify-between'>
<div className='text-sm'>
{t('ticket.info_ticket')}
</div>
<div className={clx(
'h-7 px-3 rounded-xl flex items-center text-xs bg-green-100 text-green-400',
(ticket as { status?: string })?.status === 'PENDING' && 'bg-orange-100 text-orange-700',
(ticket as { status?: string })?.status === 'CLOSED' && 'bg-red-100 text-red-400'
)}>
{t(`ticket.${(ticket as { status?: string })?.status}`)}
</div>
</div>
<div className='mt-14 text-xs w-full'>
<div className='flex justify-between text-description'>
<div>{t('ticket.ticket_number')}</div>
<div>
{(ticket as { numericId?: string })?.numericId}
</div>
</div>
<div className='flex mt-6 justify-between text-description'>
<div>{t('ticket.asignto')}</div>
<div>
{(ticket as { assignedTo?: { firstName?: string; lastName?: string } })?.assignedTo
? [(ticket as { assignedTo?: { firstName?: string } })?.assignedTo?.firstName, (ticket as { assignedTo?: { lastName?: string } })?.assignedTo?.lastName].filter(Boolean).join(' ')
: ''}
</div>
</div>
<div className='flex mt-6 justify-between text-description'>
<div>{t('date')}</div>
<div className='dltr'>
{moment((ticket as { createdAt?: string })?.createdAt).format('jYYYY/jMM/jDD HH:mm')}
</div>
</div>
<div className='flex mt-6 justify-between text-description'>
<div>{t('ticket.category')}</div>
<div>
{(ticket as { category?: { title?: string } })?.category?.title}
</div>
</div>
<div className='flex mt-6 justify-between text-description'>
<div>{t('ticket.priority')}</div>
<div>
{(ticket as { priority?: string })?.priority === 'low' ? t('ticket.low') : (ticket as { priority?: string })?.priority === 'medium' ? t('ticket.medium') : t('ticket.high')}
</div>
</div>
</div>
<div className='mt-10 border-t text-xs pt-5 border-border items-center flex gap-2'>
<InfoCircle size={20} color='black' />
<div>
{t('ticket.update_sms')}
</div>
</div>
</div>
</div>
)}
<Input
label={t("ticket.user_name")}
value={getTicketDisplayName(ticket.user)}
readOnly
/>
</div>
}
</div>
)
}
export default TicketDetail
<div className={threadItems.length ? "space-y-0" : ""}>
{threadItems.map((item) => (
<div
key={item.id}
className={!item.isFromUser ? "w-full flex justify-end" : ""}
>
<MessageBubble item={item} />
</div>
))}
</div>
{!isClosed && (
<ReplyForm
content={content}
onContentChange={setContent}
onFilesChange={setFiles}
onSubmit={handleSendReply}
isPending={addMessage.isPending}
/>
)}
<div className="h-8" />
</div>
<aside className="h-fit lg:sticky lg:top-0 xl:w-[300px] w-full">
{!isClosed && (
<TicketDetailActions
onScrollToReply={handleScrollToReply}
onCloseTicket={handleCloseTicket}
/>
)}
<TicketInfoSidebar ticket={ticket} />
</aside>
</div>
</div>
);
};
export default TicketDetail;