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

969 lines
58 KiB
TypeScript
Raw Permalink 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.
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import {
User, Crown, Home, ShoppingBag, Wallet, MapPin,
Headphones, IdCard, LogOut, TrendingUp,
Plus, Hash, Clock, AlertCircle, Eye, Reply,
CreditCard, Box, Shield, ShieldAlert, Mail, CheckCircle, ShieldX, CircleCheck, Search, Send, X, LoaderCircle
} from 'lucide-react';
import { useRouter } from "next/navigation";
import { logoutUser } from "@/public/src/services/auth/api";
import {
closeUserTicket,
createUserTicket,
getAdminTicketById,
getAdminTickets,
getUserTicketById,
getUserTickets,
sendAdminTicketMessage,
sendUserTicketMessage,
TicketDetail,
TicketListItem,
TicketPriority,
TicketStatus,
updateAdminTicket,
} from "@/public/src/services/tickets/api";
import NotLogin from '@/components/Notlogin';
const sampleOrders = [
{ id: "PR-10452", status: "در حال پردازش", statusColor: "amber", total: "500,000", regDate: "15 دی 1404", deliveryDate: "20 دی 1404", delivered: false },
{ id: "PR-10451", status: "تحویل شده", statusColor: "green", total: "120,000", regDate: "10 دی 1404", deliveryDate: "12 دی 1404", delivered: true },
];
const sampleAddresses = [
{ id: "addr1", text: "شیراز، بلوار معالی آباد، خیابان پزشکان، ساختمان پارس، واحد 4", receiver: "علی محمدی", phone: "09123456789", isDefault: true },
{ id: "addr2", text: "تهران، خیابان سعدی جنوبی، کوچه ناظم‌الاطباء، پلاک 20", receiver: "دفتر تهران", phone: "021-33900000", isDefault: false },
];
const statusLabels: Record<TicketStatus, string> = {
waiting_for_admin: "در انتظار پاسخ پشتیبانی",
waiting_for_user: "در انتظار پاسخ کاربر",
closed: "بسته شده",
};
const priorityLabels: Record<TicketPriority, string> = {
low: "کم",
normal: "عادی",
high: "زیاد",
urgent: "فوری",
};
type TicketCounts = {
total: number;
waiting_for_admin: number;
waiting_for_user: number;
closed: number;
};
export default function DashboardPage() {
const [activeTab, setActiveTab] = useState('dash');
const router = useRouter();
const [success, setSuccess] = useState<"" | "login" | "register">("");
const [authorized, setAuthorized] = useState<boolean | null>(null);
const [token, setToken] = useState("");
const [userInfo, setUserInfo] = useState({
fullName: "کاربر پارس شاپ",
username: "user",
phone: "شماره ثبت نشده",
role: "user" as "user" | "admin",
});
const [ticketCounts, setTicketCounts] = useState({
total: 0,
waiting_for_admin: 0,
waiting_for_user: 0,
closed: 0,
});
const [userTickets, setUserTickets] = useState<TicketListItem[]>([]);
const [userTicketsLoading, setUserTicketsLoading] = useState(false);
const [userTicketsError, setUserTicketsError] = useState("");
const [selectedUserTicketId, setSelectedUserTicketId] = useState("");
const [selectedUserTicket, setSelectedUserTicket] = useState<TicketDetail | null>(null);
const [ticketDetailLoading, setTicketDetailLoading] = useState(false);
const [ticketReply, setTicketReply] = useState("");
const [ticketReplyLoading, setTicketReplyLoading] = useState(false);
const [ticketStatusFilter, setTicketStatusFilter] = useState<"" | TicketStatus>("");
const [ticketPage, setTicketPage] = useState(1);
const [ticketTotalPages, setTicketTotalPages] = useState(1);
const [isCreateTicketOpen, setIsCreateTicketOpen] = useState(false);
const [createTicketLoading, setCreateTicketLoading] = useState(false);
const [createTicketError, setCreateTicketError] = useState("");
const [createTicketForm, setCreateTicketForm] = useState({
subject: "",
priority: "normal" as TicketPriority,
message: "",
});
const [adminTickets, setAdminTickets] = useState<TicketListItem[]>([]);
const [adminTicketsLoading, setAdminTicketsLoading] = useState(false);
const [adminTicketsError, setAdminTicketsError] = useState("");
const [adminStatusFilter, setAdminStatusFilter] = useState<"" | TicketStatus>("");
const [adminPriorityFilter, setAdminPriorityFilter] = useState<"" | TicketPriority>("");
const [adminSearch, setAdminSearch] = useState("");
const [adminPage, setAdminPage] = useState(1);
const [adminTotalPages, setAdminTotalPages] = useState(1);
const [selectedAdminTicketId, setSelectedAdminTicketId] = useState("");
const [selectedAdminTicket, setSelectedAdminTicket] = useState<TicketDetail | null>(null);
const [adminReply, setAdminReply] = useState("");
const [adminReplyLoading, setAdminReplyLoading] = useState(false);
const [adminUpdateLoading, setAdminUpdateLoading] = useState(false);
const [adminTicketForm, setAdminTicketForm] = useState({
status: "waiting_for_user" as TicketStatus,
priority: "normal" as TicketPriority,
assignedAdminId: "",
});
const ticketStatValue = useMemo(() => `${ticketCounts.total.toLocaleString('fa-IR')} مورد`, [ticketCounts.total]);
useEffect(() => {
const token = localStorage.getItem("accessToken");
const role = localStorage.getItem("role");
const params = new URLSearchParams(window.location.search);
const currentTab = params.get("tab");
const currentSuccess = params.get("success");
if (currentTab && ['dash', 'orders', 'wallet', 'address', 'tickets', 'profile'].includes(currentTab)) {
setActiveTab(currentTab);
}
if (currentSuccess === "login" || currentSuccess === "register") {
setSuccess(currentSuccess);
}
if (token && (role === "user" || role === "admin")) {
setToken(token);
setUserInfo({
fullName: localStorage.getItem("fullName") || localStorage.getItem("username") || "کاربر پارس شاپ",
username: localStorage.getItem("username") || "user",
phone: localStorage.getItem("phone") || "شماره ثبت نشده",
role: role,
});
setAuthorized(true);
} else {
setAuthorized(false);
}
}, []);
useEffect(() => {
if (!token) {
return;
}
loadTicketCounts();
}, [token, userInfo.role]);
useEffect(() => {
if (!token || activeTab !== 'tickets') {
return;
}
if (userInfo.role === "admin") {
loadAdminTickets();
return;
}
loadUserTickets();
}, [token, activeTab, userInfo.role, ticketStatusFilter, ticketPage, adminStatusFilter, adminPriorityFilter, adminSearch, adminPage]);
useEffect(() => {
if (!token) {
return;
}
if (userInfo.role === "admin") {
if (selectedAdminTicketId) {
loadAdminTicketDetail(selectedAdminTicketId);
}
return;
}
if (selectedUserTicketId) {
loadUserTicketDetail(selectedUserTicketId);
}
}, [token, userInfo.role, selectedUserTicketId, selectedAdminTicketId]);
const handleLogout = async () => {
const token = localStorage.getItem("accessToken");
try {
if (token) {
await logoutUser(token);
}
} catch (error) {
console.error("Logout failed:", error);
} finally {
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
localStorage.removeItem("username");
localStorage.removeItem("fullName");
localStorage.removeItem("phone");
localStorage.removeItem("role");
setAuthorized(false);
router.push("/");
}
};
const loadTicketCounts = async () => {
try {
if (userInfo.role === "admin") {
const [allRes, adminWaitRes, userWaitRes, closedRes] = await Promise.all([
getAdminTickets(token, { page: 1, limit: 1 }),
getAdminTickets(token, { status: "waiting_for_admin", page: 1, limit: 1 }),
getAdminTickets(token, { status: "waiting_for_user", page: 1, limit: 1 }),
getAdminTickets(token, { status: "closed", page: 1, limit: 1 }),
]);
setTicketCounts({
total: allRes.pagination.total,
waiting_for_admin: adminWaitRes.pagination.total,
waiting_for_user: userWaitRes.pagination.total,
closed: closedRes.pagination.total,
});
return;
}
const [allRes, adminWaitRes, userWaitRes, closedRes] = await Promise.all([
getUserTickets(token, { page: 1, limit: 1 }),
getUserTickets(token, { status: "waiting_for_admin", page: 1, limit: 1 }),
getUserTickets(token, { status: "waiting_for_user", page: 1, limit: 1 }),
getUserTickets(token, { status: "closed", page: 1, limit: 1 }),
]);
setTicketCounts({
total: allRes.pagination.total,
waiting_for_admin: adminWaitRes.pagination.total,
waiting_for_user: userWaitRes.pagination.total,
closed: closedRes.pagination.total,
});
} catch (error) {
console.error("Ticket count load failed:", error);
}
};
const loadUserTickets = async () => {
setUserTicketsLoading(true);
setUserTicketsError("");
try {
const response = await getUserTickets(token, {
status: ticketStatusFilter || undefined,
page: ticketPage,
limit: 10,
});
setUserTickets(response.items);
setTicketTotalPages(response.pagination.totalPages || 1);
if (!selectedUserTicketId && response.items[0]) {
setSelectedUserTicketId(response.items[0].id);
}
if (!response.items.length) {
setSelectedUserTicketId("");
setSelectedUserTicket(null);
}
} catch (error: any) {
setUserTicketsError(error?.message || "دریافت تیکت‌ها انجام نشد.");
} finally {
setUserTicketsLoading(false);
}
};
const loadUserTicketDetail = async (ticketId: string) => {
setTicketDetailLoading(true);
try {
const detail = await getUserTicketById(token, ticketId);
setSelectedUserTicket(detail);
} catch (error) {
console.error("User ticket detail failed:", error);
} finally {
setTicketDetailLoading(false);
}
};
const loadAdminTickets = async () => {
setAdminTicketsLoading(true);
setAdminTicketsError("");
try {
const response = await getAdminTickets(token, {
status: adminStatusFilter || undefined,
priority: adminPriorityFilter || undefined,
search: adminSearch || undefined,
page: adminPage,
limit: 10,
});
setAdminTickets(response.items);
setAdminTotalPages(response.pagination.totalPages || 1);
if (!selectedAdminTicketId && response.items[0]) {
setSelectedAdminTicketId(response.items[0].id);
}
if (!response.items.length) {
setSelectedAdminTicketId("");
setSelectedAdminTicket(null);
}
} catch (error: any) {
setAdminTicketsError(error?.message || "دریافت تیکت‌های ادمین انجام نشد.");
} finally {
setAdminTicketsLoading(false);
}
};
const loadAdminTicketDetail = async (ticketId: string) => {
setTicketDetailLoading(true);
try {
const detail = await getAdminTicketById(token, ticketId);
setSelectedAdminTicket(detail);
setAdminTicketForm({
status: detail.status,
priority: detail.priority,
assignedAdminId: detail.assignedAdmin?.id || "",
});
} catch (error) {
console.error("Admin ticket detail failed:", error);
} finally {
setTicketDetailLoading(false);
}
};
const handleCreateTicket = async () => {
if (!createTicketForm.subject.trim() || !createTicketForm.message.trim()) {
setCreateTicketError("موضوع و متن پیام را کامل کنید.");
return;
}
setCreateTicketLoading(true);
setCreateTicketError("");
try {
const detail = await createUserTicket(token, {
subject: createTicketForm.subject.trim(),
priority: createTicketForm.priority,
message: createTicketForm.message.trim(),
});
setIsCreateTicketOpen(false);
setCreateTicketForm({ subject: "", priority: "normal", message: "" });
setSelectedUserTicketId(detail.id);
setSelectedUserTicket(detail);
setTicketPage(1);
await Promise.all([loadUserTickets(), loadTicketCounts()]);
} catch (error: any) {
setCreateTicketError(error?.message || "ثبت تیکت انجام نشد.");
} finally {
setCreateTicketLoading(false);
}
};
const handleSendUserReply = async () => {
if (!selectedUserTicket || !ticketReply.trim()) return;
setTicketReplyLoading(true);
try {
const detail = await sendUserTicketMessage(token, selectedUserTicket.id, ticketReply.trim());
setSelectedUserTicket(detail);
setTicketReply("");
await Promise.all([loadUserTickets(), loadTicketCounts()]);
} catch (error) {
console.error("User reply failed:", error);
} finally {
setTicketReplyLoading(false);
}
};
const handleCloseTicket = async () => {
if (!selectedUserTicket) return;
try {
const detail = await closeUserTicket(token, selectedUserTicket.id);
setSelectedUserTicket(detail);
await Promise.all([loadUserTickets(), loadTicketCounts()]);
} catch (error) {
console.error("Close ticket failed:", error);
}
};
const handleAdminReply = async () => {
if (!selectedAdminTicket || !adminReply.trim()) return;
setAdminReplyLoading(true);
try {
const detail = await sendAdminTicketMessage(token, selectedAdminTicket.id, adminReply.trim());
setSelectedAdminTicket(detail);
setAdminReply("");
await Promise.all([loadAdminTickets(), loadTicketCounts()]);
} catch (error) {
console.error("Admin reply failed:", error);
} finally {
setAdminReplyLoading(false);
}
};
const handleAdminUpdate = async () => {
if (!selectedAdminTicket) return;
setAdminUpdateLoading(true);
try {
const detail = await updateAdminTicket(token, selectedAdminTicket.id, {
status: adminTicketForm.status,
priority: adminTicketForm.priority,
assignedAdminId: adminTicketForm.assignedAdminId.trim() || undefined,
});
setSelectedAdminTicket(detail);
await Promise.all([loadAdminTickets(), loadTicketCounts()]);
} catch (error) {
console.error("Admin update failed:", error);
} finally {
setAdminUpdateLoading(false);
}
};
const menuItems = [
{ id: 'dash', label: 'پیشخوان', icon: Home },
{ id: 'orders', label: 'سفارش‌های من', icon: ShoppingBag },
{ id: 'wallet', label: 'کیف پول', icon: Wallet },
{ id: 'address', label: 'آدرس‌ها', icon: MapPin },
{ id: 'tickets', label: 'تیکت پشتیبانی', icon: Headphones },
{ id: 'profile', label: 'مشخصات حساب', icon: IdCard },
];
if (authorized === null) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50" dir="rtl">
<p className="text-gray-600">در حال بررسی دسترسی...</p>
</div>
);
}
if (!authorized) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4" dir="rtl">
<NotLogin/>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-8" dir="rtl">
<div className="container mx-auto px-4 max-w-6xl">
{success === "login" && <div className="mb-4 flex items-center gap-2 p-3 bg-[#d2f6dca3] text-green-700 rounded-xl text-sm"><CheckCircle className="w-4 h-4 text-green-600" /><span>ورود با موفقیت انجام شد</span></div>}
{success === "register" && <div className="mb-4 flex items-center gap-2 p-3 bg-[#d2f6dca3] text-green-700 rounded-xl text-sm"><CheckCircle className="w-4 h-4 text-green-600" /><span>ثبت نام با موفقیت انجام شد</span></div>}
<div className="flex flex-col lg:flex-row gap-8">
<aside className="w-full lg:w-[280px] bg-white rounded-2xl shadow-sm flex-shrink-0 h-fit">
<div className="p-6 flex flex-col items-center border-b border-gray-100">
<div className="w-20 h-20 bg-gray-50 rounded-full flex items-center justify-center text-gray-400 mb-4">
<User size={32} strokeWidth={1.5} />
</div>
<div className="text-lg font-bold text-gray-800 mb-1">{userInfo.fullName}</div>
<div className="text-sm text-gray-500 mb-4 " dir="ltr">{userInfo.phone}</div>
<div className="w-full flex flex-col items-center">
<div className="inline-flex items-center gap-1.5 px-3 py-1 bg-yellow-100 text-yellow-700 rounded-full text-xs font-bold mb-3">
<Crown size={14} /> حساب فعال
</div>
<div className="w-full">
<div className="w-full h-1.5 bg-gray-100 rounded-full overflow-hidden mb-1.5">
<div className="h-full bg-yellow-400 rounded-full transition-all duration-500" style={{ width: '75%' }}></div>
</div>
<div className="text-[10px] text-gray-400 text-center">75٪ تا سطح بعدی</div>
</div>
</div>
</div>
<ul className="p-4 flex flex-col gap-1">
{menuItems.map((item) => (
<li key={item.id}>
<button onClick={() => setActiveTab(item.id)} className={`w-full cursor-pointer flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold transition-all duration-200 ${activeTab === item.id ? 'bg-[#1a2332] text-white shadow-md' : 'text-gray-600 hover:bg-gray-50 hover:text-[#ffb900]'}`}>
<item.icon size={18} className={activeTab === item.id ? 'text-white' : 'text-gray-400'} />
{item.label}
</button>
</li>
))}
<li className="mt-2 pt-2 border-t border-gray-100">
<button onClick={handleLogout} className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold text-red-500 hover:bg-red-50 transition-colors">
<LogOut size={18} /> خروج
</button>
</li>
</ul>
</aside>
<main className="flex-1 overflow-hidden">
{activeTab === 'dash' && (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
<h2 className="text-2xl font-bold text-gray-800 mb-6">خوش آمدید</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 mb-6">
<DashboardStat icon={<Wallet size={24} />} color="bg-blue-50 text-blue-600" label="موجودی کیف پول" value="1,500,000 تومان" />
<DashboardStat icon={<ShoppingBag size={24} />} color="bg-green-50 text-green-600" label="سفارشات جاری" value="2 مورد" />
<DashboardStat icon={<Headphones size={24} />} color="bg-orange-50 text-orange-600" label="تیکت‌ها" value={ticketStatValue} />
<DashboardStat icon={<TrendingUp size={24} />} color="bg-purple-50 text-purple-600" label="امتیاز وفاداری" value="2,340 امتیاز" />
</div>
</div>
)}
{activeTab === 'tickets' && (
userInfo.role === "admin" ? (
<AdminTicketsView
tickets={adminTickets}
loading={adminTicketsLoading}
error={adminTicketsError}
counts={ticketCounts}
statusFilter={adminStatusFilter}
priorityFilter={adminPriorityFilter}
search={adminSearch}
onStatusFilterChange={(value) => { setAdminStatusFilter(value); setAdminPage(1); }}
onPriorityFilterChange={(value) => { setAdminPriorityFilter(value); setAdminPage(1); }}
onSearchChange={(value) => { setAdminSearch(value); setAdminPage(1); }}
selectedTicketId={selectedAdminTicketId}
onSelectTicket={setSelectedAdminTicketId}
selectedTicket={selectedAdminTicket}
detailLoading={ticketDetailLoading}
reply={adminReply}
onReplyChange={setAdminReply}
onReplySubmit={handleAdminReply}
replyLoading={adminReplyLoading}
ticketForm={adminTicketForm}
onTicketFormChange={setAdminTicketForm}
onTicketUpdate={handleAdminUpdate}
updateLoading={adminUpdateLoading}
page={adminPage}
totalPages={adminTotalPages}
onPrevPage={() => setAdminPage((prev) => Math.max(1, prev - 1))}
onNextPage={() => setAdminPage((prev) => Math.min(adminTotalPages, prev + 1))}
/>
) : (
<UserTicketsView
tickets={userTickets}
loading={userTicketsLoading}
error={userTicketsError}
counts={ticketCounts}
statusFilter={ticketStatusFilter}
onStatusFilterChange={(value) => { setTicketStatusFilter(value); setTicketPage(1); }}
selectedTicketId={selectedUserTicketId}
onSelectTicket={setSelectedUserTicketId}
selectedTicket={selectedUserTicket}
detailLoading={ticketDetailLoading}
reply={ticketReply}
onReplyChange={setTicketReply}
onReplySubmit={handleSendUserReply}
replyLoading={ticketReplyLoading}
onCloseTicket={handleCloseTicket}
onOpenCreateTicket={() => setIsCreateTicketOpen(true)}
page={ticketPage}
totalPages={ticketTotalPages}
onPrevPage={() => setTicketPage((prev) => Math.max(1, prev - 1))}
onNextPage={() => setTicketPage((prev) => Math.min(ticketTotalPages, prev + 1))}
/>
)
)}
{activeTab === 'address' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{sampleAddresses.map((address) => (
<div key={address.id} className={`bg-white p-6 rounded-2xl border-2 ${address.isDefault ? 'border-[#ffd230]' : 'border-gray-200'}`}>
<div className="text-gray-800 leading-relaxed font-medium mb-4 text-sm sm:text-base">{address.text}</div>
<div className="flex flex-col gap-2 text-sm text-gray-500 mb-6">
<div className="flex items-center gap-2"><User size={16} /> گیرنده: {address.receiver}</div>
<div className="flex items-center gap-2"><Headphones size={16} /> تماس: {address.phone}</div>
</div>
</div>
))}
</div>
)}
{activeTab === 'orders' && (
<div className="space-y-4">
{sampleOrders.map((order) => (
<div key={order.id} className="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div className="p-6 flex items-center justify-between border-b border-gray-100">
<div>
<h3 className="text-lg font-bold text-gray-800">{order.id}</h3>
<p className="text-sm text-gray-500 mt-1">ثبت سفارش: {order.regDate}</p>
</div>
<span className={`px-3 py-1 rounded-full text-xs font-bold ${order.statusColor === 'amber' ? 'bg-amber-100 text-amber-700' : 'bg-green-100 text-green-700'}`}>{order.status}</span>
</div>
<div className="p-6 flex flex-col lg:flex-row gap-6">
<div className="w-full lg:w-64 bg-gray-50 rounded-xl p-4 flex flex-col gap-3 text-sm text-gray-600">
<div className="flex items-center gap-2"><Clock size={16} className="text-gray-400" /> تاریخ ثبت: {order.regDate}</div>
<div className="flex items-center gap-2">{order.delivered ? <CircleCheck size={16} className="text-green-500" /> : <Box size={16} className="text-blue-500" />}{order.delivered ? 'تحویل شده:' : 'تحویل تا:'} {order.deliveryDate}</div>
<div className="flex items-center gap-2 border-t border-gray-200 pt-3 mt-1 font-bold text-gray-800"><CreditCard size={16} className="text-gray-400" /> مبلغ کل: {order.total} تومان</div>
</div>
</div>
</div>
))}
</div>
)}
{activeTab === 'wallet' && (
<div className="bg-gray-800 text-white p-6 sm:p-8 rounded-2xl mb-8">
<span className="text-gray-300 text-sm mb-2 block">موجودی فعلی حساب شما:</span>
<div className="text-3xl sm:text-4xl font-bold text-yellow-400">1,500,000 <span className="text-lg font-normal text-white">تومان</span></div>
</div>
)}
{activeTab === 'profile' && (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<h2 className="text-xl font-bold text-gray-800 mb-6 border-b border-gray-100 pb-4">اطلاعات شخصی</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<ProfileField label="نام و نام خانوادگی" value={userInfo.fullName} />
<ProfileField label="شماره موبایل" value={userInfo.phone} />
<ProfileField label="نام کاربری" value={userInfo.username} />
<ProfileField label="نقش" value={userInfo.role} />
</div>
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between p-4 border border-gray-100 rounded-xl bg-gray-50">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-blue-50 text-blue-600 flex items-center justify-center"><Shield size={20} /></div>
<div><h3 className="font-bold text-gray-800 text-sm">تغییر رمز عبور</h3><p className="text-xs text-gray-500 mt-1">آخرین تغییر: 2 ماه پیش</p></div>
</div>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between p-4 border border-gray-100 rounded-xl bg-gray-50">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-amber-50 text-amber-600 flex items-center justify-center"><ShieldAlert size={20} /></div>
<div><h3 className="font-bold text-gray-800 text-sm">احراز هویت دو مرحلهای</h3><p className="text-xs mt-1 text-gray-500">وضعیت: <span className="text-red-500 font-bold">غیرفعال</span></p></div>
</div>
</div>
<div className="flex items-center justify-between p-4 border border-gray-100 rounded-xl bg-gray-50">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-green-50 text-green-600 flex items-center justify-center"><Mail size={20} /></div>
<div><h3 className="font-bold text-gray-800 text-sm">اطلاعرسانی ایمیلی</h3><p className="text-xs text-gray-500 mt-1">دریافت ایمیل برای هشدارهای امنیتی</p></div>
</div>
</div>
</div>
</div>
)}
</main>
</div>
</div>
{isCreateTicketOpen && (
<CreateTicketModal
form={createTicketForm}
onChange={setCreateTicketForm}
onClose={() => setIsCreateTicketOpen(false)}
onSubmit={handleCreateTicket}
loading={createTicketLoading}
error={createTicketError}
/>
)}
</div>
);
}
function DashboardStat({ icon, color, label, value }: { icon: React.ReactNode; color: string; label: string; value: string }) {
return (
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm flex flex-col items-center gap-4 hover:shadow-md transition-shadow">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${color}`}>{icon}</div>
<div className='flex flex-col items-center'>
<h4 className="text-xs text-gray-500 mb-1">{label}</h4>
<strong className="text-lg text-gray-800">{value}</strong>
</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 UserTicketsView(props: {
tickets: TicketListItem[];
loading: boolean;
error: string;
counts: TicketCounts;
statusFilter: "" | TicketStatus;
onStatusFilterChange: (value: "" | TicketStatus) => void;
selectedTicketId: string;
onSelectTicket: (id: string) => void;
selectedTicket: TicketDetail | null;
detailLoading: boolean;
reply: string;
onReplyChange: (value: string) => void;
onReplySubmit: () => void;
replyLoading: boolean;
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>
);
}
function AdminTicketsView(props: {
tickets: TicketListItem[];
loading: boolean;
error: string;
counts: TicketCounts;
statusFilter: "" | TicketStatus;
priorityFilter: "" | TicketPriority;
search: string;
onStatusFilterChange: (value: "" | TicketStatus) => void;
onPriorityFilterChange: (value: "" | TicketPriority) => void;
onSearchChange: (value: string) => void;
selectedTicketId: string;
onSelectTicket: (id: string) => void;
selectedTicket: TicketDetail | null;
detailLoading: boolean;
reply: string;
onReplyChange: (value: string) => void;
onReplySubmit: () => void;
replyLoading: boolean;
ticketForm: { status: TicketStatus; priority: TicketPriority; assignedAdminId: string };
onTicketFormChange: React.Dispatch<React.SetStateAction<{ status: TicketStatus; priority: TicketPriority; assignedAdminId: string }>>;
onTicketUpdate: () => void;
updateLoading: boolean;
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>
</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="bg-white p-4 rounded-2xl border border-gray-100 shadow-sm mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="relative">
<Search className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input value={props.search} onChange={(e) => props.onSearchChange(e.target.value)} placeholder="جستجو در موضوع یا شماره تیکت" className="w-full pr-10 px-4 py-3 border border-gray-200 rounded-xl text-sm outline-none focus:border-[#ffb900]" />
</div>
<select value={props.statusFilter} onChange={(e) => props.onStatusFilterChange(e.target.value as "" | TicketStatus)} className="px-4 py-3 border border-gray-200 rounded-xl text-sm outline-none focus:border-[#ffb900]">
<option value="">همه وضعیتها</option>
{Object.entries(statusLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
<select value={props.priorityFilter} onChange={(e) => props.onPriorityFilterChange(e.target.value as "" | TicketPriority)} className="px-4 py-3 border border-gray-200 rounded-xl text-sm outline-none focus:border-[#ffb900]">
<option value="">همه اولویتها</option>
{Object.entries(priorityLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
</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>{ticket.assignedAdmin?.fullName || "بدون ادمین مسئول"}</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 className="space-y-6">
{props.detailLoading ? <LoadingCard text="در حال دریافت جزئیات تیکت..." /> : !props.selectedTicket ? <EmptyCard title="یک تیکت را انتخاب کنید" description="برای دیدن پیام‌ها و تنظیمات، یک تیکت را از لیست انتخاب کنید." /> : (
<>
<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">{props.selectedTicket.ticketNumber}</div>
<h3 className="text-lg font-bold text-gray-800">{props.selectedTicket.subject}</h3>
</div>
<div className="flex gap-2 flex-wrap">
<StatusBadge status={props.selectedTicket.status} />
<PriorityBadge priority={props.selectedTicket.priority} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-4">
<select value={props.ticketForm.status} onChange={(e) => props.onTicketFormChange((prev) => ({ ...prev, status: e.target.value as TicketStatus }))} className="px-4 py-3 border border-gray-200 rounded-xl text-sm outline-none focus:border-[#ffb900]">
{Object.entries(statusLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
<select value={props.ticketForm.priority} onChange={(e) => props.onTicketFormChange((prev) => ({ ...prev, priority: e.target.value as TicketPriority }))} className="px-4 py-3 border border-gray-200 rounded-xl text-sm outline-none focus:border-[#ffb900]">
{Object.entries(priorityLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
<input value={props.ticketForm.assignedAdminId} onChange={(e) => props.onTicketFormChange((prev) => ({ ...prev, assignedAdminId: e.target.value }))} placeholder="assignedAdminId" className="px-4 py-3 border border-gray-200 rounded-xl text-sm outline-none focus:border-[#ffb900]" />
</div>
<button onClick={props.onTicketUpdate} disabled={props.updateLoading} className="bg-[#1a2332] text-white px-5 py-2.5 rounded-xl text-sm font-medium hover:bg-[#ffb900] disabled:opacity-60">
{props.updateLoading ? "در حال ذخیره..." : "ذخیره تنظیمات"}
</button>
</div>
<TicketConversationCard ticket={props.selectedTicket} reply={props.reply} onReplyChange={props.onReplyChange} onReplySubmit={props.onReplySubmit} replyLoading={props.replyLoading} adminMode />
</>
)}
</div>
</div>
</div>
);
}
function ProfileField({ label, value }: { label: string; value: string }) {
return (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{label}</label>
<div className="w-full px-4 py-2 border border-gray-100 bg-gray-50 text-gray-700 rounded-xl">{value}</div>
</div>
);
}
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 CreateTicketModal({ form, onChange, onClose, onSubmit, loading, error }: {
form: { subject: string; priority: TicketPriority; message: string };
onChange: React.Dispatch<React.SetStateAction<{ subject: string; priority: TicketPriority; message: string }>>;
onClose: () => void;
onSubmit: () => void;
loading: boolean;
error: string;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[#1A2332]/40 backdrop-blur-sm" dir="rtl">
<div className="absolute inset-0" onClick={onClose}></div>
<div className="relative w-full max-w-2xl bg-white rounded-3xl shadow-2xl p-8">
<button onClick={onClose} className="absolute left-5 top-4 text-gray-400 hover:text-gray-700"><X size={20} /></button>
<h3 className="text-xl font-bold text-gray-800 mb-2">ثبت تیکت جدید</h3>
<p className="text-sm text-gray-500 mb-6">موضوع و متن درخواستت را کامل وارد کن تا سریعتر بررسی شود.</p>
<div className="space-y-4">
<input value={form.subject} onChange={(e) => onChange((prev) => ({ ...prev, subject: e.target.value }))} placeholder="موضوع تیکت" className="w-full px-4 py-3 border border-gray-200 rounded-xl text-sm outline-none focus:border-[#ffb900]" />
<select value={form.priority} onChange={(e) => onChange((prev) => ({ ...prev, priority: e.target.value as TicketPriority }))} className="w-full px-4 py-3 border border-gray-200 rounded-xl text-sm outline-none focus:border-[#ffb900]">
{Object.entries(priorityLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
<textarea value={form.message} onChange={(e) => onChange((prev) => ({ ...prev, message: e.target.value }))} rows={6} placeholder="متن پیام" className="w-full px-4 py-3 border border-gray-200 rounded-xl text-sm outline-none focus:border-[#ffb900] resize-none" />
{error && <div className="text-sm text-red-500">{error}</div>}
<div className="flex gap-3">
<button onClick={onSubmit} disabled={loading} className="bg-[#1a2332] text-white px-5 py-3 rounded-xl text-sm font-medium hover:bg-[#ffb900] disabled:opacity-60">{loading ? "در حال ثبت..." : "ثبت تیکت"}</button>
<button onClick={onClose} className="border border-gray-200 text-gray-600 px-5 py-3 rounded-xl text-sm font-medium hover:bg-gray-50">بستن</button>
</div>
</div>
</div>
</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 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 formatDateTime(value?: string | null) {
if (!value) return "ثبت نشده";
return new Intl.DateTimeFormat("fa-IR", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
}