feat(admin): overhaul orders management page with interactive modal, postal tracking code editor, inline status selector and invoice print
This commit is contained in:
parent
d1802581eb
commit
24c361c4ff
@ -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
|
||||
}
|
||||
}
|
||||
@ -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": {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user