This commit is contained in:
hamid zarghami
2026-02-25 10:58:14 +03:30
parent 5c4923129a
commit 62257d093b
17 changed files with 1355 additions and 10 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# 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_REFRESH_TOKEN_NAME = 'negareh_art'
+1
View File
@@ -60,6 +60,7 @@ export const Paths = {
orderDetails: '/order/',
tickets: {
list: '/tickets',
detail: '/tickets/',
},
auth: {
login: '/auth/login',
+33
View File
@@ -69,4 +69,37 @@ export const fa = {
errors: {
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: "راهنما",
},
};
+134
View File
@@ -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
+174
View File
@@ -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;
+50
View File
@@ -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;
+54
View File
@@ -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;
+36
View File
@@ -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;
+83
View File
@@ -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;
+112
View File
@@ -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
+289
View File
@@ -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",
},
},
},
],
},
};
+51
View File
@@ -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");
};
+106
View File
@@ -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"],
});
},
});
};
+99
View File
@@ -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;
};
+109
View File
@@ -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;
};
+5
View File
@@ -49,6 +49,8 @@ import EditAdmin from "@/pages/admin/Update";
import RequestDetail from "@/pages/requests/Detail";
import ConvertToOrders from "@/pages/order/ConvertToOrders";
import OrderPrint from "@/pages/order/Print";
import TicketList from "@/pages/ticket/TicketList";
import TicketDetail from "@/pages/ticket/Detail";
const MainRouter: FC = () => {
return (
@@ -92,6 +94,9 @@ const MainRouter: FC = () => {
<Route path={Paths.convertToOrder} element={<ConvertToOrders />} />
<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.create} element={<CreateSection />} />
<Route path={Paths.print.update + ":id"} element={<UpdateSection />} />
+18 -9
View File
@@ -6,12 +6,14 @@ import {
Element3,
ElementEqual,
Home2,
Logout,
Messages3,
MessageText,
NotificationStatus,
People,
Printer,
Receipt21,
Sms,
Teacher,
User,
} from 'iconsax-react';
@@ -108,6 +110,13 @@ const SideBar: FC = () => {
link={Paths.print.list}
/>
<SideBarItem
icon={<Sms size={iconSizeSideBar} color="#4F5260" />}
title={'تیکت ها'}
isActive={isActive('tickets')}
link={Paths.tickets.list}
/>
<SideBarItem
icon={<User size={iconSizeSideBar} color="#4F5260" />}
title={'ادمین'}
@@ -161,15 +170,15 @@ const SideBar: FC = () => {
</Button>
</div>
{/* <div className='text-xs text-[#8C90A3]'>
<SideBarItem
icon={<Logout variant={isActive('logout') ? 'Bold' : 'Outline'} color={isActive('logout') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
title={t('sidebar.logout')}
isActive={isActive('logout')}
link={`#`}
isLogout
/>
</div> */}
<div className='text-xs text-[#8C90A3]'>
<SideBarItem
icon={<Logout variant={isActive('logout') ? 'Bold' : 'Outline'} color={isActive('logout') ? 'black' : '#8C90A3'} size={iconSizeSideBar} />}
title={t('sidebar.logout')}
isActive={isActive('logout')}
link={`#`}
isLogout
/>
</div>
</div>
</div>
</div>