ticket
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
# VITE_API_BASE_URL = 'https://negareh-api.dev.danakcorp.com'
|
# VITE_API_BASE_URL = 'https://negareh-api.dev.danakcorp.com'
|
||||||
VITE_API_BASE_URL = 'http://10.29.35.88:4000'
|
VITE_API_BASE_URL = 'http://10.39.173.88:4000'
|
||||||
VITE_TOKEN_NAME = 'negareh_at'
|
VITE_TOKEN_NAME = 'negareh_at'
|
||||||
VITE_REFRESH_TOKEN_NAME = 'negareh_art'
|
VITE_REFRESH_TOKEN_NAME = 'negareh_art'
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export const Paths = {
|
|||||||
orderDetails: '/order/',
|
orderDetails: '/order/',
|
||||||
tickets: {
|
tickets: {
|
||||||
list: '/tickets',
|
list: '/tickets',
|
||||||
|
detail: '/tickets/',
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
login: '/auth/login',
|
login: '/auth/login',
|
||||||
|
|||||||
@@ -69,4 +69,37 @@ export const fa = {
|
|||||||
errors: {
|
errors: {
|
||||||
required: "این فیلد الزامی است",
|
required: "این فیلد الزامی است",
|
||||||
},
|
},
|
||||||
|
ticket: {
|
||||||
|
ticket_number: "شماره تیکت",
|
||||||
|
info_ticket: "اطلاعات تیکت",
|
||||||
|
subject: "موضوع",
|
||||||
|
select_your_service: "سرویس",
|
||||||
|
user_name: "نام کاربر",
|
||||||
|
customer: "کاربر",
|
||||||
|
expert: "پشتیبانی",
|
||||||
|
your_description: "متن پاسخ به کاربر",
|
||||||
|
attachment: "پیوست",
|
||||||
|
send: "ارسال",
|
||||||
|
send_answer: "ارسال پاسخ",
|
||||||
|
close_ticket: "بستن تیکت",
|
||||||
|
date: "تاریخ",
|
||||||
|
asignto: "واگذار شده به",
|
||||||
|
category: "دستهبندی",
|
||||||
|
priority: "اولویت",
|
||||||
|
low: "کم",
|
||||||
|
medium: "متوسط",
|
||||||
|
high: "بالا",
|
||||||
|
update_sms: "در صورت بهروزرسانی، از طریق پیامک مطلع میشوید.",
|
||||||
|
PENDING: "در حال بررسی",
|
||||||
|
pending: "در حال بررسی",
|
||||||
|
ANSWERED: "پاسخ داده شده",
|
||||||
|
answered: "پاسخ داده شده",
|
||||||
|
CLOSED: "بسته شده",
|
||||||
|
closed: "بسته شده",
|
||||||
|
status_unknown: "نامشخص",
|
||||||
|
new_ticket: "تیکت جدید",
|
||||||
|
submit_your_message: "پیام خود را ثبت کنید",
|
||||||
|
enter_your_subject: "موضوع را وارد کنید",
|
||||||
|
title_hint: "راهنما",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { type FC, useState } from 'react'
|
||||||
|
import Input from '../../components/Input'
|
||||||
|
import UploadBox from '../../components/UploadBox'
|
||||||
|
import Button from '../../components/Button'
|
||||||
|
import { TickCircle, TickSquare } from 'iconsax-react'
|
||||||
|
import { useCreateOrReplyTicket } from './hooks/useTicketData'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { fa } from '@/locale/fa'
|
||||||
|
import Textarea from '@/components/Textarea'
|
||||||
|
import { Paths } from '@/config/Paths'
|
||||||
|
|
||||||
|
const CreateTicket: FC = () => {
|
||||||
|
const t = (key: string): 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 [subject, setSubject] = useState('')
|
||||||
|
const [content, setContent] = useState('')
|
||||||
|
const [files, setFiles] = useState<File[]>([])
|
||||||
|
|
||||||
|
const createTicket = useCreateOrReplyTicket()
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!subject.trim() || !content.trim()) return
|
||||||
|
createTicket.mutate(
|
||||||
|
{
|
||||||
|
subject: subject.trim(),
|
||||||
|
content: content.trim(),
|
||||||
|
attachments: files.length ? files.map(() => ({})) : [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => navigate(Paths.tickets.list),
|
||||||
|
onError: (err: unknown) => console.error(err),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='mt-4 pb-12'>
|
||||||
|
<div>
|
||||||
|
{t('ticket.new_ticket')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex gap-6 xl:mt-8 mt-4'>
|
||||||
|
<div className='flex-1 bg-white py-8 xl:px-10 px-4 rounded-3xl'>
|
||||||
|
<div>
|
||||||
|
{t('ticket.submit_your_message')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-6'>
|
||||||
|
<Input
|
||||||
|
label={t('ticket.subject')}
|
||||||
|
placeholder={t('ticket.enter_your_subject')}
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-6'>
|
||||||
|
<Textarea
|
||||||
|
label={t('ticket.your_description')}
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-6'>
|
||||||
|
<UploadBox
|
||||||
|
label={t('ticket.attachment')}
|
||||||
|
isMultiple
|
||||||
|
onChange={setFiles}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-8 flex justify-end'>
|
||||||
|
<Button
|
||||||
|
className='xl:max-w-[100px]'
|
||||||
|
onClick={handleSubmit}
|
||||||
|
isLoading={createTicket.isPending}
|
||||||
|
>
|
||||||
|
<div className='flex gap-2 items-center'>
|
||||||
|
<TickCircle size={20} color='black' />
|
||||||
|
<div>
|
||||||
|
{t('ticket.send')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='bg-white w-[300px] xl:block hidden py-10 px-5 h-fit rounded-3xl'>
|
||||||
|
<div className='text-sm'>
|
||||||
|
{t('ticket.title_hint')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-6'>
|
||||||
|
<div className='flex items-start gap-2 border-b pb-5'>
|
||||||
|
<div>
|
||||||
|
<TickSquare size={20} color='black' variant='Bold' />
|
||||||
|
</div>
|
||||||
|
<div className='text-description text-xs leading-5'>
|
||||||
|
سوالات - مشکلاتی که به یک موضوع مربوط میشوند را در یک درخواست پشتیبانی پیگیر باشید و چند درخواست برای یک موضوع باز نکنید.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-start gap-2 mt-6 border-b pb-5'>
|
||||||
|
<div>
|
||||||
|
<TickSquare size={20} color='black' variant='Bold' />
|
||||||
|
</div>
|
||||||
|
<div className='text-description text-xs leading-5'>
|
||||||
|
لطفاً برای بررسی و رفع مشکلات احتمالی صبور باشید بررسی و رفع مشکلات در برخی موارد زمان گیر است.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-start gap-2 mt-6'>
|
||||||
|
<div>
|
||||||
|
<TickSquare size={20} color='black' variant='Bold' />
|
||||||
|
</div>
|
||||||
|
<div className='text-description text-xs leading-5'>
|
||||||
|
پاسخگویی 24 ساعته تلفنی را تنها از داناک می توانید انتظار داشته باشید .بخش پشتیبانی در هر ساعتی حتی در روز های تعطیل آماده پیگیری سریع مشکلات کاربران است.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateTicket
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { type FC, useMemo, useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import Input from "@/components/Input";
|
||||||
|
import { clx } from "@/helpers/utils";
|
||||||
|
import { t } from "@/locale";
|
||||||
|
import { getTicketDisplayName } from "./types/TicketTypes";
|
||||||
|
import type { TicketDetailType } from "./types/TicketTypes";
|
||||||
|
import { useCloseTicket, useCreateOrReplyTicket, useGetTicketById } from "./hooks/useTicketData";
|
||||||
|
import { buildThreadItems, getStatusClass, getStatusLabel } 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 { id } = useParams<{ id: string }>();
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
|
||||||
|
const getTicket = useGetTicketById(id ?? "");
|
||||||
|
const addMessage = useCreateOrReplyTicket();
|
||||||
|
const closeTicket = useCloseTicket();
|
||||||
|
|
||||||
|
const rawData = getTicket.data?.data;
|
||||||
|
const ticket: TicketDetailType | undefined = useMemo(() => {
|
||||||
|
if (!rawData) return undefined;
|
||||||
|
if (Array.isArray(rawData)) {
|
||||||
|
const first = rawData[0];
|
||||||
|
if (!first) return undefined;
|
||||||
|
return { ...first, children: rawData.slice(1) } as unknown as TicketDetailType;
|
||||||
|
}
|
||||||
|
return rawData as TicketDetailType;
|
||||||
|
}, [rawData]);
|
||||||
|
const threadItems = useMemo(
|
||||||
|
() => (ticket ? buildThreadItems(ticket) : []),
|
||||||
|
[ticket]
|
||||||
|
);
|
||||||
|
const isClosed =
|
||||||
|
ticket?.status?.toLowerCase() === STATUS_CLOSED ||
|
||||||
|
ticket?.status?.toUpperCase() === "CLOSED";
|
||||||
|
|
||||||
|
const handleCloseTicket = () => {
|
||||||
|
if (!id) return;
|
||||||
|
closeTicket.mutate(
|
||||||
|
{ id, params: { status: "closed" } },
|
||||||
|
{
|
||||||
|
onSuccess: () => getTicket.refetch(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendReply = () => {
|
||||||
|
if (!id || !ticket || !content.trim()) return;
|
||||||
|
addMessage.mutate(
|
||||||
|
{
|
||||||
|
parentId: id,
|
||||||
|
content: content.trim(),
|
||||||
|
attachments: files.length ? files.map(() => ({})) : [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 extendedTicket = ticket as TicketDetailType & {
|
||||||
|
danakService?: { name?: string };
|
||||||
|
numericId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getStatusLabel(ticket.status)}
|
||||||
|
</span>
|
||||||
|
</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.subject}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<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" : ""}
|
||||||
|
>
|
||||||
|
<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}
|
||||||
|
isClosing={closeTicket.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TicketInfoSidebar ticket={ticket} />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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,36 @@
|
|||||||
|
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;
|
||||||
|
isClosing?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TicketDetailActions: FC<TicketDetailActionsProps> = ({
|
||||||
|
onScrollToReply,
|
||||||
|
onCloseTicket,
|
||||||
|
isClosing = false,
|
||||||
|
}) => (
|
||||||
|
<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}
|
||||||
|
disabled={isClosing}
|
||||||
|
>
|
||||||
|
<div className="flex gap-2 text-xs items-center text-white">
|
||||||
|
<CloseCircle color="white" size={20} />
|
||||||
|
<span>{isClosing ? "..." : 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, getStatusLabel } 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 = getStatusLabel(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,112 @@
|
|||||||
|
import { type FC, useState } from 'react'
|
||||||
|
import { Eye } from 'iconsax-react'
|
||||||
|
import Tabs from '../../components/Tabs'
|
||||||
|
import Table from '../../components/Table'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import moment from 'moment-jalaali'
|
||||||
|
import type { ColumnType } from '@/components/types/TableTypes'
|
||||||
|
import { Paths } from '@/config/Paths'
|
||||||
|
import { useGetTickets } from './hooks/useTicketData'
|
||||||
|
import type { TicketListItemType } from './types/TicketTypes'
|
||||||
|
|
||||||
|
const TicketList: FC = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'ANSWERED' | 'PENDING' | 'CLOSED' | ''>('')
|
||||||
|
const getTickets = useGetTickets(activeTab || undefined)
|
||||||
|
|
||||||
|
const tickets: TicketListItemType[] = getTickets.data?.data ?? []
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
ANSWERED: 'پاسخ داده شده',
|
||||||
|
PENDING: 'در حال بررسی',
|
||||||
|
CLOSED: 'بسته شده'
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnType<TicketListItemType>[] = [
|
||||||
|
{
|
||||||
|
title: 'شماره تیکت',
|
||||||
|
key: 'id',
|
||||||
|
render: (item) => item.id.slice(-8).toUpperCase()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'عنوان',
|
||||||
|
key: 'subject',
|
||||||
|
render: (item) => item.subject
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'تاریخ',
|
||||||
|
key: 'createdAt',
|
||||||
|
render: (item) => (
|
||||||
|
<div className='dltr text-right'>
|
||||||
|
{moment(item.createdAt).format('jYYYY-jMM-jDD HH:mm')}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'وضعیت',
|
||||||
|
key: 'status',
|
||||||
|
render: (item) => statusLabels[item.status] ?? item.status
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'نام کاربر',
|
||||||
|
key: 'user',
|
||||||
|
render: (item) => {
|
||||||
|
const u = item.user
|
||||||
|
const name = u ? [u.firstName, u.lastName].filter(Boolean).join(' ') || u.phone || '-' : '-'
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'جزئیات',
|
||||||
|
key: 'actions',
|
||||||
|
render: (item) => (
|
||||||
|
<Link to={Paths.tickets.detail + item.id}>
|
||||||
|
<Eye size={20} color='#8C90A3' />
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='mt-4'>
|
||||||
|
<div className='flex justify-between items-center'>
|
||||||
|
<div>
|
||||||
|
تیکتهای کاربران
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-14'>
|
||||||
|
<Tabs
|
||||||
|
activeTab={activeTab}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: 'همه',
|
||||||
|
value: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'در حال بررسی',
|
||||||
|
value: 'PENDING'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'پاسخ داده شده',
|
||||||
|
value: 'ANSWERED'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'بسته شده',
|
||||||
|
value: 'CLOSED'
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onTabChange={(value: string) => setActiveTab(value as 'ANSWERED' | 'PENDING' | 'CLOSED')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={tickets}
|
||||||
|
isLoading={getTickets.isPending}
|
||||||
|
noDataMessage="هیچ تیکتی یافت نشد"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TicketList
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
// دادههای استاتیک برای بخش تیکت
|
||||||
|
|
||||||
|
export const staticTicketsData = {
|
||||||
|
data: {
|
||||||
|
tickets: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
numericId: "TK-001",
|
||||||
|
subject: "مشکل در اتصال به سرور",
|
||||||
|
status: "PENDING",
|
||||||
|
priority: "HIGH",
|
||||||
|
createdAt: "2024-01-15T10:30:00Z",
|
||||||
|
updatedAt: "2024-01-15T14:45:00Z",
|
||||||
|
category: {
|
||||||
|
id: "1",
|
||||||
|
title: "مشکلات فنی",
|
||||||
|
},
|
||||||
|
danakService: {
|
||||||
|
id: "1",
|
||||||
|
name: "سرویس ابری داناک",
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: "1",
|
||||||
|
firstName: "احمد",
|
||||||
|
lastName: "محمدی",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
numericId: "TK-002",
|
||||||
|
subject: "سوال در مورد صورتحساب",
|
||||||
|
status: "ANSWERED",
|
||||||
|
priority: "MEDIUM",
|
||||||
|
createdAt: "2024-01-14T09:15:00Z",
|
||||||
|
updatedAt: "2024-01-14T16:20:00Z",
|
||||||
|
category: {
|
||||||
|
id: "2",
|
||||||
|
title: "مالی",
|
||||||
|
},
|
||||||
|
danakService: {
|
||||||
|
id: "2",
|
||||||
|
name: "سرویس پرداخت",
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: "1",
|
||||||
|
firstName: "احمد",
|
||||||
|
lastName: "محمدی",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
numericId: "TK-003",
|
||||||
|
subject: "درخواست تغییر پلان",
|
||||||
|
status: "CLOSED",
|
||||||
|
priority: "LOW",
|
||||||
|
createdAt: "2024-01-13T11:00:00Z",
|
||||||
|
updatedAt: "2024-01-13T15:30:00Z",
|
||||||
|
category: {
|
||||||
|
id: "3",
|
||||||
|
title: "درخواستها",
|
||||||
|
},
|
||||||
|
danakService: {
|
||||||
|
id: "1",
|
||||||
|
name: "سرویس ابری داناک",
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: "1",
|
||||||
|
firstName: "احمد",
|
||||||
|
lastName: "محمدی",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
numericId: "TK-004",
|
||||||
|
subject: "مشکل در آپلود فایل",
|
||||||
|
status: "PENDING",
|
||||||
|
priority: "HIGH",
|
||||||
|
createdAt: "2024-01-12T14:20:00Z",
|
||||||
|
updatedAt: "2024-01-12T14:20:00Z",
|
||||||
|
category: {
|
||||||
|
id: "1",
|
||||||
|
title: "مشکلات فنی",
|
||||||
|
},
|
||||||
|
danakService: {
|
||||||
|
id: "3",
|
||||||
|
name: "سرویس ذخیرهسازی",
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: "1",
|
||||||
|
firstName: "احمد",
|
||||||
|
lastName: "محمدی",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5",
|
||||||
|
numericId: "TK-005",
|
||||||
|
subject: "سوال در مورد API",
|
||||||
|
status: "ANSWERED",
|
||||||
|
priority: "MEDIUM",
|
||||||
|
createdAt: "2024-01-11T08:45:00Z",
|
||||||
|
updatedAt: "2024-01-11T12:15:00Z",
|
||||||
|
category: {
|
||||||
|
id: "4",
|
||||||
|
title: "پشتیبانی فنی",
|
||||||
|
},
|
||||||
|
danakService: {
|
||||||
|
id: "4",
|
||||||
|
name: "سرویس API",
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: "1",
|
||||||
|
firstName: "احمد",
|
||||||
|
lastName: "محمدی",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const staticTicketDetailData = {
|
||||||
|
data: {
|
||||||
|
ticket: {
|
||||||
|
id: "1",
|
||||||
|
numericId: "TK-001",
|
||||||
|
subject: "مشکل در اتصال به سرور",
|
||||||
|
status: "PENDING",
|
||||||
|
priority: "HIGH",
|
||||||
|
createdAt: "2024-01-15T10:30:00Z",
|
||||||
|
updatedAt: "2024-01-15T14:45:00Z",
|
||||||
|
category: {
|
||||||
|
id: "1",
|
||||||
|
title: "مشکلات فنی",
|
||||||
|
},
|
||||||
|
danakService: {
|
||||||
|
id: "1",
|
||||||
|
name: "سرویس ابری داناک",
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: "1",
|
||||||
|
firstName: "احمد",
|
||||||
|
lastName: "محمدی",
|
||||||
|
},
|
||||||
|
assignedTo: {
|
||||||
|
id: "2",
|
||||||
|
firstName: "علی",
|
||||||
|
lastName: "احمدی",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
content:
|
||||||
|
"سلام، من در اتصال به سرور مشکل دارم. هر بار که سعی میکنم به API متصل شوم، خطای timeout دریافت میکنم. لطفاً راهنمایی کنید.",
|
||||||
|
createdAt: "2024-01-15T10:30:00Z",
|
||||||
|
author: {
|
||||||
|
id: "1",
|
||||||
|
firstName: "احمد",
|
||||||
|
lastName: "محمدی",
|
||||||
|
roles: [
|
||||||
|
{
|
||||||
|
name: "user",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
attachmentUrl: "https://example.com/attachment1.pdf",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
content:
|
||||||
|
"سلام احمد عزیز، متشکرم از گزارش مشکل. این مشکل معمولاً به دلیل تنظیمات فایروال یا محدودیتهای شبکه رخ میدهد. لطفاً موارد زیر را بررسی کنید:\n\n1. بررسی تنظیمات فایروال\n2. اطمینان از دسترسی به پورت 443\n3. تست اتصال با VPN\n\nاگر مشکل ادامه داشت، لطفاً لاگهای مربوطه را ارسال کنید.",
|
||||||
|
createdAt: "2024-01-15T11:15:00Z",
|
||||||
|
author: {
|
||||||
|
id: "2",
|
||||||
|
firstName: "علی",
|
||||||
|
lastName: "احمدی",
|
||||||
|
roles: [
|
||||||
|
{
|
||||||
|
name: "admin",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
attachments: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
content:
|
||||||
|
"ممنون از راهنمایی. بررسی کردم و مشکل از فایروال بود. حالا درست کار میکند.",
|
||||||
|
createdAt: "2024-01-15T14:45:00Z",
|
||||||
|
author: {
|
||||||
|
id: "1",
|
||||||
|
firstName: "احمد",
|
||||||
|
lastName: "محمدی",
|
||||||
|
roles: [
|
||||||
|
{
|
||||||
|
name: "user",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
attachments: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const staticCategoriesData = {
|
||||||
|
data: {
|
||||||
|
ticketCategories: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
title: "مشکلات فنی",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
title: "مالی",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
title: "درخواستها",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
title: "پشتیبانی فنی",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5",
|
||||||
|
title: "پیشنهادات",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const staticProfileData = {
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
id: "1",
|
||||||
|
firstName: "احمد",
|
||||||
|
lastName: "محمدی",
|
||||||
|
email: "ahmad.mohammadi@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const staticServicesData = {
|
||||||
|
data: {
|
||||||
|
subscriptions: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
plan: {
|
||||||
|
service: {
|
||||||
|
id: "1",
|
||||||
|
name: "سرویس ابری داناک",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
plan: {
|
||||||
|
service: {
|
||||||
|
id: "2",
|
||||||
|
name: "سرویس پرداخت",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
plan: {
|
||||||
|
service: {
|
||||||
|
id: "3",
|
||||||
|
name: "سرویس ذخیرهسازی",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
plan: {
|
||||||
|
service: {
|
||||||
|
id: "4",
|
||||||
|
name: "سرویس API",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** ترجمهٔ وضعیت تیکت با fallback به نامشخص */
|
||||||
|
export const getStatusLabel = (status: string | undefined): string => {
|
||||||
|
if (!status) return t("ticket.status_unknown");
|
||||||
|
const key1 = `ticket.${status}`;
|
||||||
|
const key2 = `ticket.${status.toUpperCase()}`;
|
||||||
|
const label1 = t(key1);
|
||||||
|
const label2 = t(key2);
|
||||||
|
if (label1 !== key1) return label1;
|
||||||
|
if (label2 !== key2) return label2;
|
||||||
|
return t("ticket.status_unknown");
|
||||||
|
};
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import * as api from "../service/TicketService";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
type AddMessageTicketType,
|
||||||
|
type CloseTicketParamsType,
|
||||||
|
type CreateTicketPayload,
|
||||||
|
type CreateTicketType,
|
||||||
|
} from "../types/TicketTypes";
|
||||||
|
|
||||||
|
export const useGetTickets = (status?: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["public-tickets", status ?? ""],
|
||||||
|
queryFn: () => api.getTickets(status),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** جزئیات یک تیکت از GET /public/tickets/{id} */
|
||||||
|
export const useGetTicketById = (id: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["public-ticket", id],
|
||||||
|
queryFn: () => api.getTicketById(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** ایجاد تیکت جدید (بدون parentId) یا ارسال پیام (با parentId) */
|
||||||
|
export const useCreateOrReplyTicket = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (variables: CreateTicketPayload) => api.createOrReplyTicket(variables),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["public-tickets"] });
|
||||||
|
if (variables.parentId) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["public-ticket", variables.parentId] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCreateTicket = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (variables: CreateTicketType) => api.createTicket(variables),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetCategoriesTicket = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["category-tickets"],
|
||||||
|
queryFn: () => api.getCategoriesTicket(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSingleUpload = (callback?: (value: number) => void) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (variables: FormData) => api.uploadSingle(variables, callback),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
export const useMultiUpload = (callback?: (value: number) => void) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (variables: FormData) =>
|
||||||
|
api.uploadMultiple(variables, callback),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetMessages = (id: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["ticket-messages", id],
|
||||||
|
queryFn: () => api.getMessages(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAddMessageTicket = (id: string) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (variables: AddMessageTicketType) =>
|
||||||
|
api.addMessageTicket(id, variables),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({
|
||||||
|
queryKey: ["ticket-messages", id],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCloseTicket = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
id,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
params: CloseTicketParamsType;
|
||||||
|
}) => api.closeTicket(id, params),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.refetchQueries({
|
||||||
|
queryKey: ["tickets"],
|
||||||
|
});
|
||||||
|
queryClient.refetchQueries({
|
||||||
|
queryKey: ["ticket-messages"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type { AxiosProgressEvent } from "axios";
|
||||||
|
import axios from "@/config/axios";
|
||||||
|
import {
|
||||||
|
type AddMessageTicketType,
|
||||||
|
type CloseTicketParamsType,
|
||||||
|
type CreateTicketPayload,
|
||||||
|
type CreateTicketType,
|
||||||
|
type GetTicketByIdResponseType,
|
||||||
|
type GetTicketsResponseType,
|
||||||
|
} from "../types/TicketTypes";
|
||||||
|
|
||||||
|
/** لیست تیکتها - GET /public/tickets */
|
||||||
|
export const getTickets = async (status?: string): Promise<GetTicketsResponseType> => {
|
||||||
|
const query = status ? `?status=${status}` : "";
|
||||||
|
const { data } = await axios.get<GetTicketsResponseType>(`/admin/tickets${query}`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTicketById = async (id: string): Promise<GetTicketByIdResponseType> => {
|
||||||
|
const { data } = await axios.get<GetTicketByIdResponseType>(`/admin/tickets/${id}`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** ایجاد تیکت جدید (بدون parentId) یا ارسال پیام (با parentId) - POST /public/tickets */
|
||||||
|
export const createOrReplyTicket = async (params: CreateTicketPayload) => {
|
||||||
|
const { data } = await axios.post('/admin/tickets', params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @deprecated استفاده از createOrReplyTicket با/بدون parentId */
|
||||||
|
export const createTicket = async (params: CreateTicketType) => {
|
||||||
|
const { data } = await axios.post(`/tickets`, params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCategoriesTicket = async () => {
|
||||||
|
const { data } = await axios.get(`/tickets/categories`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadSingle = async (
|
||||||
|
params: FormData,
|
||||||
|
callback?: (value: number) => void
|
||||||
|
) => {
|
||||||
|
const { data } = await axios.post(`/uploader/single-file`, params, {
|
||||||
|
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
|
||||||
|
if (progressEvent.bytes && progressEvent.total) {
|
||||||
|
if (callback) {
|
||||||
|
callback(
|
||||||
|
Math.round((progressEvent.loaded / progressEvent.total) * 100)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (callback) {
|
||||||
|
callback(0);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadMultiple = async (
|
||||||
|
params: FormData,
|
||||||
|
callback?: (value: number) => void
|
||||||
|
) => {
|
||||||
|
const { data } = await axios.post(`/uploader/multi-file`, params, {
|
||||||
|
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
|
||||||
|
if (progressEvent.bytes && progressEvent.total) {
|
||||||
|
if (callback) {
|
||||||
|
callback(
|
||||||
|
Math.round((progressEvent.loaded / progressEvent.total) * 100)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (callback) {
|
||||||
|
callback(0);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMessages = async (id: string) => {
|
||||||
|
const { data } = await axios.get(`/tickets/${id}/messages`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addMessageTicket = async (
|
||||||
|
id: string,
|
||||||
|
params: AddMessageTicketType
|
||||||
|
) => {
|
||||||
|
const { data } = await axios.post(`/tickets/${id}/messages`, params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const closeTicket = async (id: string, params: CloseTicketParamsType) => {
|
||||||
|
const { data } = await axios.patch(`/admin/tickets/${id}/status`, params);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { BaseResponse } from "@/shared/types/Types";
|
||||||
|
|
||||||
|
/** کاربر در لیست تیکت (پاسخ GET /public/tickets) */
|
||||||
|
export type TicketUserType = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
gender: boolean;
|
||||||
|
maxCredit: number;
|
||||||
|
addresse: string | null;
|
||||||
|
phone: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** آیتم تیکت در لیست (پاسخ GET /public/tickets) */
|
||||||
|
export type TicketListItemType = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
parent: unknown | null;
|
||||||
|
subject: string;
|
||||||
|
content: string;
|
||||||
|
status: string;
|
||||||
|
attachments: unknown[];
|
||||||
|
admin: unknown | null;
|
||||||
|
user: TicketUserType;
|
||||||
|
children: unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
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[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** API ممکن است یک تیکت یا آرایهٔ پیامهای ترد برگرداند */
|
||||||
|
export type GetTicketByIdResponseType = BaseResponse<TicketDetailType | TicketDetailType[]>;
|
||||||
|
|
||||||
|
/** برای ایجاد تیکت جدید (بدون parentId) یا ارسال پیام (با parentId) - subject فقط برای ساخت تیکت */
|
||||||
|
export type CreateTicketPayload = {
|
||||||
|
parentId?: string;
|
||||||
|
subject?: string;
|
||||||
|
content: string;
|
||||||
|
attachments?: Record<string, unknown>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateTicketType = {
|
||||||
|
title: string;
|
||||||
|
subject: string;
|
||||||
|
priority: "LOW" | "MEDIUM" | "HIGH";
|
||||||
|
danakServiceId?: string;
|
||||||
|
categoryId: string;
|
||||||
|
message: string;
|
||||||
|
attachmentUrls?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AddMessageTicketType = {
|
||||||
|
content: string;
|
||||||
|
attachmentUrls?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AttachmentType = {
|
||||||
|
id: string;
|
||||||
|
attachmentUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageType = {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
author: {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
roles: Array<{ name: string }>;
|
||||||
|
};
|
||||||
|
attachments?: AttachmentType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** آیتم قابل نمایش در رشته مکالمه (تیکت اصلی یا پاسخ) */
|
||||||
|
export type TicketThreadItemType = {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
authorLabel: string;
|
||||||
|
isFromUser: boolean;
|
||||||
|
attachments?: AttachmentType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTicketDisplayName = (user: TicketUserType | null | undefined): string =>
|
||||||
|
user
|
||||||
|
? [user.firstName, user.lastName].filter(Boolean).join(" ").trim() || user.phone || "-"
|
||||||
|
: "-";
|
||||||
|
|
||||||
|
export type CloseTicketParamsType = {
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
@@ -49,6 +49,8 @@ import EditAdmin from "@/pages/admin/Update";
|
|||||||
import RequestDetail from "@/pages/requests/Detail";
|
import RequestDetail from "@/pages/requests/Detail";
|
||||||
import ConvertToOrders from "@/pages/order/ConvertToOrders";
|
import ConvertToOrders from "@/pages/order/ConvertToOrders";
|
||||||
import OrderPrint from "@/pages/order/Print";
|
import OrderPrint from "@/pages/order/Print";
|
||||||
|
import TicketList from "@/pages/ticket/TicketList";
|
||||||
|
import TicketDetail from "@/pages/ticket/Detail";
|
||||||
|
|
||||||
const MainRouter: FC = () => {
|
const MainRouter: FC = () => {
|
||||||
return (
|
return (
|
||||||
@@ -92,6 +94,9 @@ const MainRouter: FC = () => {
|
|||||||
<Route path={Paths.convertToOrder} element={<ConvertToOrders />} />
|
<Route path={Paths.convertToOrder} element={<ConvertToOrders />} />
|
||||||
<Route path={Paths.orderPrint + ':id'} element={<OrderPrint />} />
|
<Route path={Paths.orderPrint + ':id'} element={<OrderPrint />} />
|
||||||
|
|
||||||
|
<Route path={Paths.tickets.list} element={<TicketList />} />
|
||||||
|
<Route path={Paths.tickets.detail + ":id"} element={<TicketDetail />} />
|
||||||
|
|
||||||
<Route path={Paths.print.list} element={<SectionList />} />
|
<Route path={Paths.print.list} element={<SectionList />} />
|
||||||
<Route path={Paths.print.create} element={<CreateSection />} />
|
<Route path={Paths.print.create} element={<CreateSection />} />
|
||||||
<Route path={Paths.print.update + ":id"} element={<UpdateSection />} />
|
<Route path={Paths.print.update + ":id"} element={<UpdateSection />} />
|
||||||
|
|||||||
+18
-9
@@ -6,12 +6,14 @@ import {
|
|||||||
Element3,
|
Element3,
|
||||||
ElementEqual,
|
ElementEqual,
|
||||||
Home2,
|
Home2,
|
||||||
|
Logout,
|
||||||
Messages3,
|
Messages3,
|
||||||
MessageText,
|
MessageText,
|
||||||
NotificationStatus,
|
NotificationStatus,
|
||||||
People,
|
People,
|
||||||
Printer,
|
Printer,
|
||||||
Receipt21,
|
Receipt21,
|
||||||
|
Sms,
|
||||||
Teacher,
|
Teacher,
|
||||||
User,
|
User,
|
||||||
} from 'iconsax-react';
|
} from 'iconsax-react';
|
||||||
@@ -108,6 +110,13 @@ const SideBar: FC = () => {
|
|||||||
link={Paths.print.list}
|
link={Paths.print.list}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SideBarItem
|
||||||
|
icon={<Sms size={iconSizeSideBar} color="#4F5260" />}
|
||||||
|
title={'تیکت ها'}
|
||||||
|
isActive={isActive('tickets')}
|
||||||
|
link={Paths.tickets.list}
|
||||||
|
/>
|
||||||
|
|
||||||
<SideBarItem
|
<SideBarItem
|
||||||
icon={<User size={iconSizeSideBar} color="#4F5260" />}
|
icon={<User size={iconSizeSideBar} color="#4F5260" />}
|
||||||
title={'ادمین'}
|
title={'ادمین'}
|
||||||
@@ -161,15 +170,15 @@ const SideBar: FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* <div className='text-xs text-[#8C90A3]'>
|
<div className='text-xs text-[#8C90A3]'>
|
||||||
<SideBarItem
|
<SideBarItem
|
||||||
icon={<Logout variant={isActive('logout') ? 'Bold' : 'Outline'} color={isActive('logout') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
|
icon={<Logout variant={isActive('logout') ? 'Bold' : 'Outline'} color={isActive('logout') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
|
||||||
title={t('sidebar.logout')}
|
title={t('sidebar.logout')}
|
||||||
isActive={isActive('logout')}
|
isActive={isActive('logout')}
|
||||||
link={`#`}
|
link={`#`}
|
||||||
isLogout
|
isLogout
|
||||||
/>
|
/>
|
||||||
</div> */}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user