feat(admin): overhaul orders management page with interactive modal, postal tracking code editor, inline status selector and invoice print

This commit is contained in:
parsa aghaei 2026-07-27 11:27:47 +03:30
parent d1802581eb
commit 24c361c4ff
5 changed files with 573 additions and 64 deletions

View File

@ -495,14 +495,53 @@
"sub_steps": [
{ "index": 1, "name": "unify_order_status_badges", "description": "Unify status labels across UserDashboard and OrderDetailsModal", "status": "done" }
]
},
{
"id": "EPIC-14-TASK-01",
"title": "Backend Admin Order Search, Includes & Tracking Number Support",
"description": "Expand getOrders query to include nested orderItems.product and user details, add multi-field search (trackingNumber, customer name, phone), and support updating trackingNumber in updateOrderStatus.",
"architectural_layer": "business_logic",
"assigned_role": "05_dev_backend",
"priority": "HIGH",
"status": "completed",
"max_files_allowed": 3,
"estimated_minutes": 20,
"dependency_task_ids": ["EPIC-13-TASK-01"],
"acceptance_criteria": [
"پشتیبانی کامل بک‌اند از جستجوی شماره پیگیری، نام کاربر و تلفن",
"امکان بروزرسانی همزمان کد پیگیری پستی و وضعیت سفارش توسط ادمین"
],
"sub_steps": [
{ "index": 1, "name": "expand_admin_orders_backend", "description": "Update getOrders includes and add trackingNumber support to updateOrderStatus", "status": "done" }
]
},
{
"id": "EPIC-14-TASK-02",
"title": "Admin Panel Order Details & Official Invoice Modal Overhaul",
"description": "Build comprehensive AdminOrderModal in Orders.tsx featuring customer details, status dropdown, tracking code editor, full product breakdown, financial summary, and print invoice button.",
"architectural_layer": "presentation_ui",
"assigned_role": "06_dev_frontend",
"priority": "HIGH",
"status": "completed",
"max_files_allowed": 3,
"estimated_minutes": 25,
"dependency_task_ids": ["EPIC-14-TASK-01"],
"acceptance_criteria": [
"عملکرد ۱۰۰٪ دکمه مشاهده فاکتور و باز شدن مدال جامع جزئیات سفارش ادمین",
"امکان ویرایش و ثبت کد پیگیری پستی و تغییر مستقیم وضعیت سفارش از جدول و مدال",
"چاپ مستقیم فاکتور رسمی از داخل پنل مدیریت"
],
"sub_steps": [
{ "index": 1, "name": "build_admin_order_modal", "description": "Build AdminOrderModal with tracking editor, status changer, and invoice printer in Orders.tsx", "status": "done" }
]
}
],
"metadata": {
"total": 25,
"completed": 25,
"total": 27,
"completed": 27,
"in_progress": 0,
"pending": 0,
"generated_at": "2026-07-26T21:00:00Z",
"decomposition_pass": 11
"generated_at": "2026-07-27T11:30:00Z",
"decomposition_pass": 12
}
}

View File

