From 24c361c4ff4583bbef2b51c28937a225c90caa91 Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Mon, 27 Jul 2026 11:27:47 +0330 Subject: [PATCH] feat(admin): overhaul orders management page with interactive modal, postal tracking code editor, inline status selector and invoice print --- .ai_agency/memory/backlog.json | 47 +- .ai_agency/memory/state.json | 6 +- backend/src/admin/admin.controller.ts | 10 +- backend/src/admin/admin.service.ts | 39 +- frontend/admin-panel/src/pages/Orders.tsx | 535 ++++++++++++++++++++-- 5 files changed, 573 insertions(+), 64 deletions(-) diff --git a/.ai_agency/memory/backlog.json b/.ai_agency/memory/backlog.json index f139053..e889415 100644 --- a/.ai_agency/memory/backlog.json +++ b/.ai_agency/memory/backlog.json @@ -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 } } \ No newline at end of file diff --git a/.ai_agency/memory/state.json b/.ai_agency/memory/state.json index 0e98520..3685a8d 100644 --- a/.ai_agency/memory/state.json +++ b/.ai_agency/memory/state.json @@ -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": { diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts index 08db885..39e6736 100644 --- a/backend/src/admin/admin.controller.ts +++ b/backend/src/admin/admin.controller.ts @@ -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 diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts index b583eef..c2f4c45 100644 --- a/backend/src/admin/admin.service.ts +++ b/backend/src/admin/admin.service.ts @@ -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 + } + } + } }); } diff --git a/frontend/admin-panel/src/pages/Orders.tsx b/frontend/admin-panel/src/pages/Orders.tsx index cbf9aa1..3053889 100644 --- a/frontend/admin-panel/src/pages/Orders.tsx +++ b/frontend/admin-panel/src/pages/Orders.tsx @@ -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 = { - 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 = { + 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(null); + // Modal State + const [selectedOrder, setSelectedOrder] = useState(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 ` + + +
+ ${pName} + ${pName} +
+ + ${toPersianDigits(item.quantity)} + ${toPersianDigits(pPrice.toLocaleString())} تومان + ${toPersianDigits(rowTotal.toLocaleString())} تومان + + `; + }).join(''); + + printWindow.document.write(` + + + + + فاکتور رسمی کانینا - ${trackingCode} + + + +
+
+
+
CANINA | کانینا
+ +
+
+
کد پیگیری: ${toPersianDigits(trackingCode)}
+
تاریخ: ${formattedDate} - ساعت ${formattedTime}
+
+
+ +
+
+

نام خریدار: ${customerName}

+

گیرنده و آدرس: ${orderToPrint.shippingAddress || "ثبت‌شده در حساب کاربری"}

+

روش پرداخت: ${paymentMethodText}

+
+
+

تلفن خریدار: ${toPersianDigits(orderToPrint.user?.phone || 'ثبت نشده')}

+

وضعیت سفارش: ${statusStyles[orderToPrint.status]?.label || orderToPrint.status}

+

نوع سفارش: ${isRefill ? 'تمدید خودکار (Refill)' : 'عادی'}

