add dashboard tabs component
This commit is contained in:
229
components/dashboard/tickets.tsx
Normal file
229
components/dashboard/tickets.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user