Files
parsshop/components/dashboard/tickets.tsx
2026-04-30 18:55:28 +03:30

230 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}