add : Ticket
This commit is contained in:
@@ -1,20 +1,979 @@
|
||||
import { Suspense } from "react";
|
||||
import { DashboardClient } from "@/components/dashboard/dashboard-client";
|
||||
'use client';
|
||||
|
||||
function DashboardFallback() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f7f3ec] px-4 py-16" dir="rtl">
|
||||
<div className="mx-auto max-w-6xl rounded-[32px] border border-[#ebe4d8] bg-white px-8 py-20 text-center text-[#6e675b] shadow-[0_20px_50px_rgba(48,40,28,0.06)]">
|
||||
در حال بارگذاری پیشخوان...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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";
|
||||
|
||||
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 (
|
||||
<Suspense fallback={<DashboardFallback />}>
|
||||
<DashboardClient />
|
||||
</Suspense>
|
||||
<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">
|
||||
<div className="bg-white shadow-xl rounded-2xl p-10 max-w-md w-full text-center border border-gray-100">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="bg-red-100 p-4 rounded-full">
|
||||
<ShieldX className="w-10 h-10 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-2">دسترسی غیرمجاز</h1>
|
||||
<p className="text-gray-500 text-sm leading-relaxed mb-6">برای مشاهده داشبورد باید ابتدا وارد حساب کاربری خود شوید.</p>
|
||||
<button onClick={() => router.push("/")} className="flex mx-auto cursor-pointer items-center gap-2 bg-gray-900 text-white px-4 py-2 rounded-lg text-sm hover:bg-black transition">
|
||||
<Home className="w-4 h-4" />
|
||||
بازگشت به صفحه اصلی
|
||||
</button>
|
||||
</div>
|
||||
</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 font-mono" 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));
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ export function DashboardClient() {
|
||||
if (!session || !selectedUserTicketId || !userReplyMessage.trim()) return;
|
||||
setUserReplySubmitting(true);
|
||||
try {
|
||||
setSelectedUserTicket(await sendUserTicketMessage(session.token, selectedUserTicketId, { message: userReplyMessage.trim() }));
|
||||
setSelectedUserTicket(await sendUserTicketMessage(session.token, selectedUserTicketId, userReplyMessage.trim()));
|
||||
setUserReplyMessage("");
|
||||
await Promise.all([
|
||||
loadUserTickets(session.token, { status: userTicketStatusFilter === "all" ? undefined : userTicketStatusFilter, page: userTicketPage, limit: 12 }),
|
||||
@@ -414,7 +414,7 @@ export function DashboardClient() {
|
||||
if (!session || !selectedAdminTicketId || !adminReplyMessage.trim()) return;
|
||||
setAdminReplySubmitting(true);
|
||||
try {
|
||||
setSelectedAdminTicket(await sendAdminTicketMessage(session.token, selectedAdminTicketId, { message: adminReplyMessage.trim() }));
|
||||
setSelectedAdminTicket(await sendAdminTicketMessage(session.token, selectedAdminTicketId, adminReplyMessage.trim()));
|
||||
setAdminReplyMessage("");
|
||||
await Promise.all([
|
||||
loadAdminTickets(session.token, { status: adminStatusFilter || undefined, priority: adminPriorityFilter || undefined, search: deferredAdminSearch || undefined, page: adminTicketPage, limit: 12 }),
|
||||
|
||||
@@ -10,19 +10,19 @@ export interface TicketPagination {
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface TicketMessageSummary {
|
||||
id: string;
|
||||
senderType: "user" | "admin";
|
||||
message: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TicketAssignee {
|
||||
id: string;
|
||||
fullName: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface TicketListMessage {
|
||||
id: string;
|
||||
senderType: "user" | "admin";
|
||||
message: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TicketListItem {
|
||||
id: string;
|
||||
ticketNumber: string;
|
||||
@@ -33,7 +33,7 @@ export interface TicketListItem {
|
||||
isReadByUser: boolean;
|
||||
messagesCount: number;
|
||||
lastMessageAt: string | null;
|
||||
lastMessage: TicketMessageSummary | null;
|
||||
lastMessage: TicketListMessage | null;
|
||||
assignedAdmin: TicketAssignee | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -81,24 +81,17 @@ export interface UserTicketsQuery {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface CreateTicketPayload {
|
||||
subject: string;
|
||||
priority: TicketPriority;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SendTicketMessagePayload {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AdminTicketsQuery {
|
||||
status?: TicketStatus;
|
||||
export interface AdminTicketsQuery extends UserTicketsQuery {
|
||||
priority?: TicketPriority;
|
||||
search?: string;
|
||||
userId?: string;
|
||||
assignedAdminId?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface CreateTicketPayload {
|
||||
subject: string;
|
||||
priority: TicketPriority;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface UpdateAdminTicketPayload {
|
||||
@@ -107,14 +100,26 @@ export interface UpdateAdminTicketPayload {
|
||||
assignedAdminId?: string;
|
||||
}
|
||||
|
||||
function createAuthHeaders(token: string, contentType = false) {
|
||||
function authHeaders(token: string, withJson = false) {
|
||||
return {
|
||||
Accept: "*/*",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(contentType ? { "Content-Type": "application/json" } : {}),
|
||||
...(withJson ? { "Content-Type": "application/json" } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function withQuery<T extends object>(path: string, query: T) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
Object.entries(query as Record<string, string | number | undefined>).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== "") {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
return `${path}${params.toString() ? `?${params.toString()}` : ""}`;
|
||||
}
|
||||
|
||||
async function parseResponse<T>(response: Response): Promise<T> {
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
@@ -126,23 +131,10 @@ async function parseResponse<T>(response: Response): Promise<T> {
|
||||
return data as T;
|
||||
}
|
||||
|
||||
function withQuery(path: string, query: Record<string, string | number | undefined>) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== "") {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
return `${path}${params.toString() ? `?${params.toString()}` : ""}`;
|
||||
}
|
||||
|
||||
export async function getUserTickets(token: string, query: UserTicketsQuery = {}) {
|
||||
const response = await fetch(
|
||||
withQuery(`${API_BASE_URL}/users/me/tickets`, query),
|
||||
{ headers: createAuthHeaders(token) }
|
||||
);
|
||||
const response = await fetch(withQuery(`${API_BASE_URL}/users/me/tickets`, query), {
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
|
||||
return parseResponse<TicketListResponse>(response);
|
||||
}
|
||||
@@ -150,7 +142,7 @@ export async function getUserTickets(token: string, query: UserTicketsQuery = {}
|
||||
export async function createUserTicket(token: string, payload: CreateTicketPayload) {
|
||||
const response = await fetch(`${API_BASE_URL}/users/me/tickets`, {
|
||||
method: "POST",
|
||||
headers: createAuthHeaders(token, true),
|
||||
headers: authHeaders(token, true),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
@@ -159,17 +151,17 @@ export async function createUserTicket(token: string, payload: CreateTicketPaylo
|
||||
|
||||
export async function getUserTicketById(token: string, ticketId: string) {
|
||||
const response = await fetch(`${API_BASE_URL}/users/me/tickets/${ticketId}`, {
|
||||
headers: createAuthHeaders(token),
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
|
||||
return parseResponse<TicketDetail>(response);
|
||||
}
|
||||
|
||||
export async function sendUserTicketMessage(token: string, ticketId: string, payload: SendTicketMessagePayload) {
|
||||
export async function sendUserTicketMessage(token: string, ticketId: string, message: string) {
|
||||
const response = await fetch(`${API_BASE_URL}/users/me/tickets/${ticketId}/messages`, {
|
||||
method: "POST",
|
||||
headers: createAuthHeaders(token, true),
|
||||
body: JSON.stringify(payload),
|
||||
headers: authHeaders(token, true),
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
|
||||
return parseResponse<TicketDetail>(response);
|
||||
@@ -178,24 +170,23 @@ export async function sendUserTicketMessage(token: string, ticketId: string, pay
|
||||
export async function closeUserTicket(token: string, ticketId: string) {
|
||||
const response = await fetch(`${API_BASE_URL}/users/me/tickets/${ticketId}/close`, {
|
||||
method: "PATCH",
|
||||
headers: createAuthHeaders(token),
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
|
||||
return parseResponse<TicketDetail>(response);
|
||||
}
|
||||
|
||||
export async function getAdminTickets(token: string, query: AdminTicketsQuery = {}) {
|
||||
const response = await fetch(
|
||||
withQuery(`${API_BASE_URL}/admin/tickets`, query),
|
||||
{ headers: createAuthHeaders(token) }
|
||||
);
|
||||
const response = await fetch(withQuery(`${API_BASE_URL}/admin/tickets`, query), {
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
|
||||
return parseResponse<TicketListResponse>(response);
|
||||
}
|
||||
|
||||
export async function getAdminTicketById(token: string, ticketId: string) {
|
||||
const response = await fetch(`${API_BASE_URL}/admin/tickets/${ticketId}`, {
|
||||
headers: createAuthHeaders(token),
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
|
||||
return parseResponse<TicketDetail>(response);
|
||||
@@ -204,18 +195,18 @@ export async function getAdminTicketById(token: string, ticketId: string) {
|
||||
export async function updateAdminTicket(token: string, ticketId: string, payload: UpdateAdminTicketPayload) {
|
||||
const response = await fetch(`${API_BASE_URL}/admin/tickets/${ticketId}`, {
|
||||
method: "PATCH",
|
||||
headers: createAuthHeaders(token, true),
|
||||
headers: authHeaders(token, true),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
return parseResponse<TicketDetail>(response);
|
||||
}
|
||||
|
||||
export async function sendAdminTicketMessage(token: string, ticketId: string, payload: SendTicketMessagePayload) {
|
||||
export async function sendAdminTicketMessage(token: string, ticketId: string, message: string) {
|
||||
const response = await fetch(`${API_BASE_URL}/admin/tickets/${ticketId}/messages`, {
|
||||
method: "POST",
|
||||
headers: createAuthHeaders(token, true),
|
||||
body: JSON.stringify(payload),
|
||||
headers: authHeaders(token, true),
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
|
||||
return parseResponse<TicketDetail>(response);
|
||||
|
||||
Reference in New Issue
Block a user