ticket detail
This commit is contained in:
@@ -66,6 +66,7 @@ export const fa = {
|
||||
status: "وضعیت",
|
||||
priority: "اولویت",
|
||||
detail: "جزئیات",
|
||||
open: "باز",
|
||||
PENDING: "در حال بررسی",
|
||||
ANSWERED: "پاسخ داده شده",
|
||||
CLOSED: "بسته شده",
|
||||
@@ -75,6 +76,7 @@ export const fa = {
|
||||
subject: "موضوع",
|
||||
select_your_service: "انتخاب سرویس شما",
|
||||
user_name: "نام کاربر",
|
||||
customer: "کاربر",
|
||||
expert: "کارشناس",
|
||||
answer: "پاسخ",
|
||||
your_description: "توضیحات شما",
|
||||
|
||||
+126
-262
@@ -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>
|
||||
{
|
||||
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'>
|
||||
<Input
|
||||
label={t('ticket.subject')}
|
||||
value={(ticket as { subject?: string })?.subject}
|
||||
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="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)
|
||||
)}
|
||||
>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<Send2 size={20} color='black' />
|
||||
<div>
|
||||
{t('ticket.send')}
|
||||
{t(`ticket.${ticket.status}`) || t(`ticket.${ticket.status?.toUpperCase()}`)}
|
||||
</span>
|
||||
</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}
|
||||
<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
|
||||
/>
|
||||
<Button
|
||||
className='bg-[#D52903] xl:h-10 h-8'
|
||||
onClick={hadleCloseTicket}
|
||||
<div className="gap-6 rowTwoInput mt-5">
|
||||
{extendedTicket.danakService && (
|
||||
<Input
|
||||
label={t("ticket.select_your_service")}
|
||||
value={extendedTicket.danakService.name}
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
label={t("ticket.user_name")}
|
||||
value={getTicketDisplayName(ticket.user)}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={threadItems.length ? "space-y-0" : ""}>
|
||||
{threadItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={!item.isFromUser ? "w-full flex justify-end" : ""}
|
||||
>
|
||||
<div className='flex gap-2 text-xs items-center'>
|
||||
<CloseCircle
|
||||
color='white'
|
||||
size={20}
|
||||
<MessageBubble item={item} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!isClosed && (
|
||||
<ReplyForm
|
||||
content={content}
|
||||
onContentChange={setContent}
|
||||
onFilesChange={setFiles}
|
||||
onSubmit={handleSendReply}
|
||||
isPending={addMessage.isPending}
|
||||
/>
|
||||
<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 className="h-8" />
|
||||
</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')}
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
</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>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TicketDetail
|
||||
export default TicketDetail;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { type FC } from "react";
|
||||
import { Paperclip2 } from "iconsax-react";
|
||||
import { clx } from "@/helpers/utils";
|
||||
import { t } from "@/locale";
|
||||
import type { TicketThreadItemType } from "./types/TicketTypes";
|
||||
import { formatDate } from "./detailUtils";
|
||||
|
||||
export type MessageBubbleProps = { item: TicketThreadItemType };
|
||||
|
||||
const MessageBubble: FC<MessageBubbleProps> = ({ item }) => {
|
||||
const isUser = item.isFromUser;
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"mt-6 xl:text-sm text-xs p-6 rounded-3xl xl:max-w-[70%] max-w-[90%]",
|
||||
isUser
|
||||
? "bg-[#F6F7FA] rounded-tr-none"
|
||||
: "bg-[#EBEDF5] rounded-tl-none"
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-1 mb-4">
|
||||
<span className="font-bold">
|
||||
{isUser ? t("ticket.customer") : t("ticket.expert")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="leading-7">{item.content}</div>
|
||||
<div className="flex dltr end mt-6 text-xs text-description">
|
||||
{formatDate(item.createdAt)}
|
||||
</div>
|
||||
{item.attachments && item.attachments.length > 0 && (
|
||||
<div className="flex gap-2 flex-wrap text-sm mt-2">
|
||||
{item.attachments.map((att, index) => (
|
||||
<a
|
||||
key={att.id}
|
||||
href={att.attachmentUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[#0047FF] flex gap-1 items-center"
|
||||
>
|
||||
<Paperclip2 size={20} color="#0047FF" />
|
||||
{t("attach")} {index + 1}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageBubble;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { type FC, Fragment } from "react";
|
||||
import { Send2 } from "iconsax-react";
|
||||
import Button from "@/components/Button";
|
||||
import Textarea from "@/components/Textarea";
|
||||
import UploadBox from "@/components/UploadBox";
|
||||
import { t } from "@/locale";
|
||||
|
||||
export type ReplyFormProps = {
|
||||
content: string;
|
||||
onContentChange: (v: string) => void;
|
||||
onFilesChange: (f: File[]) => void;
|
||||
onSubmit: () => void;
|
||||
isPending: boolean;
|
||||
};
|
||||
|
||||
const ReplyForm: FC<ReplyFormProps> = ({
|
||||
content,
|
||||
onContentChange,
|
||||
onFilesChange,
|
||||
onSubmit,
|
||||
isPending,
|
||||
}) => (
|
||||
<Fragment>
|
||||
<div className="mt-9">
|
||||
<Textarea
|
||||
id="content"
|
||||
placeholder={t("ticket.your_description")}
|
||||
value={content}
|
||||
onChange={(e) => onContentChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-7">
|
||||
<UploadBox
|
||||
label={t("ticket.attachment")}
|
||||
onChange={onFilesChange}
|
||||
isMultiple
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end mt-6">
|
||||
<Button
|
||||
className="xl:max-w-[100px]"
|
||||
onClick={onSubmit}
|
||||
isLoading={isPending}
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Send2 size={20} color="black" />
|
||||
<span>{t("ticket.send")}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
export default ReplyForm;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { type FC } from "react";
|
||||
import { CloseCircle } from "iconsax-react";
|
||||
import Button from "@/components/Button";
|
||||
import { t } from "@/locale";
|
||||
|
||||
export type TicketDetailActionsProps = {
|
||||
onScrollToReply: () => void;
|
||||
onCloseTicket: () => void;
|
||||
};
|
||||
|
||||
const TicketDetailActions: FC<TicketDetailActionsProps> = ({
|
||||
onScrollToReply,
|
||||
onCloseTicket,
|
||||
}) => (
|
||||
<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={onScrollToReply}
|
||||
/>
|
||||
<Button
|
||||
className="bg-[#D52903] xl:h-10 h-8"
|
||||
onClick={onCloseTicket}
|
||||
>
|
||||
<div className="flex gap-2 text-xs items-center text-white">
|
||||
<CloseCircle color="white" size={20} />
|
||||
<span>{t("ticket.close_ticket")}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default TicketDetailActions;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { type FC } from "react";
|
||||
import { InfoCircle } from "iconsax-react";
|
||||
import { clx } from "@/helpers/utils";
|
||||
import { t } from "@/locale";
|
||||
import type { TicketDetailType } from "./types/TicketTypes";
|
||||
import { formatDate, getStatusClass } from "./detailUtils";
|
||||
|
||||
export type TicketInfoSidebarProps = {
|
||||
ticket: TicketDetailType;
|
||||
};
|
||||
|
||||
type ExtendedTicketFields = {
|
||||
numericId?: string;
|
||||
assignedTo?: { firstName?: string; lastName?: string };
|
||||
category?: { title?: string };
|
||||
priority?: string;
|
||||
};
|
||||
|
||||
const TicketInfoSidebar: FC<TicketInfoSidebarProps> = ({ ticket }) => {
|
||||
const status = ticket.status?.toUpperCase() ?? "";
|
||||
const statusLabel = t(`ticket.${ticket.status}`) || t(`ticket.${status}`);
|
||||
const extendedTicket = ticket as TicketDetailType & ExtendedTicketFields;
|
||||
|
||||
const infoRows: { label: string; value: string }[] = [
|
||||
{ label: t("ticket.ticket_number"), value: extendedTicket.numericId ?? ticket.id },
|
||||
{ label: t("ticket.date"), value: formatDate(ticket.createdAt) },
|
||||
];
|
||||
if (extendedTicket.assignedTo) {
|
||||
const name = [extendedTicket.assignedTo.firstName, extendedTicket.assignedTo.lastName]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
if (name) infoRows.push({ label: t("ticket.asignto"), value: name });
|
||||
}
|
||||
if (extendedTicket.category?.title) {
|
||||
infoRows.push({ label: t("ticket.category"), value: extendedTicket.category.title });
|
||||
}
|
||||
if (extendedTicket.priority) {
|
||||
const priorityLabel =
|
||||
extendedTicket.priority === "low"
|
||||
? t("ticket.low")
|
||||
: extendedTicket.priority === "medium"
|
||||
? t("ticket.medium")
|
||||
: t("ticket.high");
|
||||
infoRows.push({ label: t("ticket.priority"), value: priorityLabel });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx(
|
||||
"bg-white p-5 rounded-3xl",
|
||||
status === "CLOSED" ? "mt-0" : "mt-6"
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-between items-center gap-3">
|
||||
<span className="text-sm font-medium">{t("ticket.info_ticket")}</span>
|
||||
<span
|
||||
className={clx(
|
||||
"shrink-0 h-7 px-3 rounded-xl flex items-center text-xs font-medium",
|
||||
getStatusClass(ticket.status)
|
||||
)}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<dl className="mt-5 space-y-4 text-xs">
|
||||
{infoRows.map(({ label, value }) => (
|
||||
<div key={label} className="flex justify-between gap-3 items-baseline">
|
||||
<dt className="text-description shrink-0">{label}</dt>
|
||||
<dd className="dltr text-left font-medium min-w-0 truncate" title={value}>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="mt-6 pt-4 border-t border-border flex items-center gap-2 text-xs text-description">
|
||||
<InfoCircle size={18} className="shrink-0" />
|
||||
<span>{t("ticket.update_sms")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketInfoSidebar;
|
||||
@@ -0,0 +1,39 @@
|
||||
import moment from "moment-jalaali";
|
||||
import { t } from "@/locale";
|
||||
import type { TicketDetailType, TicketThreadItemType } from "./types/TicketTypes";
|
||||
import type { AttachmentType } from "./types/TicketTypes";
|
||||
import { getTicketDisplayName } from "./types/TicketTypes";
|
||||
|
||||
export const buildThreadItems = (ticket: TicketDetailType): TicketThreadItemType[] => {
|
||||
const main: TicketThreadItemType = {
|
||||
id: ticket.id,
|
||||
content: ticket.content,
|
||||
createdAt: ticket.createdAt,
|
||||
authorLabel: getTicketDisplayName(ticket.user),
|
||||
isFromUser: true,
|
||||
attachments: (ticket.attachments as AttachmentType[] | undefined) ?? [],
|
||||
};
|
||||
const children: TicketThreadItemType[] = (ticket.children ?? []).map((c) => {
|
||||
const isFromUser = c.admin == null;
|
||||
return {
|
||||
id: c.id,
|
||||
content: c.content,
|
||||
createdAt: c.createdAt,
|
||||
authorLabel: isFromUser
|
||||
? getTicketDisplayName(ticket.user)
|
||||
: t("ticket.expert"),
|
||||
isFromUser,
|
||||
attachments: (c.attachments as AttachmentType[] | undefined) ?? [],
|
||||
};
|
||||
});
|
||||
return [main, ...children];
|
||||
};
|
||||
|
||||
export const formatDate = (date: string) => moment(date).format("jYYYY-jMM-jDD HH:mm");
|
||||
|
||||
export const getStatusClass = (status: string) => {
|
||||
const s = status?.toUpperCase();
|
||||
if (s === "CLOSED") return "bg-red-100 text-red-400";
|
||||
if (s === "PENDING") return "bg-orange-100 text-orange-700";
|
||||
return "bg-green-100 text-green-400";
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type AddMessageTicketType,
|
||||
type CreateTicketPayload,
|
||||
type CreateTicketType,
|
||||
type GetTicketByIdResponseType,
|
||||
type GetTicketsResponseType,
|
||||
} from "../types/TicketTypes";
|
||||
|
||||
@@ -14,9 +15,8 @@ export const getTickets = async (status?: string): Promise<GetTicketsResponseTyp
|
||||
return data;
|
||||
};
|
||||
|
||||
/** جزئیات یک تیکت - GET /public/tickets/{id} */
|
||||
export const getTicketById = async (id: string) => {
|
||||
const { data } = await axios.get(`/public/tickets/${id}`);
|
||||
export const getTicketById = async (id: string): Promise<GetTicketByIdResponseType> => {
|
||||
const { data } = await axios.get<GetTicketByIdResponseType>(`/public/tickets/${id}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
@@ -31,6 +31,20 @@ export type TicketListItemType = {
|
||||
|
||||
export type GetTicketsResponseType = BaseResponse<TicketListItemType[]>;
|
||||
|
||||
/** آیتم تیکت در children (user بهصورت id یا null، admin برای تشخیص پیام ادمین/کاربر) */
|
||||
export type TicketDetailChildType = Omit<TicketListItemType, "user" | "children"> & {
|
||||
user: string | null;
|
||||
/** null = پیام از کاربر، مقدار دارد = پیام از ادمین/کارشناس */
|
||||
admin: string | null;
|
||||
};
|
||||
|
||||
/** جزئیات یک تیکت (پاسخ GET /public/tickets/:id) */
|
||||
export type TicketDetailType = Omit<TicketListItemType, "children"> & {
|
||||
children: TicketDetailChildType[];
|
||||
};
|
||||
|
||||
export type GetTicketByIdResponseType = BaseResponse<TicketDetailType>;
|
||||
|
||||
/** برای ایجاد تیکت جدید (بدون parentId) یا ارسال پیام (با parentId) */
|
||||
export type CreateTicketPayload = {
|
||||
parentId?: string;
|
||||
@@ -70,3 +84,16 @@ export type MessageType = {
|
||||
};
|
||||
attachments?: AttachmentType[];
|
||||
};
|
||||
|
||||
/** آیتم قابل نمایش در رشته مکالمه (تیکت اصلی یا پاسخ) */
|
||||
export type TicketThreadItemType = {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
authorLabel: string;
|
||||
isFromUser: boolean;
|
||||
attachments?: AttachmentType[];
|
||||
};
|
||||
|
||||
export const getTicketDisplayName = (user: TicketUserType): string =>
|
||||
[user.firstName, user.lastName].filter(Boolean).join(" ").trim() || user.phone || "-";
|
||||
|
||||
Reference in New Issue
Block a user