@ -7,12 +7,12 @@
"status": "COMPLETE",
"checkpoint": {
"active_agent": "01_auditor",
"current_ticket_id": "EPIC-13-TASK-01",
"current_ticket_id": "EPIC-14-TASK-02",
"sub_step": {
"index": 1,
"total": 1,
"name": "unify_order_status_badges",
"description": "Order status badge mismatch between history table and details modal resolved and 100% verified"
"name": "build_admin_order_modal",
"description": "Admin panel order management overhaul, modal, tracking editor, status selector, and print invoice complete and 100% verified"
}
},
"review_phase": {

View File

@ -114,9 +114,13 @@ export class AdminController {
@UseGuards(JwtAuthGuard)
@Put('orders/:id/status')
@ApiOperation({ summary: 'تغییر وضعیت سفارش' })
async updateOrderStatus(@Param('id') id: string, @Body('status') status: string) {
const order = await this.adminService.updateOrderStatus(id, status);
@ApiOperation({ summary: 'تغییر وضعیت و کد رهگیری سفارش' })
async updateOrderStatus(
@Param('id') id: string,
@Body('status') status: string,
@Body('trackingNumber') trackingNumber?: string
) {
const order = await this.adminService.updateOrderStatus(id, status, trackingNumber);
return {
success: true,
data: order

View File

@ -206,7 +206,13 @@ export class AdminService {
const where: any = {};
if (query.search) {
where.id = { contains: query.search };
where.OR = [
{ id: { contains: query.search } },
{ trackingNumber: { contains: query.search, mode: 'insensitive' } },
{ user: { firstName: { contains: query.search, mode: 'insensitive' } } },
{ user: { lastName: { contains: query.search, mode: 'insensitive' } } },
{ user: { phone: { contains: query.search } } }
];
}
if (query.status) {
where.status = query.status;
@ -214,8 +220,19 @@ export class AdminService {
const [data, total] = await Promise.all([
this.prisma.order.findMany({
where, skip, take: limit, orderBy: { createdAt: 'desc' },
include: { user: true, orderItems: true }
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
user: true,
orderItems: {
include: {
product: true
}
},
coupon: true
}
}),
this.prisma.order.count({ where })
]);
@ -223,10 +240,22 @@ export class AdminService {
return { data, meta: { total, page, limit, lastPage: Math.ceil(total / limit) } };
}
async updateOrderStatus(id: string, status: string) {
async updateOrderStatus(id: string, status: string, trackingNumber?: string) {
const dataToUpdate: any = { status };
if (trackingNumber !== undefined) {
dataToUpdate.trackingNumber = trackingNumber;
}
return this.prisma.order.update({
where: { id },
data: { status }
data: dataToUpdate,
include: {
user: true,
orderItems: {
include: {
product: true
}
}
}
});
}

View File

@ -1,16 +1,40 @@
import { useState, useEffect } from 'react';
import { ShoppingCart, Eye, Truck, Check, X, Search } from 'lucide-react';
import {
ShoppingCart,
Eye,
Search,
X,
Printer,
CheckCircle2,
Clock,
Truck,
XCircle,
MapPin,
User,
Phone,
Mail,
Package,
CreditCard,
Save,
Download
} from 'lucide-react';
import api from '../services/api';
import Skeleton from '../components/ui/Skeleton';
import Spinner from '../components/ui/Spinner';
import Pagination from '../components/ui/Pagination';
const statusStyles: Record<string, { label: string, color: string }> = {
pending: { label: 'در حال بررسی', color: 'bg-orange-100 text-orange-700' },
processing: { label: 'در حال پردازش', color: 'bg-yellow-100 text-yellow-700' },
shipped: { label: 'ارسال شده', color: 'bg-blue-100 text-blue-700' },
delivered: { label: 'تحویل داده شده', color: 'bg-green-100 text-green-700' },
cancelled: { label: 'لغو شده', color: 'bg-red-100 text-red-700' },
const statusStyles: Record<string, { label: string; color: string; icon: any }> = {
pending: { label: 'در حال بررسی', color: 'bg-orange-100 text-orange-700 border-orange-200', icon: Clock },
processing: { label: 'در حال پردازش', color: 'bg-amber-100 text-amber-800 border-amber-200', icon: Clock },
shipped: { label: 'ارسال شده', color: 'bg-blue-100 text-blue-700 border-blue-200', icon: Truck },
delivered: { label: 'تحویل داده شده', color: 'bg-green-100 text-green-700 border-green-200', icon: CheckCircle2 },
cancelled: { label: 'لغو شده', color: 'bg-red-100 text-red-700 border-red-200', icon: XCircle },
};
const toPersianDigits = (n: string | number): string => {
if (n === null || n === undefined) return '';
const farsiDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'];
return n.toString().replace(/\d/g, (x) => farsiDigits[parseInt(x)]);
};
export default function Orders() {
@ -24,6 +48,12 @@ export default function Orders() {
const [totalPages, setTotalPages] = useState(1);
const [processingId, setProcessingId] = useState<string | null>(null);
// Modal State
const [selectedOrder, setSelectedOrder] = useState<any | null>(null);
const [modalTrackingCode, setModalTrackingCode] = useState('');
const [modalStatus, setModalStatus] = useState('');
const [isSavingTracking, setIsSavingTracking] = useState(false);
const fetchOrders = async () => {
try {
setIsLoading(true);
@ -36,6 +66,14 @@ export default function Orders() {
if (response.data?.success) {
setOrders(response.data.data);
setTotalPages(response.data.meta?.lastPage || 1);
// Update selectedOrder if open
if (selectedOrder) {
const updated = response.data.data.find((o: any) => o.id === selectedOrder.id);
if (updated) {
setSelectedOrder(updated);
}
}
}
} catch (err) {
console.error('Failed to fetch orders', err);
@ -52,11 +90,14 @@ export default function Orders() {
return () => clearTimeout(delayDebounceFn);
}, [page, search, status]);
const handleUpdateStatus = async (id: string, status: string) => {
const handleUpdateStatus = async (id: string, newStatus: string, trackingNumber?: string) => {
try {
setProcessingId(id);
await api.put(`/admin/orders/${id}/status`, { status });
fetchOrders();
await api.put(`/admin/orders/${id}/status`, {
status: newStatus,
trackingNumber: trackingNumber !== undefined ? trackingNumber : undefined
});
await fetchOrders();
} catch (err) {
console.error('Failed to update status', err);
} finally {
@ -64,20 +105,201 @@ export default function Orders() {
}
};
const openOrderModal = (order: any) => {
setSelectedOrder(order);
setModalTrackingCode(order.trackingNumber || '');
setModalStatus(order.status || 'processing');
};
const handleSaveModalChanges = async () => {
if (!selectedOrder) return;
try {
setIsSavingTracking(true);
await api.put(`/admin/orders/${selectedOrder.id}/status`, {
status: modalStatus,
trackingNumber: modalTrackingCode
});
await fetchOrders();
} catch (err) {
console.error('Failed to save tracking number', err);
} finally {
setIsSavingTracking(false);
}
};
const handlePrintInvoice = (orderToPrint: any) => {
if (!orderToPrint) return;
const printWindow = window.open('', '_blank');
if (!printWindow) return;
const orderDate = new Date(orderToPrint.createdAt || orderToPrint.date);
const formattedDate = toPersianDigits(orderDate.toLocaleDateString("fa-IR"));
const formattedTime = toPersianDigits(orderDate.toLocaleTimeString("fa-IR", { hour: '2-digit', minute: '2-digit' }));
const trackingCode = orderToPrint.trackingNumber || orderToPrint.id.substring(0, 8);
const items = orderToPrint.orderItems || orderToPrint.items || [];
const itemsSubtotal = items.reduce((sum: number, item: any) => {
const price = Number(item.product?.priceValue || item.priceValue || 0);
return sum + price * (item.quantity || 1);
}, 0);
const charityAmount = Number(orderToPrint.charityDonation || 0);
const totalAmount = Number(orderToPrint.totalAmount || orderToPrint.total || itemsSubtotal + charityAmount);
const isRefill = Boolean(orderToPrint.isRefill);
const refillDiscount = isRefill ? Math.round(itemsSubtotal * 0.05) : 0;
const customerName = orderToPrint.user ? `${orderToPrint.user.firstName || ''} ${orderToPrint.user.lastName || ''}`.trim() : 'مشتری مهمان';
const paymentMethodText = orderToPrint.paymentMethod === 'wallet' ? 'پرداخت از کیف پول الکترونیک' : 'پرداخت آنلاین از درگاه شتاب';
const itemsHtml = items.map((item: any) => {
const pName = item.product?.nameFa || item.product?.name || item.name || 'محصول کانینا';
const pImg = item.product?.imageUrl || item.product?.image || '/products/product-placeholder.png';
const pPrice = Number(item.product?.priceValue || item.priceValue || 0);
const rowTotal = pPrice * (item.quantity || 1);
return `
<tr>
<td style="padding: 14px; border-bottom: 1px solid #e5e7eb; vertical-align: middle;">
<div style="display: flex; align-items: center; gap: 12px;">
<img src="${pImg}" alt="${pName}" style="width: 48px; height: 48px; object-fit: contain; border-radius: 8px; border: 1px solid #f3f4f6; background: #fff; padding: 4px;" />
<span style="font-weight: 700; color: #111827;">${pName}</span>
</div>
</td>
<td style="padding: 14px; border-bottom: 1px solid #e5e7eb; text-align: center; font-weight: 700;">${toPersianDigits(item.quantity)}</td>
<td style="padding: 14px; border-bottom: 1px solid #e5e7eb; text-align: left; font-weight: 700;">${toPersianDigits(pPrice.toLocaleString())} تومان</td>
<td style="padding: 14px; border-bottom: 1px solid #e5e7eb; text-align: left; font-weight: 900; color: #0055FF;">${toPersianDigits(rowTotal.toLocaleString())} تومان</td>
</tr>
`;
}).join('');
printWindow.document.write(`
<!DOCTYPE html>
<html dir="rtl" lang="fa">
<head>
<meta charset="utf-8" />
<title>فاکتور رسمی کانینا - ${trackingCode}</title>
<style>
@font-face {
font-family: 'Vazirmatn';
src: url('https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/fonts/webfonts/Vazirmatn-Regular.woff2') format('woff2');
}
body { font-family: 'Vazirmatn', sans-serif; padding: 40px; color: #1f2937; background: #fff; line-height: 1.6; }
.invoice-card { max-width: 850px; margin: 0 auto; border: 2px solid #e5e7eb; border-radius: 24px; padding: 40px; }
.header { display: flex; justify-between: space-between; align-items: center; border-bottom: 3px solid #0055FF; padding-bottom: 24px; margin-bottom: 32px; }
.logo-text { font-size: 28px; font-weight: 900; color: #0055FF; font-style: italic; }
.sub-logo { font-size: 13px; color: #6b7280; font-weight: 700; margin-top: 4px; }
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; background: #f9fafb; padding: 20px; border-radius: 16px; margin-bottom: 32px; font-size: 13px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 32px; }
th { background: #f3f4f6; text-align: right; padding: 14px; font-size: 12px; font-weight: 900; color: #4b5563; border-radius: 8px; }
.summary-box { background: #0f172a; color: #fff; padding: 24px; border-radius: 20px; margin-top: 32px; }
.summary-row { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 10px; font-weight: 700; color: #94a3b8; }
.summary-row.total { font-size: 20px; font-weight: 900; color: #fbbf24; border-top: 1px solid #334155; padding-top: 14px; margin-top: 14px; margin-bottom: 0; }
.footer { margin-top: 40px; text-align: center; font-size: 11px; color: #9ca3af; font-weight: 700; }
@media print {
body { padding: 0; }
.invoice-card { border: none; padding: 0; }
}
</style>
</head>
<body>
<div class="invoice-card">
<div class="header">
<div>
<div class="logo-text">CANINA | کانینا</div>
<div class="sub-logo">پنل مدیریت سفارشات آلمان</div>
</div>
<div style="text-align: left;">
<div style="font-size: 14px; font-weight: 900; color: #111827;">کد پیگیری: ${toPersianDigits(trackingCode)}</div>
<div style="font-size: 12px; color: #6b7280; font-weight: 700;">تاریخ: ${formattedDate} - ساعت ${formattedTime}</div>
</div>
</div>
<div class="meta-grid">
<div>
<p style="margin: 0 0 6px 0;"><strong>نام خریدار:</strong> ${customerName}</p>
<p style="margin: 0 0 6px 0;"><strong>گیرنده و آدرس:</strong> ${orderToPrint.shippingAddress || "ثبت‌شده در حساب کاربری"}</p>
<p style="margin: 0;"><strong>روش پرداخت:</strong> ${paymentMethodText}</p>
</div>
<div style="text-align: left;">
<p style="margin: 0 0 6px 0;"><strong>تلفن خریدار:</strong> ${toPersianDigits(orderToPrint.user?.phone || 'ثبت نشده')}</p>
<p style="margin: 0 0 6px 0;"><strong>وضعیت سفارش:</strong> ${statusStyles[orderToPrint.status]?.label || orderToPrint.status}</p>
<p style="margin: 0;"><strong>نوع سفارش:</strong> ${isRefill ? 'تمدید خودکار (Refill)' : 'عادی'}</p>
</div>
</div>
<table>
<thead>
<tr>
<th>شرح محصول</th>
<th style="text-align: center;">تعداد</th>
<th>قیمت واحد</th>
<th>مجموع</th>
</tr>
</thead>
<tbody>
${itemsHtml}
</tbody>
</table>
<div class="summary-box">
<div class="summary-row">
<span>مجموع اولیه اقلام:</span>
<span>${toPersianDigits(itemsSubtotal.toLocaleString())} تومان</span>
</div>
${isRefill ? `
<div class="summary-row" style="color: #4ade80;">
<span>تخفیف تمدید خودکار (۵٪):</span>
<span>${toPersianDigits(refillDiscount.toLocaleString())}- تومان</span>
</div>
` : ''}
${charityAmount > 0 ? `
<div class="summary-row" style="color: #f472b6;">
<span>کمک اهدایی (ردپای مهربانی):</span>
<span>${toPersianDigits(charityAmount.toLocaleString())}+ تومان</span>
</div>
` : ''}
<div class="summary-row">
<span>هزینه ارسال:</span>
<span style="color: #4ade80;">رایگان</span>
</div>
<div class="summary-row total">
<span>مبلغ نهایی پرداخت شده:</span>
<span>${toPersianDigits(totalAmount.toLocaleString())} تومان</span>
</div>
</div>
<div class="footer">
این فاکتور رسمی به صورت سیستمی صادر شده و فاقد نیاز به مهر و امضای فیزیکی است.<br/>
پشتیبانی کانینا آلمان: ۰۲۱-۸۸۸۸۸۸۸۸ | www.canina.ir
</div>
</div>
<script>
window.onload = function() {
setTimeout(function() {
window.print();
}, 400);
};
</script>
</body>
</html>
`);
printWindow.document.close();
};
return (
<div className="space-y-6">
<div className="space-y-6 font-vazir text-right" dir="rtl">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
<ShoppingCart className="w-6 h-6 text-purple-600" />
مدیریت سفارشات
</h2>
<p className="text-gray-500 font-medium mt-1">مشاهده فاکتورها، تغییر وضعیت و درج کد رهگیری پستی</p>
<p className="text-gray-500 font-medium mt-1">مشاهده فاکتورها، تغییر وضعیت، ثبت کد رهگیری پستی و چاپ فاکتور رسمی</p>
</div>
<div className="flex flex-col sm:flex-row items-center gap-3 w-full sm:w-auto">
<select
className="bg-white border border-gray-200 text-gray-700 px-4 py-2 rounded-xl flex items-center gap-2 font-bold hover:bg-gray-50 transition-all outline-none w-full sm:w-auto"
className="bg-white border border-gray-200 text-gray-700 px-4 py-2.5 rounded-xl flex items-center gap-2 font-bold hover:bg-gray-50 transition-all outline-none w-full sm:w-auto text-sm"
value={status}
onChange={(e) => { setStatus(e.target.value); setPage(1); }}
>
@ -85,13 +307,14 @@ export default function Orders() {
<option value="processing">در حال پردازش</option>
<option value="shipped">ارسال شده</option>
<option value="delivered">تحویل داده شده</option>
<option value="cancelled">لغو شده</option>
</select>
<div className="relative w-full sm:w-64">
<div className="relative w-full sm:w-72">
<Search className="w-5 h-5 text-gray-400 absolute right-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="جستجو (شماره سفارش)..."
className="pl-4 pr-10 py-2 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-purple-500 w-full font-medium"
placeholder="جستجو (شماره سفارش، خریدار، تلفن)..."
className="pl-4 pr-10 py-2.5 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-purple-500 w-full font-medium text-sm"
dir="rtl"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
@ -100,68 +323,100 @@ export default function Orders() {
</div>
</div>
{/* Orders Table */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-right font-vazir">
<thead className="bg-gray-50 text-gray-500 text-sm border-b border-gray-200">
<table className="w-full text-right">
<thead className="bg-gray-50 text-gray-500 text-xs uppercase tracking-wider border-b border-gray-200">
<tr>
<th className="py-4 px-6 font-bold">شماره سفارش</th>
<th className="py-4 px-6 font-bold">شماره / کد پیگیری</th>
<th className="py-4 px-6 font-bold">مشتری</th>
<th className="py-4 px-6 font-bold">مبلغ کل</th>
<th className="py-4 px-6 font-bold">تاریخ ثبت</th>
<th className="py-4 px-6 font-bold">آیتمها</th>
<th className="py-4 px-6 font-bold">وضعیت</th>
<th className="py-4 px-6 font-bold text-center">عملیات</th>
<th className="py-4 px-6 font-bold">تاریخ و ساعت ثبت</th>
<th className="py-4 px-6 font-bold">اقلام</th>
<th className="py-4 px-6 font-bold">وضعیت سفارش</th>
<th className="py-4 px-6 font-bold text-center">عملیات ادمین</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
<tbody className="divide-y divide-gray-100 text-sm">
{isLoading ? (
Array.from({ length: 4 }).map((_, i) => (
<tr key={i}>
<td className="py-4 px-6"><Skeleton className="h-6 w-24" /></td>
<td className="py-4 px-6"><Skeleton className="h-6 w-32" /></td>
<td className="py-4 px-6"><Skeleton className="h-6 w-24" /></td>
<td className="py-4 px-6"><Skeleton className="h-6 w-24" /></td>
<td className="py-4 px-6"><Skeleton className="h-6 w-28" /></td>
<td className="py-4 px-6"><Skeleton className="h-6 w-16" /></td>
<td className="py-4 px-6"><Skeleton className="h-6 w-24" /></td>
<td className="py-4 px-6"><Skeleton className="h-8 w-24 mx-auto" /></td>
</tr>
))
) : orders.length === 0 ? (
<tr>
<td colSpan={7} className="py-12 text-center text-gray-400 font-bold">
هیچ سفارشی یافت نشد.
</td>
</tr>
) : (
orders.map((order) => {
const isProcessing = processingId === order.id;
const displayCode = order.trackingNumber || order.id.slice(0, 8);
const orderDate = new Date(order.createdAt || order.date);
const customerName = order.user ? `${order.user.firstName || ''} ${order.user.lastName || ''}`.trim() : 'کاربر مهمان';
const itemsCount = (order.orderItems || order.items || []).length;
const totalAmt = Number(order.totalAmount || order.total || 0);
return (
<tr key={order.id} className="hover:bg-gray-50/50 transition-colors">
<td className="py-4 px-6 font-bold text-purple-600" dir="ltr">{order.id.slice(0,8)}</td>
<td className="py-4 px-6 font-bold text-gray-900">{order.user?.firstName} {order.user?.lastName}</td>
<td className="py-4 px-6 font-bold text-gray-900">{Number(order.totalAmount).toLocaleString()} تومان</td>
<td className="py-4 px-6 text-gray-500 text-sm font-medium">{new Date(order.createdAt).toLocaleDateString('fa-IR')}</td>
<td className="py-4 px-6 text-gray-500 font-bold">{order.orderItems?.length || 0} قلم</td>
<td className="py-4 px-6 font-bold text-purple-600 font-mono" dir="ltr">
{toPersianDigits(displayCode)}
</td>
<td className="py-4 px-6">
<span className={`px-3 py-1 rounded-full text-xs font-bold ${statusStyles[order.status]?.color || 'bg-gray-100 text-gray-700'}`}>
{statusStyles[order.status]?.label || order.status}
</span>
<div className="font-bold text-gray-900">{customerName}</div>
<div className="text-xs text-gray-400 font-medium" dir="ltr">{toPersianDigits(order.user?.phone || '')}</div>
</td>
<td className="py-4 px-6 font-black text-gray-900">
{toPersianDigits(totalAmt.toLocaleString())} <span className="text-xs font-normal">تومان</span>
</td>
<td className="py-4 px-6 text-gray-500 text-xs font-bold">
<div>{toPersianDigits(orderDate.toLocaleDateString('fa-IR'))}</div>
<div className="text-[10px] text-gray-400">{toPersianDigits(orderDate.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }))}</div>
</td>
<td className="py-4 px-6 text-gray-600 font-bold">
{toPersianDigits(itemsCount)} قلم
</td>
<td className="py-4 px-6">
<select
disabled={isProcessing}
value={order.status}
onChange={(e) => handleUpdateStatus(order.id, e.target.value)}
className={`text-xs font-bold px-3 py-1.5 rounded-xl border outline-none cursor-pointer transition-all ${statusStyles[order.status]?.color || 'bg-gray-100 text-gray-700'}`}
>
<option value="processing">در حال پردازش</option>
<option value="shipped">ارسال شده</option>
<option value="delivered">تحویل داده شده</option>
<option value="cancelled">لغو شده</option>
</select>
</td>
<td className="py-4 px-6">
<div className="flex items-center justify-center gap-2">
<button className="text-gray-600 hover:bg-gray-100 p-2 rounded-lg transition-colors" title="مشاهده فاکتور" disabled={isProcessing}>
<button
onClick={() => openOrderModal(order)}
className="text-purple-600 hover:bg-purple-50 p-2 rounded-xl transition-colors flex items-center gap-1 font-bold text-xs"
title="مشاهده فاکتور و جزئیات کامل"
disabled={isProcessing}
>
<Eye className="w-4 h-4" />
<span>جزئیات</span>
</button>
<button
onClick={() => handlePrintInvoice(order)}
className="text-gray-600 hover:bg-gray-100 p-2 rounded-xl transition-colors"
title="چاپ فوری فاکتور رسمی"
disabled={isProcessing}
>
<Printer className="w-4 h-4" />
</button>
{order.status === 'processing' && (
<>
<button onClick={() => handleUpdateStatus(order.id, 'shipped')} className="text-blue-600 hover:bg-blue-50 p-2 rounded-lg transition-colors" title="ثبت به عنوان ارسال شده" disabled={isProcessing}>
{isProcessing ? <Spinner size="sm" /> : <Truck className="w-4 h-4" />}
</button>
<button onClick={() => handleUpdateStatus(order.id, 'cancelled')} className="text-red-600 hover:bg-red-50 p-2 rounded-lg transition-colors" title="لغو سفارش" disabled={isProcessing}>
{isProcessing ? <Spinner size="sm" /> : <X className="w-4 h-4" />}
</button>
</>
)}
{order.status === 'shipped' && (
<button onClick={() => handleUpdateStatus(order.id, 'delivered')} className="text-green-600 hover:bg-green-50 p-2 rounded-lg transition-colors" title="ثبت به عنوان تحویل داده شده" disabled={isProcessing}>
{isProcessing ? <Spinner size="sm" /> : <Check className="w-4 h-4" />}
</button>
)}
</div>
</td>
</tr>
@ -173,6 +428,188 @@ export default function Orders() {
</div>
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
</div>
{/* Admin Order Details Modal */}
{selectedOrder && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-sm">
<div className="bg-white w-full max-w-3xl rounded-3xl shadow-2xl overflow-hidden text-right border border-gray-100 max-h-[90vh] flex flex-col">
{/* Modal Header */}
<div className="bg-gray-50 px-8 py-6 flex items-center justify-between border-b border-gray-200 shrink-0">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-purple-600 rounded-2xl flex items-center justify-center text-white shadow-md">
<Package className="w-6 h-6" />
</div>
<div>
<h3 className="text-lg font-black text-gray-900 italic">
جزئیات سفارش {toPersianDigits(selectedOrder.trackingNumber || selectedOrder.id.slice(0, 8))}
</h3>
<p className="text-xs font-bold text-gray-400">
ثبت شده در {toPersianDigits(new Date(selectedOrder.createdAt || selectedOrder.date).toLocaleDateString('fa-IR'))} - ساعت {toPersianDigits(new Date(selectedOrder.createdAt || selectedOrder.date).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }))}
</p>
</div>
</div>
<button
onClick={() => setSelectedOrder(null)}
className="w-9 h-9 flex items-center justify-center rounded-xl bg-white border border-gray-200 text-gray-400 hover:text-red-500 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Modal Body */}
<div className="p-8 overflow-y-auto space-y-8 custom-scrollbar">
{/* Customer & Shipping Info */}
<div className="grid md:grid-cols-2 gap-6 bg-gray-50 p-6 rounded-2xl border border-gray-100">
<div className="space-y-3">
<h4 className="text-xs font-black text-gray-400 uppercase tracking-widest flex items-center gap-1.5">
<User className="w-4 h-4 text-purple-600" />
اطلاعات خریدار
</h4>
<div className="text-sm font-black text-gray-900">
{selectedOrder.user ? `${selectedOrder.user.firstName || ''} ${selectedOrder.user.lastName || ''}` : 'خریدار مهمان'}
</div>
<div className="flex items-center gap-2 text-xs font-bold text-gray-500">
<Phone className="w-3.5 h-3.5 text-gray-400" />
<span dir="ltr">{toPersianDigits(selectedOrder.user?.phone || 'شماره ثبت نشده')}</span>
</div>
{selectedOrder.user?.email && (
<div className="flex items-center gap-2 text-xs font-bold text-gray-500">
<Mail className="w-3.5 h-3.5 text-gray-400" />
<span>{selectedOrder.user.email}</span>
</div>
)}
</div>
<div className="space-y-3">
<h4 className="text-xs font-black text-gray-400 uppercase tracking-widest flex items-center gap-1.5">
<MapPin className="w-4 h-4 text-purple-600" />
آدرس ارسال سفارش
</h4>
<p className="text-xs font-bold text-gray-700 leading-relaxed">
{selectedOrder.shippingAddress || 'آدرس ثبتی کاربر در حساب کاربری'}
</p>
</div>
</div>
{/* Status & Tracking Editor */}
<div className="bg-purple-50/50 border border-purple-100 p-6 rounded-2xl space-y-4">
<h4 className="text-xs font-black text-purple-900 uppercase tracking-widest">
مدیریت وضعیت و کد رهگیری پستی
</h4>
<div className="grid md:grid-cols-2 gap-4 items-end">
<div>
<label className="block text-xs font-bold text-gray-700 mb-1.5">تغییر وضعیت سفارش</label>
<select
value={modalStatus}
onChange={(e) => setModalStatus(e.target.value)}
className="w-full bg-white border border-gray-300 text-gray-900 text-sm font-bold rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="processing">در حال پردازش</option>
<option value="shipped">ارسال شده</option>
<option value="delivered">تحویل داده شده</option>
<option value="cancelled">لغو شده</option>
</select>
</div>
<div>
<label className="block text-xs font-bold text-gray-700 mb-1.5">کد رهگیری مرسوله پستی</label>
<div className="flex gap-2">
<input
type="text"
placeholder="مثلا: POST-9823478"
value={modalTrackingCode}
onChange={(e) => setModalTrackingCode(e.target.value)}
className="flex-1 bg-white border border-gray-300 text-gray-900 text-sm font-mono font-bold rounded-xl px-3 py-2.5 outline-none focus:ring-2 focus:ring-purple-500"
dir="ltr"
/>
<button
onClick={handleSaveModalChanges}
disabled={isSavingTracking}
className="bg-purple-600 text-white px-4 py-2.5 rounded-xl font-black text-xs flex items-center gap-1.5 hover:bg-purple-700 transition-all shrink-0 shadow-md shadow-purple-600/20"
>
{isSavingTracking ? <Spinner size="sm" /> : <Save className="w-4 h-4" />}
<span>ذخیره</span>
</button>
</div>
</div>
</div>
</div>
{/* Items Table */}
<div>
<h4 className="text-xs font-black text-gray-400 uppercase tracking-widest mb-4">
اقلام خریده شده ({toPersianDigits((selectedOrder.orderItems || selectedOrder.items || []).length)})
</h4>
<div className="space-y-3">
{(selectedOrder.orderItems || selectedOrder.items || []).map((item: any, idx: number) => {
const pName = item.product?.nameFa || item.product?.name || item.name || 'محصول کانینا';
const pImg = item.product?.imageUrl || item.product?.image || '/products/product-placeholder.png';
const pPrice = Number(item.product?.priceValue || item.priceValue || 0);
const qty = item.quantity || 1;
return (
<div key={item.id || idx} className="flex items-center justify-between p-4 border border-gray-100 rounded-2xl bg-white">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gray-50 rounded-xl p-1 flex items-center justify-center border border-gray-100 shrink-0">
<img src={pImg} alt={pName} className="max-w-full max-h-full object-contain" />
</div>
<div>
<div className="font-black text-sm text-gray-900">{pName}</div>
<div className="text-xs text-gray-400 font-bold">{toPersianDigits(qty)} عدد × {toPersianDigits(pPrice.toLocaleString())} تومان</div>
</div>
</div>
<div className="text-left font-black text-purple-600 text-sm">
{toPersianDigits((pPrice * qty).toLocaleString())} تومان
</div>
</div>
);
})}
</div>
</div>
{/* Financial Summary */}
<div className="bg-gray-900 text-white p-6 rounded-2xl space-y-2">
<div className="flex justify-between text-xs font-bold text-gray-400">
<span>روش پرداخت:</span>
<span className="text-white font-bold">{selectedOrder.paymentMethod === 'wallet' ? 'کیف پول الکترونیک' : 'درگاه پرداخت آنلاین'}</span>
</div>
{selectedOrder.isRefill && (
<div className="flex justify-between text-xs font-bold text-green-400">
<span>تخفیف تمدید خودکار (۵٪):</span>
<span>فعال</span>
</div>
)}
{Number(selectedOrder.charityDonation || 0) > 0 && (
<div className="flex justify-between text-xs font-bold text-pink-400">
<span>کمک اهدایی (ردپای مهربانی):</span>
<span>{toPersianDigits(Number(selectedOrder.charityDonation).toLocaleString())}+ تومان</span>
</div>
)}
<div className="flex justify-between text-base font-black text-amber-400 pt-3 border-t border-gray-800">
<span>مبلغ نهایی پرداخت شده:</span>
<span>{toPersianDigits(Number(selectedOrder.totalAmount || selectedOrder.total || 0).toLocaleString())} تومان</span>
</div>
</div>
</div>
{/* Modal Footer */}
<div className="p-6 bg-gray-50 border-t border-gray-200 flex gap-4 shrink-0">
<button
onClick={() => handlePrintInvoice(selectedOrder)}
className="flex-1 py-3.5 bg-purple-600 text-white rounded-xl font-black text-sm flex items-center justify-center gap-2 hover:bg-purple-700 transition-all shadow-lg shadow-purple-600/20"
>
<Download className="w-4 h-4" />
دانلود و چاپ فاکتور رسمی
</button>
<button
onClick={() => setSelectedOrder(null)}
className="px-6 py-3.5 bg-white border border-gray-200 text-gray-700 rounded-xl font-bold text-sm hover:bg-gray-100 transition-all"
>
بستن
</button>
</div>
</div>
</div>
)}
</div>
);
}