744 lines
38 KiB
TypeScript
744 lines
38 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
||
import {
|
||
ShoppingCart,
|
||
Eye,
|
||
Search,
|
||
X,
|
||
Printer,
|
||
CheckCircle2,
|
||
Clock,
|
||
Truck,
|
||
XCircle,
|
||
MapPin,
|
||
User,
|
||
Phone,
|
||
Mail,
|
||
Package,
|
||
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';
|
||
|
||
export interface OrderItem {
|
||
id?: string;
|
||
name?: string;
|
||
priceValue?: number;
|
||
quantity?: number;
|
||
product?: {
|
||
nameFa?: string;
|
||
name?: string;
|
||
priceValue?: number;
|
||
imageUrl?: string;
|
||
image?: string;
|
||
};
|
||
}
|
||
|
||
export interface PaymentTx {
|
||
id: string;
|
||
trackId?: string;
|
||
refNumber?: string;
|
||
cardNumber?: string;
|
||
amount?: number;
|
||
status: string;
|
||
message?: string;
|
||
gateway?: string;
|
||
createdAt: string;
|
||
}
|
||
|
||
export interface Order {
|
||
id: string;
|
||
trackingNumber?: string;
|
||
status: string;
|
||
createdAt?: string;
|
||
date?: string;
|
||
charityDonation?: number;
|
||
totalAmount?: number;
|
||
total?: number;
|
||
isRefill?: boolean;
|
||
paymentMethod?: string;
|
||
address?: string;
|
||
shippingAddress?: string;
|
||
orderItems?: OrderItem[];
|
||
items?: OrderItem[];
|
||
paymentTransactions?: PaymentTx[];
|
||
user?: {
|
||
firstName?: string;
|
||
lastName?: string;
|
||
mobile?: string;
|
||
phone?: string;
|
||
email?: string;
|
||
};
|
||
}
|
||
|
||
const statusStyles: Record<string, { label: string; color: string; icon: React.ElementType }> = {
|
||
pending_payment: { label: 'در انتظار پرداخت / ناموفق', color: 'bg-rose-100 text-rose-800 border-rose-200', icon: Clock },
|
||
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() {
|
||
const [orders, setOrders] = useState<Order[]>([]);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
|
||
// Queries
|
||
const [page, setPage] = useState(1);
|
||
const [search, setSearch] = useState('');
|
||
const [status, setStatus] = useState('');
|
||
const [totalPages, setTotalPages] = useState(1);
|
||
const [processingId, setProcessingId] = useState<string | null>(null);
|
||
|
||
// Modal State
|
||
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null);
|
||
const [modalTrackingCode, setModalTrackingCode] = useState('');
|
||
const [modalStatus, setModalStatus] = useState('');
|
||
const [isSavingTracking, setIsSavingTracking] = useState(false);
|
||
|
||
const fetchOrders = useCallback(async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
const params = new URLSearchParams();
|
||
params.append('page', page.toString());
|
||
if (search) params.append('search', search);
|
||
if (status) params.append('status', status);
|
||
|
||
const response = await api.get(`/admin/orders?${params.toString()}`);
|
||
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: Order) => o.id === selectedOrder.id);
|
||
if (updated) {
|
||
setSelectedOrder(updated);
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to fetch orders', err);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [page, search, status, selectedOrder]);
|
||
|
||
useEffect(() => {
|
||
const delayDebounceFn = setTimeout(() => {
|
||
fetchOrders();
|
||
}, 500);
|
||
|
||
return () => clearTimeout(delayDebounceFn);
|
||
}, [fetchOrders]);
|
||
|
||
const handleUpdateStatus = async (id: string, newStatus: string, trackingNumber?: string) => {
|
||
try {
|
||
setProcessingId(id);
|
||
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 {
|
||
setProcessingId(null);
|
||
}
|
||
};
|
||
|
||
const openOrderModal = (order: Order) => {
|
||
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: Order) => {
|
||
if (!orderToPrint) return;
|
||
const printWindow = window.open('', '_blank');
|
||
if (!printWindow) return;
|
||
|
||
const orderDate = new Date(orderToPrint.createdAt || orderToPrint.date || Date.now());
|
||
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: OrderItem) => {
|
||
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: OrderItem) => {
|
||
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 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>
|
||
</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.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); }}
|
||
>
|
||
<option value="">همه وضعیتها</option>
|
||
<option value="pending_payment">در انتظار پرداخت / ناموفق</option>
|
||
<option value="processing">در حال پردازش</option>
|
||
<option value="shipped">ارسال شده</option>
|
||
<option value="delivered">تحویل داده شده</option>
|
||
<option value="cancelled">لغو شده</option>
|
||
</select>
|
||
<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.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); }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Quick Filter Pills */}
|
||
<div className="flex flex-wrap gap-2 pt-1">
|
||
{[
|
||
{ label: 'همه سفارشها', value: '' },
|
||
{ label: 'در انتظار پرداخت / ناموفق', value: 'pending_payment', color: 'text-rose-700 bg-rose-50 border-rose-200' },
|
||
{ label: 'در حال پردازش', value: 'processing', color: 'text-amber-700 bg-amber-50 border-amber-200' },
|
||
{ label: 'ارسال شده', value: 'shipped', color: 'text-blue-700 bg-blue-50 border-blue-200' },
|
||
{ label: 'تحویل شده', value: 'delivered', color: 'text-green-700 bg-green-50 border-green-200' },
|
||
{ label: 'لغو شده', value: 'cancelled', color: 'text-red-700 bg-red-50 border-red-200' },
|
||
].map((tab) => {
|
||
const isSelected = status === tab.value;
|
||
return (
|
||
<button
|
||
key={tab.value}
|
||
onClick={() => { setStatus(tab.value); setPage(1); }}
|
||
className={`px-4 py-2 rounded-xl font-bold text-xs border transition-all cursor-pointer ${
|
||
isSelected
|
||
? 'bg-purple-600 text-white border-purple-600 shadow-md shadow-purple-600/20'
|
||
: `${tab.color || 'bg-white text-gray-700 border-gray-200'} hover:bg-gray-50`
|
||
}`}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</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">
|
||
<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 text-center">عملیات ادمین</th>
|
||
</tr>
|
||
</thead>
|
||
<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-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 font-mono" dir="ltr">
|
||
<div>{toPersianDigits(displayCode)}</div>
|
||
{order.isRefill && (
|
||
<span className="inline-block mt-1 text-[9px] font-black px-2 py-0.5 rounded-full bg-green-50 text-green-700 border border-green-200">
|
||
Refill 5%
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td className="py-4 px-6">
|
||
<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="pending_payment">در انتظار پرداخت / ناموفق</option>
|
||
<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
|
||
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>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</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="pending_payment">در انتظار پرداخت / ناموفق</option>
|
||
<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>
|
||
|
||
{/* Payment Gateway Transactions Log */}
|
||
{selectedOrder.paymentTransactions && selectedOrder.paymentTransactions.length > 0 && (
|
||
<div className="bg-slate-50 p-6 rounded-2xl border border-slate-200 space-y-3">
|
||
<h4 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-2">
|
||
<Clock className="w-4 h-4 text-purple-600" />
|
||
لاگ تراکنشهای درگاه پرداخت زیبال / بانکی
|
||
</h4>
|
||
<div className="space-y-2 text-xs">
|
||
{selectedOrder.paymentTransactions.map((tx) => (
|
||
<div key={tx.id} className="p-3 bg-white rounded-xl border border-slate-200 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||
<div className="space-y-1">
|
||
<div className="font-mono font-bold text-slate-700">
|
||
شناسه رهگیری زیبال (TrackID): <span className="text-purple-600">{tx.trackId || 'ثبت نشده'}</span>
|
||
</div>
|
||
{tx.refNumber && (
|
||
<div className="text-slate-600">
|
||
شماره ارجاع شاپرک (RRN): <span className="font-mono font-bold">{tx.refNumber}</span>
|
||
</div>
|
||
)}
|
||
{tx.message && (
|
||
<div className="text-slate-500">
|
||
پیام درگاه: <span className="font-semibold">{tx.message}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="text-left">
|
||
<span className={`inline-block px-2.5 py-1 rounded-lg font-bold text-[11px] ${
|
||
tx.status === 'VERIFIED'
|
||
? 'bg-emerald-100 text-emerald-800'
|
||
: tx.status === 'PENDING'
|
||
? 'bg-amber-100 text-amber-800'
|
||
: 'bg-rose-100 text-rose-800'
|
||
}`}>
|
||
{tx.status === 'VERIFIED' ? 'پرداخت تایید شده' : tx.status === 'PENDING' ? 'در انتظار پرداخت' : 'پرداخت ناموفق'}
|
||
</span>
|
||
</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: OrderItem, 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 bg-green-950/50 p-2 rounded-xl border border-green-800">
|
||
<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>
|
||
);
|
||
}
|