+
+
+ + + + + + + + + + + + ${itemsHtml} + +
شرح محصولتعدادقیمت واحدمجموع
+ +
+
+ مجموع اولیه اقلام: + ${toPersianDigits(itemsSubtotal.toLocaleString())} تومان +
+ ${isRefill ? ` +
+ تخفیف تمدید خودکار (۵٪): + ${toPersianDigits(refillDiscount.toLocaleString())}- تومان +
+ ` : ''} + ${charityAmount > 0 ? ` +
+ کمک اهدایی (ردپای مهربانی): + ${toPersianDigits(charityAmount.toLocaleString())}+ تومان +
+ ` : ''} +
+ هزینه ارسال: + رایگان +
+
+ مبلغ نهایی پرداخت شده: + ${toPersianDigits(totalAmount.toLocaleString())} تومان +
+
+ + +
+ + + + + `); + printWindow.document.close(); + }; + return ( -
+
+ {/* Header */}

مدیریت سفارشات

-

مشاهده فاکتورها، تغییر وضعیت و درج کد رهگیری پستی

+

مشاهده فاکتورها، تغییر وضعیت، ثبت کد رهگیری پستی و چاپ فاکتور رسمی

-
+
{ setSearch(e.target.value); setPage(1); }} @@ -100,68 +323,100 @@ export default function Orders() {
+ {/* Orders Table */}
- - +
+ - + - - - - + + + + - + {isLoading ? ( Array.from({ length: 4 }).map((_, i) => ( - + )) + ) : orders.length === 0 ? ( + + + ) : ( 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 ( - - - - - + + + + + @@ -173,6 +428,188 @@ export default function Orders() { + + {/* Admin Order Details Modal */} + {selectedOrder && ( +
+
+ {/* Modal Header */} +
+
+
+ +
+
+

+ جزئیات سفارش {toPersianDigits(selectedOrder.trackingNumber || selectedOrder.id.slice(0, 8))} +

+

+ ثبت شده در {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' }))} +

+
+
+ +
+ + {/* Modal Body */} +
+ {/* Customer & Shipping Info */} +
+
+

+ + اطلاعات خریدار +

+
+ {selectedOrder.user ? `${selectedOrder.user.firstName || ''} ${selectedOrder.user.lastName || ''}` : 'خریدار مهمان'} +
+
+ + {toPersianDigits(selectedOrder.user?.phone || 'شماره ثبت نشده')} +
+ {selectedOrder.user?.email && ( +
+ + {selectedOrder.user.email} +
+ )} +
+ +
+

+ + آدرس ارسال سفارش +

+

+ {selectedOrder.shippingAddress || 'آدرس ثبتی کاربر در حساب کاربری'} +

+
+
+ + {/* Status & Tracking Editor */} +
+

+ مدیریت وضعیت و کد رهگیری پستی +

+
+
+ + +
+
+ +
+ 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" + /> + +
+
+
+
+ + {/* Items Table */} +
+

+ اقلام خریده شده ({toPersianDigits((selectedOrder.orderItems || selectedOrder.items || []).length)}) +

+
+ {(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 ( +
+
+
+ {pName} +
+
+
{pName}
+
{toPersianDigits(qty)} عدد × {toPersianDigits(pPrice.toLocaleString())} تومان
+
+
+
+ {toPersianDigits((pPrice * qty).toLocaleString())} تومان +
+
+ ); + })} +
+
+ + {/* Financial Summary */} +
+
+ روش پرداخت: + {selectedOrder.paymentMethod === 'wallet' ? 'کیف پول الکترونیک' : 'درگاه پرداخت آنلاین'} +
+ {selectedOrder.isRefill && ( +
+ تخفیف تمدید خودکار (۵٪): + فعال +
+ )} + {Number(selectedOrder.charityDonation || 0) > 0 && ( +
+ کمک اهدایی (ردپای مهربانی): + {toPersianDigits(Number(selectedOrder.charityDonation).toLocaleString())}+ تومان +
+ )} +
+ مبلغ نهایی پرداخت شده: + {toPersianDigits(Number(selectedOrder.totalAmount || selectedOrder.total || 0).toLocaleString())} تومان +
+
+
+ + {/* Modal Footer */} +
+ + +
+
+
+ )} ); }
شماره سفارششماره / کد پیگیری مشتری مبلغ کلتاریخ ثبتآیتم‌هاوضعیتعملیاتتاریخ و ساعت ثبتاقلاموضعیت سفارشعملیات ادمین
+ هیچ سفارشی یافت نشد. +
{order.id.slice(0,8)}{order.user?.firstName} {order.user?.lastName}{Number(order.totalAmount).toLocaleString()} تومان{new Date(order.createdAt).toLocaleDateString('fa-IR')}{order.orderItems?.length || 0} قلم + {toPersianDigits(displayCode)} + - - {statusStyles[order.status]?.label || order.status} - +
{customerName}
+
{toPersianDigits(order.user?.phone || '')}
+
+ {toPersianDigits(totalAmt.toLocaleString())} تومان + +
{toPersianDigits(orderDate.toLocaleDateString('fa-IR'))}
+
{toPersianDigits(orderDate.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }))}
+
+ {toPersianDigits(itemsCount)} قلم + +
- + - {order.status === 'processing' && ( - <> - - - - )} - {order.status === 'shipped' && ( - - )}