470 lines
20 KiB
TypeScript
470 lines
20 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
||
import {
|
||
MessageSquare,
|
||
Search,
|
||
Filter,
|
||
Stethoscope,
|
||
FileText,
|
||
User,
|
||
Clock,
|
||
CheckCircle2,
|
||
AlertCircle,
|
||
XCircle,
|
||
Send,
|
||
RefreshCw,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
ShieldCheck,
|
||
Building2,
|
||
Calendar,
|
||
} from 'lucide-react';
|
||
import api from '../services/api';
|
||
import { toast } from 'react-hot-toast';
|
||
import Button from '../components/ui/Button';
|
||
import Modal from '../components/ui/Modal';
|
||
|
||
interface TicketMessage {
|
||
id: string;
|
||
senderId?: string;
|
||
senderRole: 'USER' | 'ADMIN' | 'VET_DOCTOR';
|
||
senderName: string;
|
||
message: string;
|
||
attachment?: string;
|
||
createdAt: string;
|
||
}
|
||
|
||
interface Ticket {
|
||
id: string;
|
||
ticketNumber: string;
|
||
subject: string;
|
||
category: 'VET_CONSULTATION' | 'ORDER_SUPPORT' | 'PRODUCT_INQUIRY' | 'GENERAL';
|
||
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
|
||
status: 'OPEN' | 'IN_PROGRESS' | 'ANSWERED' | 'CLOSED';
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
user: {
|
||
id: string;
|
||
firstName?: string;
|
||
lastName?: string;
|
||
mobile: string;
|
||
};
|
||
pet?: {
|
||
id: string;
|
||
name: string;
|
||
type: string;
|
||
breed: string;
|
||
};
|
||
messages: TicketMessage[];
|
||
}
|
||
|
||
export default function Tickets() {
|
||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [total, setTotal] = useState(0);
|
||
const [page, setPage] = useState(1);
|
||
const [statusFilter, setStatusFilter] = useState<string>('ALL');
|
||
const [categoryFilter, setCategoryFilter] = useState<string>('ALL');
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
|
||
// Detail / Reply Modal
|
||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||
const [fetchingDetail, setFetchingDetail] = useState(false);
|
||
const [replyMessage, setReplyMessage] = useState('');
|
||
const [replyStatus, setReplyStatus] = useState<string>('ANSWERED');
|
||
const [sendingReply, setSendingReply] = useState(false);
|
||
|
||
const fetchTickets = useCallback(async () => {
|
||
try {
|
||
setLoading(true);
|
||
const params: Record<string, any> = { page, limit: 15 };
|
||
if (statusFilter !== 'ALL') params.status = statusFilter;
|
||
if (categoryFilter !== 'ALL') params.category = categoryFilter;
|
||
if (searchQuery.trim()) params.search = searchQuery.trim();
|
||
|
||
const res = await api.get('/tickets/admin/all', { params });
|
||
setTickets(res.data?.data || []);
|
||
setTotal(res.data?.meta?.total || 0);
|
||
} catch {
|
||
toast.error('خطا در دریافت لیست تیکتها');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [page, statusFilter, categoryFilter, searchQuery]);
|
||
|
||
useEffect(() => {
|
||
fetchTickets();
|
||
}, [fetchTickets]);
|
||
|
||
const handleOpenTicket = async (ticketId: string) => {
|
||
try {
|
||
setFetchingDetail(true);
|
||
const res = await api.get(`/tickets/${ticketId}`);
|
||
setSelectedTicket(res.data?.data || null);
|
||
setReplyStatus(res.data?.data?.status === 'OPEN' ? 'ANSWERED' : res.data?.data?.status || 'ANSWERED');
|
||
} catch {
|
||
toast.error('خطا در بارگذاری جزئیات تیکت');
|
||
} finally {
|
||
setFetchingDetail(false);
|
||
}
|
||
};
|
||
|
||
const handleSendReply = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!selectedTicket || !replyMessage.trim()) return;
|
||
|
||
try {
|
||
setSendingReply(true);
|
||
await api.post(`/tickets/${selectedTicket.id}/messages`, {
|
||
message: replyMessage.trim(),
|
||
status: replyStatus,
|
||
});
|
||
toast.success('پاسخ با موفقیت برای کاربر ارسال شد');
|
||
setReplyMessage('');
|
||
// Reload current ticket
|
||
handleOpenTicket(selectedTicket.id);
|
||
fetchTickets();
|
||
} catch (err: any) {
|
||
toast.error(err.response?.data?.message || 'خطا در ارسال پاسخ');
|
||
} finally {
|
||
setSendingReply(false);
|
||
}
|
||
};
|
||
|
||
const handleUpdateStatus = async (status: string) => {
|
||
if (!selectedTicket) return;
|
||
try {
|
||
await api.put(`/tickets/admin/${selectedTicket.id}/status`, { status });
|
||
toast.success('وضعیت تیکت تغییر یافت');
|
||
setSelectedTicket({ ...selectedTicket, status: status as any });
|
||
fetchTickets();
|
||
} catch {
|
||
toast.error('خطا در تغییر وضعیت تیکت');
|
||
}
|
||
};
|
||
|
||
const getCategoryBadge = (cat: string) => {
|
||
switch (cat) {
|
||
case 'VET_CONSULTATION':
|
||
return { label: 'مشاوره دامپزشک', bg: 'bg-emerald-50 text-emerald-700 border-emerald-200', icon: Stethoscope };
|
||
case 'ORDER_SUPPORT':
|
||
return { label: 'پشتیبانی سفارش', bg: 'bg-blue-50 text-blue-700 border-blue-200', icon: FileText };
|
||
case 'PRODUCT_INQUIRY':
|
||
return { label: 'سوال محصولی / دارویی', bg: 'bg-purple-50 text-purple-700 border-purple-200', icon: Building2 };
|
||
default:
|
||
return { label: 'عمومی', bg: 'bg-gray-50 text-gray-700 border-gray-200', icon: MessageSquare };
|
||
}
|
||
};
|
||
|
||
const getStatusBadge = (status: string) => {
|
||
switch (status) {
|
||
case 'OPEN':
|
||
return { label: 'در انتظار پاسخ', bg: 'bg-amber-50 text-amber-700 border-amber-200' };
|
||
case 'ANSWERED':
|
||
return { label: 'پاسخ داده شد', bg: 'bg-emerald-50 text-emerald-700 border-emerald-200' };
|
||
case 'IN_PROGRESS':
|
||
return { label: 'در حال بررسی', bg: 'bg-blue-50 text-blue-700 border-blue-200' };
|
||
case 'CLOSED':
|
||
return { label: 'بسته شده', bg: 'bg-gray-100 text-gray-600 border-gray-200' };
|
||
default:
|
||
return { label: status, bg: 'bg-gray-50 text-gray-600 border-gray-200' };
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Header */}
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||
<div>
|
||
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
|
||
<MessageSquare className="w-7 h-7 text-canina-blue" />
|
||
پشتیبانی، تیکتینگ و مشاوره دامپزشکی
|
||
</h2>
|
||
<p className="text-gray-500 font-medium mt-1">
|
||
مشاهده و پاسخدهی به تیکتهای پزشکی، مشاورهای و پشتیبانی مشتریان کنینا
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => fetchTickets()}
|
||
className="bg-gray-100 hover:bg-gray-200 text-gray-800 font-bold px-4 py-2.5 rounded-xl text-xs flex items-center gap-2 transition-colors self-start cursor-pointer"
|
||
>
|
||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||
<span>بروزرسانی لیست</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Filters & Search */}
|
||
<div className="bg-white p-4 rounded-2xl border border-gray-200 shadow-sm space-y-4">
|
||
<div className="flex flex-col sm:flex-row gap-3">
|
||
<div className="flex-1 relative">
|
||
<Search className="w-4 h-4 text-gray-400 absolute right-3.5 top-1/2 -translate-y-1/2" />
|
||
<input
|
||
type="text"
|
||
placeholder="جستجو در موضوع، شماره تیکت، نام کاربر یا شماره موبایل..."
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
className="w-full pl-4 pr-10 py-2.5 rounded-xl border border-gray-200 text-xs font-medium focus:border-canina-blue outline-none"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex gap-2">
|
||
<select
|
||
value={statusFilter}
|
||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||
className="px-3 py-2.5 rounded-xl border border-gray-200 text-xs font-bold bg-white focus:border-canina-blue outline-none"
|
||
>
|
||
<option value="ALL">همه وضعیتها</option>
|
||
<option value="OPEN">در انتظار پاسخ</option>
|
||
<option value="IN_PROGRESS">در حال بررسی</option>
|
||
<option value="ANSWERED">پاسخ داده شده</option>
|
||
<option value="CLOSED">بسته شده</option>
|
||
</select>
|
||
|
||
<select
|
||
value={categoryFilter}
|
||
onChange={(e) => { setCategoryFilter(e.target.value); setPage(1); }}
|
||
className="px-3 py-2.5 rounded-xl border border-gray-200 text-xs font-bold bg-white focus:border-canina-blue outline-none"
|
||
>
|
||
<option value="ALL">همه دستهبندیها</option>
|
||
<option value="VET_CONSULTATION">مشاوره دامپزشک</option>
|
||
<option value="ORDER_SUPPORT">پشتیبانی سفارش</option>
|
||
<option value="PRODUCT_INQUIRY">سوال دارویی / محصولی</option>
|
||
<option value="GENERAL">عمومی</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tickets Table */}
|
||
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-right text-xs">
|
||
<thead className="bg-gray-50 border-b border-gray-200 text-gray-500 font-bold">
|
||
<tr>
|
||
<th className="p-4">شماره تیکت</th>
|
||
<th className="p-4">کاربر / صاحب پت</th>
|
||
<th className="p-4">پت مرتبط</th>
|
||
<th className="p-4">موضوع و دستهبندی</th>
|
||
<th className="p-4">وضعیت</th>
|
||
<th className="p-4">آخرین بروزرسانی</th>
|
||
<th className="p-4 text-center">عملیات</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100">
|
||
{loading && tickets.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={7} className="text-center py-12 text-gray-400 font-medium">
|
||
در حال دریافت تیکتها...
|
||
</td>
|
||
</tr>
|
||
) : tickets.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={7} className="text-center py-12 text-gray-400 font-medium">
|
||
تیکتی مطابق با فیلترهای انتخابی یافت نشد.
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
tickets.map((t) => {
|
||
const cat = getCategoryBadge(t.category);
|
||
const st = getStatusBadge(t.status);
|
||
const userName = `${t.user?.firstName || ''} ${t.user?.lastName || ''}`.trim() || 'بدون نام';
|
||
|
||
return (
|
||
<tr key={t.id} className="hover:bg-gray-50/80 transition-colors">
|
||
<td className="p-4 font-mono font-bold text-canina-blue">
|
||
{t.ticketNumber}
|
||
</td>
|
||
<td className="p-4">
|
||
<div className="font-bold text-gray-900">{userName}</div>
|
||
<div className="text-gray-400 font-mono text-[11px] mt-0.5">{t.user?.mobile}</div>
|
||
</td>
|
||
<td className="p-4">
|
||
{t.pet ? (
|
||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-purple-50 text-purple-700 rounded-lg text-[11px] font-bold border border-purple-100">
|
||
🐾 {t.pet.name} ({t.pet.breed || t.pet.type})
|
||
</span>
|
||
) : (
|
||
<span className="text-gray-400 font-medium">-</span>
|
||
)}
|
||
</td>
|
||
<td className="p-4">
|
||
<div className="font-bold text-gray-900 max-w-xs truncate">{t.subject}</div>
|
||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 mt-1 rounded text-[10px] font-bold border ${cat.bg}`}>
|
||
<cat.icon className="w-3 h-3" />
|
||
{cat.label}
|
||
</span>
|
||
</td>
|
||
<td className="p-4">
|
||
<span className={`px-2.5 py-1 rounded-full font-bold text-[11px] border ${st.bg}`}>
|
||
{st.label}
|
||
</span>
|
||
</td>
|
||
<td className="p-4 text-gray-500 font-mono text-[11px]">
|
||
{new Date(t.updatedAt).toLocaleDateString('fa-IR')}
|
||
</td>
|
||
<td className="p-4 text-center">
|
||
<button
|
||
onClick={() => handleOpenTicket(t.id)}
|
||
className="px-3 py-1.5 bg-canina-blue text-white rounded-lg font-bold hover:bg-canina-dark transition-colors cursor-pointer"
|
||
>
|
||
مشاهده و پاسخ
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
<div className="p-4 border-t border-gray-100 flex items-center justify-between text-xs text-gray-500 font-medium">
|
||
<div>تعداد کل تیکتها: {total}</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
disabled={page <= 1}
|
||
onClick={() => setPage(page - 1)}
|
||
className="p-2 rounded-lg border border-gray-200 hover:bg-gray-50 disabled:opacity-40"
|
||
>
|
||
<ChevronRight className="w-4 h-4" />
|
||
</button>
|
||
<span>صفحه {page}</span>
|
||
<button
|
||
disabled={tickets.length < 15}
|
||
onClick={() => setPage(page + 1)}
|
||
className="p-2 rounded-lg border border-gray-200 hover:bg-gray-50 disabled:opacity-40"
|
||
>
|
||
<ChevronLeft className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Ticket Details & Admin Reply Modal */}
|
||
{selectedTicket && (
|
||
<Modal
|
||
isOpen={!!selectedTicket}
|
||
onClose={() => setSelectedTicket(null)}
|
||
title={`تیکت ${selectedTicket.ticketNumber}: ${selectedTicket.subject}`}
|
||
icon={MessageSquare}
|
||
maxWidth="3xl"
|
||
footer={
|
||
<div className="flex items-center justify-end gap-2.5 w-full">
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
type="button"
|
||
onClick={() => setSelectedTicket(null)}
|
||
>
|
||
بستن
|
||
</Button>
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
type="submit"
|
||
form="ticketReplyForm"
|
||
startIcon={Send}
|
||
isLoading={sendingReply}
|
||
disabled={!replyMessage.trim()}
|
||
>
|
||
ارسال پاسخ به کاربر
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="space-y-5 text-right font-vazir">
|
||
{/* Header meta */}
|
||
<div className="p-3 bg-gray-50 rounded-2xl border border-gray-100 flex flex-wrap items-center justify-between gap-3">
|
||
<div className="text-xs text-gray-600 font-medium">
|
||
<span>کاربر: {selectedTicket.user?.firstName} {selectedTicket.user?.lastName} ({selectedTicket.user?.mobile})</span>
|
||
{selectedTicket.pet && (
|
||
<span className="text-purple-600 font-bold block sm:inline sm:mr-2">🐾 {selectedTicket.pet.name} ({selectedTicket.pet.breed || selectedTicket.pet.type})</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs font-bold text-gray-500">تغییر وضعیت:</span>
|
||
<select
|
||
value={selectedTicket.status}
|
||
onChange={(e) => handleUpdateStatus(e.target.value)}
|
||
className="px-2.5 py-1.5 border border-gray-200 rounded-xl text-xs font-bold bg-white focus:ring-2 focus:ring-purple-500 outline-none"
|
||
>
|
||
<option value="OPEN">در انتظار پاسخ</option>
|
||
<option value="IN_PROGRESS">در حال بررسی</option>
|
||
<option value="ANSWERED">پاسخ داده شد</option>
|
||
<option value="CLOSED">بسته شده</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Messages Chat Scroll */}
|
||
<div className="overflow-y-auto space-y-3.5 p-1 max-h-[340px]">
|
||
{(selectedTicket.messages || []).map((msg) => {
|
||
const isAdminOrVet = msg.senderRole === 'ADMIN' || msg.senderRole === 'VET_DOCTOR';
|
||
return (
|
||
<div
|
||
key={msg.id}
|
||
className={`p-4 rounded-2xl max-w-[85%] text-xs font-medium leading-relaxed ${
|
||
isAdminOrVet
|
||
? 'bg-purple-50 border border-purple-200 text-purple-950 mr-auto'
|
||
: 'bg-blue-50 border border-blue-200 text-blue-950 ml-auto'
|
||
}`}
|
||
>
|
||
<div className="flex items-center justify-between gap-4 mb-1.5 pb-1 border-b border-black/5">
|
||
<span className="font-black text-[11px] flex items-center gap-1.5">
|
||
{isAdminOrVet ? (
|
||
<>
|
||
<Stethoscope className="w-3.5 h-3.5 text-purple-600" />
|
||
<span className="text-purple-700">{msg.senderName} (تیم پزشکی / ادمین)</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<User className="w-3.5 h-3.5 text-blue-600" />
|
||
<span className="text-blue-700">{msg.senderName} (مشتری)</span>
|
||
</>
|
||
)}
|
||
</span>
|
||
<span className="text-[10px] text-gray-400 font-normal font-mono">
|
||
{new Date(msg.createdAt).toLocaleString('fa-IR')}
|
||
</span>
|
||
</div>
|
||
<p className="whitespace-pre-wrap">{msg.message}</p>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Admin Response Composer */}
|
||
<form id="ticketReplyForm" onSubmit={handleSendReply} className="pt-3 border-t border-gray-100 space-y-3">
|
||
<div className="flex items-center justify-between text-xs">
|
||
<label className="font-bold text-gray-700">ارسال پاسخ دامپزشک / پشتیبانی:</label>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-gray-400">تغییر وضعیت به:</span>
|
||
<select
|
||
value={replyStatus}
|
||
onChange={(e) => setReplyStatus(e.target.value)}
|
||
className="px-2 py-1 bg-gray-50 border border-gray-200 rounded-lg text-xs font-bold"
|
||
>
|
||
<option value="ANSWERED">پاسخ داده شد</option>
|
||
<option value="IN_PROGRESS">در حال بررسی</option>
|
||
<option value="CLOSED">پاسخ و بستن تیکت</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<textarea
|
||
required
|
||
rows={3}
|
||
placeholder="متن پاسخ تخصصی به کاربر را وارد کنید..."
|
||
value={replyMessage}
|
||
onChange={(e) => setReplyMessage(e.target.value)}
|
||
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-xs font-medium focus:border-purple-500 focus:ring-2 focus:ring-purple-200 outline-none resize-none leading-relaxed"
|
||
/>
|
||
</form>
|
||
</div>
|
||
</Modal>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|