canina/frontend/admin-panel/src/pages/Tickets.tsx

470 lines
20 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 { 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>
);
}