230 lines
14 KiB
TypeScript
230 lines
14 KiB
TypeScript
import React from 'react';
|
||
import { Plus } from 'lucide-react'; // فرض بر این است که از lucide-react استفاده میکنید
|
||
|
||
// TODO: کامپوننتهای کمکی و تایپهای زیر باید از فایلهای مربوطهشان ایمپورت شوند
|
||
// import TicketSummaryCard from './TicketSummaryCard';
|
||
// import FilterButton from './FilterButton';
|
||
// import LoadingCard from './LoadingCard';
|
||
// import ErrorCard from './ErrorCard';
|
||
// import EmptyCard from './EmptyCard';
|
||
// import StatusBadge from './StatusBadge';
|
||
// import PriorityBadge from './PriorityBadge';
|
||
// import Pager from './Pager';
|
||
// import TicketConversationCard from './TicketConversationCard';
|
||
// import { formatDateTime } from '../utils/dateHelpers';
|
||
|
||
import {
|
||
closeUserTicket,
|
||
createUserTicket,
|
||
getAdminTicketById,
|
||
getAdminTickets,
|
||
getUserTicketById,
|
||
getUserTickets,
|
||
sendAdminTicketMessage,
|
||
sendUserTicketMessage,
|
||
TicketDetail,
|
||
TicketListItem,
|
||
TicketPriority,
|
||
TicketStatus,
|
||
updateAdminTicket,
|
||
} from "@/public/src/services/tickets/api";
|
||
|
||
import { LoaderCircle,Send , Headphones } from 'lucide-react';
|
||
|
||
const statusLabels: Record<TicketStatus, string> = {
|
||
waiting_for_admin: "در انتظار پاسخ پشتیبانی",
|
||
waiting_for_user: "در انتظار پاسخ کاربر",
|
||
closed: "بسته شده",
|
||
};
|
||
|
||
|
||
const priorityLabels: Record<TicketPriority, string> = {
|
||
low: "کم",
|
||
normal: "عادی",
|
||
high: "زیاد",
|
||
urgent: "فوری",
|
||
};
|
||
|
||
|
||
function TicketConversationCard({ ticket, reply, onReplyChange, onReplySubmit, replyLoading, onCloseTicket, adminMode = false }: {
|
||
ticket: TicketDetail;
|
||
reply: string;
|
||
onReplyChange: (value: string) => void;
|
||
onReplySubmit: () => void;
|
||
replyLoading: boolean;
|
||
onCloseTicket?: () => void;
|
||
adminMode?: boolean;
|
||
}) {
|
||
const closed = ticket.status === "closed";
|
||
|
||
return (
|
||
<div className="bg-white p-6 rounded-2xl border border-gray-100 shadow-sm">
|
||
<div className="flex items-center justify-between gap-4 mb-6">
|
||
<div>
|
||
<div className="text-xs text-gray-400 mb-2">{ticket.ticketNumber}</div>
|
||
<h3 className="text-lg font-bold text-gray-800">{ticket.subject}</h3>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<StatusBadge status={ticket.status} />
|
||
<PriorityBadge priority={ticket.priority} />
|
||
</div>
|
||
</div>
|
||
{!adminMode && onCloseTicket && (
|
||
<div className="mb-4">
|
||
<button onClick={onCloseTicket} className="px-4 py-2 rounded-xl text-sm font-medium border border-red-200 text-red-500 hover:bg-red-50">بستن تیکت</button>
|
||
</div>
|
||
)}
|
||
<div className="space-y-4 max-h-[460px] overflow-y-auto mb-6">
|
||
{ticket.messages.map((message) => (
|
||
<div key={message.id} className={`flex ${message.senderType === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||
<div className={`max-w-[85%] rounded-2xl p-4 ${message.senderType === 'user' ? 'bg-[#1a2332] text-white' : 'bg-gray-50 text-gray-800 border border-gray-100'}`}>
|
||
<div className={`text-xs mb-2 ${message.senderType === 'user' ? 'text-gray-300' : 'text-gray-500'}`}>{message.sender.fullName} • {message.senderType === 'user' ? 'کاربر' : 'پشتیبانی'}</div>
|
||
<div className="text-sm leading-7">{message.message}</div>
|
||
<div className={`text-[11px] mt-3 ${message.senderType === 'user' ? 'text-gray-300' : 'text-gray-400'}`}>{formatDateTime(message.createdAt)}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="border-t border-gray-100 pt-4">
|
||
<textarea value={reply} onChange={(e) => onReplyChange(e.target.value)} disabled={closed} rows={4} placeholder={closed ? "این تیکت بسته شده و ارسال پیام غیرفعال است." : adminMode ? "پاسخ پشتیبانی را بنویسید..." : "پیام خود را بنویسید..."} className="w-full px-4 py-3 border border-gray-200 rounded-xl text-sm outline-none focus:border-[#ffb900] resize-none disabled:bg-gray-50 disabled:text-gray-400" />
|
||
<div className="flex items-center justify-between mt-3 gap-4">
|
||
<div className="text-xs text-gray-500">{closed ? "برای ادامه گفتگو باید تیکت جدید ثبت شود." : "آخرین بروزرسانی: " + formatDateTime(ticket.updatedAt)}</div>
|
||
<button onClick={onReplySubmit} disabled={closed || replyLoading || !reply.trim()} className="bg-[#1a2332] text-white px-5 py-2.5 rounded-xl text-sm font-medium hover:bg-[#ffb900] disabled:opacity-60 flex items-center gap-2">
|
||
{replyLoading ? <LoaderCircle size={16} className="animate-spin" /> : <Send size={16} />}
|
||
ارسال پیام
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
function TicketSummaryCard({ value, label, color }: { value: string; label: string; color: string }) {
|
||
return (
|
||
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm flex flex-col items-center justify-center">
|
||
<span className={`text-3xl font-bold mb-2 ${color}`}>{value}</span>
|
||
<div className="text-sm text-gray-500">{label}</div>
|
||
</div>
|
||
);
|
||
}
|
||
function FilterButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
||
return <button onClick={onClick} className={`${active ? 'bg-gray-800 text-white' : 'bg-white border border-gray-200 text-gray-600'} px-5 py-2 rounded-full text-sm font-medium transition-colors`}>{children}</button>;
|
||
}
|
||
|
||
|
||
function Pager({ page, totalPages, onPrev, onNext }: { page: number; totalPages: number; onPrev: () => void; onNext: () => void }) {
|
||
return <div className="flex items-center justify-between pt-2"><button onClick={onPrev} disabled={page <= 1} className="px-4 py-2 rounded-xl border border-gray-200 text-sm text-gray-600 disabled:opacity-50">قبلی</button><span className="text-xs text-gray-500">صفحه {page.toLocaleString('fa-IR')} از {totalPages.toLocaleString('fa-IR')}</span><button onClick={onNext} disabled={page >= totalPages} className="px-4 py-2 rounded-xl border border-gray-200 text-sm text-gray-600 disabled:opacity-50">بعدی</button></div>;
|
||
}
|
||
|
||
function LoadingCard({ text }: { text: string }) {
|
||
return <div className="bg-white p-8 rounded-2xl border border-gray-100 shadow-sm text-center text-gray-500 flex items-center justify-center gap-2"><LoaderCircle size={18} className="animate-spin" />{text}</div>;
|
||
}
|
||
|
||
function ErrorCard({ text }: { text: string }) {
|
||
return <div className="bg-red-50 text-red-600 p-5 rounded-2xl border border-red-100 text-sm">{text}</div>;
|
||
}
|
||
|
||
function EmptyCard({ title, description }: { title: string; description: string }) {
|
||
return <div className="bg-white p-8 rounded-2xl border border-gray-100 shadow-sm text-center"><div className="w-14 h-14 mx-auto rounded-full bg-gray-100 flex items-center justify-center text-gray-400 mb-4"><Headphones size={24} /></div><h3 className="text-lg font-bold text-gray-800 mb-2">{title}</h3><p className="text-sm text-gray-500 leading-7">{description}</p></div>;
|
||
}
|
||
|
||
function StatusBadge({ status }: { status: TicketStatus }) {
|
||
return <span className={`px-3 py-1 rounded-full text-xs font-medium ${status === "waiting_for_admin" ? 'bg-amber-100 text-amber-800' : status === "waiting_for_user" ? 'bg-blue-100 text-blue-700' : 'bg-gray-200 text-gray-700'}`}>{statusLabels[status]}</span>;
|
||
}
|
||
|
||
|
||
function PriorityBadge({ priority }: { priority: TicketPriority }) {
|
||
return <span className={`px-2.5 py-1 rounded-full text-[11px] font-medium ${priority === "low" ? 'bg-emerald-100 text-emerald-700' : priority === "normal" ? 'bg-gray-100 text-gray-700' : priority === "high" ? 'bg-orange-100 text-orange-700' : 'bg-red-100 text-red-700'}`}>{priorityLabels[priority]}</span>;
|
||
}
|
||
|
||
|
||
function formatDateTime(value?: string | null) {
|
||
if (!value) return "ثبت نشده";
|
||
return new Intl.DateTimeFormat("fa-IR", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
|
||
}
|
||
|
||
|
||
export default function TicketsTab(props: {
|
||
|
||
tickets: any[]; // به جای any میتوانید TicketListItem قرار دهید
|
||
loading: boolean;
|
||
error: string;
|
||
counts: any; // TicketCounts
|
||
selectedTicketId: string;
|
||
selectedTicket: any | null; // TicketDetail
|
||
detailLoading: boolean;
|
||
reply: string;
|
||
replyLoading: boolean;
|
||
statusFilter: "" | string; // TicketStatus
|
||
onSelectTicket: (id: string) => void;
|
||
onStatusFilterChange: (status: "" | string) => void;
|
||
onReplyChange: (value: string) => void;
|
||
onReplySubmit: () => void;
|
||
onCloseTicket: () => void;
|
||
onOpenCreateTicket: () => void;
|
||
page: number;
|
||
totalPages: number;
|
||
onPrevPage: () => void;
|
||
onNextPage: () => void;
|
||
}) {
|
||
return (
|
||
<div className="animate-in fade-in duration-500">
|
||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||
<h1 className="text-2xl font-bold text-gray-800 m-0">تیکتهای پشتیبانی</h1>
|
||
<button onClick={props.onOpenCreateTicket} className="bg-[#1a2332] hover:bg-[#ffb900] text-white px-5 py-2.5 rounded-xl flex items-center gap-2 transition-colors text-sm font-medium">
|
||
<Plus size={18} /> تیکت جدید
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||
<TicketSummaryCard value={props.counts.total.toLocaleString('fa-IR')} label="کل تیکتها" color="text-gray-800" />
|
||
<TicketSummaryCard value={props.counts.waiting_for_admin.toLocaleString('fa-IR')} label="در انتظار پشتیبانی" color="text-amber-600" />
|
||
<TicketSummaryCard value={props.counts.waiting_for_user.toLocaleString('fa-IR')} label="در انتظار شما" color="text-blue-700" />
|
||
<TicketSummaryCard value={props.counts.closed.toLocaleString('fa-IR')} label="بسته شده" color="text-gray-700" />
|
||
</div>
|
||
|
||
<div className="mb-6 overflow-x-auto pb-2">
|
||
<div className="flex gap-2 min-w-max">
|
||
<FilterButton active={props.statusFilter === ""} onClick={() => props.onStatusFilterChange("")}>همه تیکتها</FilterButton>
|
||
<FilterButton active={props.statusFilter === "waiting_for_admin"} onClick={() => props.onStatusFilterChange("waiting_for_admin")}>در انتظار پشتیبانی</FilterButton>
|
||
<FilterButton active={props.statusFilter === "waiting_for_user"} onClick={() => props.onStatusFilterChange("waiting_for_user")}>در انتظار شما</FilterButton>
|
||
<FilterButton active={props.statusFilter === "closed"} onClick={() => props.onStatusFilterChange("closed")}>بسته شده</FilterButton>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 xl:grid-cols-[420px_1fr] gap-6">
|
||
<div className="space-y-4">
|
||
{props.loading ? <LoadingCard text="در حال دریافت تیکتها..." /> : props.error ? <ErrorCard text={props.error} /> : props.tickets.length === 0 ? <EmptyCard title="تیکتی پیدا نشد" description="با فیلتر فعلی تیکتی وجود ندارد." /> : (
|
||
<>
|
||
{props.tickets.map((ticket) => (
|
||
<button key={ticket.id} onClick={() => props.onSelectTicket(ticket.id)} className={`w-full text-right bg-white p-5 rounded-2xl border shadow-sm transition ${props.selectedTicketId === ticket.id ? 'border-[#ffb900] shadow-md' : 'border-gray-100 hover:shadow-md'}`}>
|
||
<div className="flex items-start justify-between gap-3 mb-3">
|
||
<div>
|
||
<h3 className="text-base font-bold text-gray-800">{ticket.subject}</h3>
|
||
<div className="text-xs text-gray-400 mt-2">{ticket.ticketNumber}</div>
|
||
</div>
|
||
<StatusBadge status={ticket.status} />
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
||
<PriorityBadge priority={ticket.priority} />
|
||
<span>{formatDateTime(ticket.lastMessageAt || ticket.createdAt)}</span>
|
||
<span>{ticket.messagesCount} پیام</span>
|
||
</div>
|
||
<div className="mt-3 text-sm text-gray-600 line-clamp-2">{ticket.lastMessage?.message || "بدون پیام"}</div>
|
||
</button>
|
||
))}
|
||
<Pager page={props.page} totalPages={props.totalPages} onPrev={props.onPrevPage} onNext={props.onNextPage} />
|
||
</>
|
||
)}
|
||
</div>
|
||
<div>
|
||
{props.detailLoading ? <LoadingCard text="در حال دریافت جزئیات تیکت..." /> : !props.selectedTicket ? <EmptyCard title="یک تیکت را انتخاب کنید" description="برای دیدن جزئیات گفتگو، یکی از تیکتها را انتخاب کنید." /> : (
|
||
<TicketConversationCard ticket={props.selectedTicket} reply={props.reply} onReplyChange={props.onReplyChange} onReplySubmit={props.onReplySubmit} replyLoading={props.replyLoading} onCloseTicket={props.selectedTicket.status === "closed" ? undefined : props.onCloseTicket} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|