diff --git a/backend/src/payment/payment.controller.ts b/backend/src/payment/payment.controller.ts index c5c4a1f..75be221 100644 --- a/backend/src/payment/payment.controller.ts +++ b/backend/src/payment/payment.controller.ts @@ -288,6 +288,33 @@ export class PaymentController { return this.zibalService.getGatewayTransactionsReport(body); } + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('Admin') + @ApiBearerAuth() + @Post('admin/checkout-report') + @ApiOperation({ summary: 'گزارش تسویه‌ها و واریزی‌های درگاه و کیف‌پول زیبال' }) + async getCheckoutReport( + @Body() + body: { + fromDate?: string; + toDate?: string; + page?: number; + size?: number; + transactionTrackId?: number; + }, + ) { + return this.zibalService.getCheckoutReport(body); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('Admin') + @ApiBearerAuth() + @Get('admin/checkout-queue') + @ApiOperation({ summary: 'گزارش صف تسویه‌های در انتظار زیبال' }) + async getCheckoutQueueReport() { + return this.zibalService.getCheckoutQueueReport(); + } + // ========================================== // ADMIN: SUB-MERCHANTS (ذی‌نفع‌های تسهیم) // ========================================== diff --git a/frontend/admin-panel/src/components/Sidebar.tsx b/frontend/admin-panel/src/components/Sidebar.tsx index 6d5de8c..ab131ea 100644 --- a/frontend/admin-panel/src/components/Sidebar.tsx +++ b/frontend/admin-panel/src/components/Sidebar.tsx @@ -34,6 +34,7 @@ import { Layers, HelpCircle, Menu, + RotateCcw, } from 'lucide-react'; import api from '../services/api'; @@ -106,6 +107,7 @@ export default function Sidebar({ isOpen, setIsOpen }: SidebarProps) { badge: newOrdersCount > 0 ? newOrdersCount : undefined, }, { icon: Receipt, label: 'تراکنش‌ها و لاگ پرداخت', path: '/transactions' }, + { icon: RotateCcw, label: 'پرتال مالی و استرداد زیبال', path: '/zibal-portal' }, { icon: CreditCard, label: 'روش‌های پرداخت و زیبال', path: '/settings/payment-methods' }, { icon: Truck, label: 'تنظیمات ارسال و کرایه', path: '/settings/shipping' }, { icon: DollarSign, label: 'تنظیمات مالی و مالیات', path: '/settings/financial' }, diff --git a/frontend/admin-panel/src/pages/ZibalPortalPage.tsx b/frontend/admin-panel/src/pages/ZibalPortalPage.tsx new file mode 100644 index 0000000..db1f212 --- /dev/null +++ b/frontend/admin-panel/src/pages/ZibalPortalPage.tsx @@ -0,0 +1,1138 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + RotateCcw, + Search, + Download, + RefreshCw, + Filter, + Calendar, + Layers, + FileSpreadsheet, + CheckCircle2, + Clock, + AlertCircle, + XCircle, + Eye, + CreditCard, + Building2, + ListOrdered, + Plus, + Send, + SlidersHorizontal, + ChevronLeft, + ChevronRight, + TrendingUp, +} from 'lucide-react'; +import { toast } from 'react-hot-toast'; +import api from '../services/api'; +import Spinner from '../components/ui/Spinner'; + +export default function ZibalPortalPage() { + const [activeMainTab, setActiveMainTab] = useState<'refunds' | 'checkouts' | 'queue' | 'gateway_transactions'>('refunds'); + + // ========================================== + // TAB 1: REFUNDS (استرداد وجه) + // ========================================== + const [refunds, setRefunds] = useState([]); + const [loadingRefunds, setLoadingRefunds] = useState(false); + const [refundSearchOpen, setRefundSearchOpen] = useState(false); + const [refundFilters, setRefundFilters] = useState({ + status: '', + psp: '', + fromDate: '', + toDate: '', + trackId: '', + refundId: '', + orderId: '', + description: '', + name: '', + destination: '', + refNumber: '', + page: 1, + size: 20, + }); + + // New Refund Modal + const [newRefundModal, setNewRefundModal] = useState(false); + const [refundForm, setRefundForm] = useState({ + trackId: '', + amount: '', + tryReverse: true, + cardNumber: '', + description: '', + }); + const [isSubmittingRefund, setIsSubmittingRefund] = useState(false); + + // ========================================== + // TAB 2: CHECKOUTS / PAYOUTS (گزارش واریز / تسویه) + // ========================================== + const [checkouts, setCheckouts] = useState([]); + const [loadingCheckouts, setLoadingCheckouts] = useState(false); + const [checkoutSearchOpen, setCheckoutSearchOpen] = useState(false); + const [checkoutFilters, setCheckoutFilters] = useState({ + fromDate: '', + toDate: '', + status: '', + type: '', + refNumber: '', + destinationAccount: '', + merchantName: '', + page: 1, + size: 20, + }); + + // ========================================== + // TAB 3: CHECKOUT QUEUE (صف تسویه) + // ========================================== + const [queueItems, setQueueItems] = useState([]); + const [loadingQueue, setLoadingQueue] = useState(false); + + // ========================================== + // TAB 4: DIRECT GATEWAY TRANSACTIONS (تراکنش‌های درگاه) + // ========================================== + const [gatewayTxList, setGatewayTxList] = useState([]); + const [loadingGatewayTx, setLoadingGatewayTx] = useState(false); + const [gatewaySearchOpen, setGatewaySearchOpen] = useState(false); + const [gatewayFilters, setGatewayFilters] = useState({ + fromDate: '', + toDate: '', + status: '', + trackId: '', + orderId: '', + mobile: '', + cardNumber: '', + amount: '', + page: 1, + size: 20, + }); + + // Details Modal + const [detailModalItem, setDetailModalItem] = useState<{ title: string; data: any } | null>(null); + + // ------------------------------------------------------------- + // Data Fetching Handlers + // ------------------------------------------------------------- + + const fetchRefunds = useCallback(async () => { + try { + setLoadingRefunds(true); + const res = await api.post('/payment/admin/refund/list', { + page: refundFilters.page, + size: refundFilters.size, + fromDate: refundFilters.fromDate || undefined, + toDate: refundFilters.toDate || undefined, + status: refundFilters.status ? [Number(refundFilters.status)] : undefined, + }); + + let items = []; + if (Array.isArray(res.data)) items = res.data; + else if (res.data?.data && Array.isArray(res.data.data)) items = res.data.data; + else if (res.data?.refundList && Array.isArray(res.data.refundList)) items = res.data.refundList; + + // Client-side quick filter matching user screenshot search fields + if (refundFilters.trackId) items = items.filter((x: any) => String(x.trackId || x.transactionTrackId || '').includes(refundFilters.trackId)); + if (refundFilters.refundId) items = items.filter((x: any) => String(x.id || x.refundId || '').includes(refundFilters.refundId)); + if (refundFilters.orderId) items = items.filter((x: any) => String(x.orderId || '').includes(refundFilters.orderId)); + if (refundFilters.refNumber) items = items.filter((x: any) => String(x.refNumber || '').includes(refundFilters.refNumber)); + if (refundFilters.name) items = items.filter((x: any) => String(x.name || x.cardHolder || '').includes(refundFilters.name)); + if (refundFilters.destination) items = items.filter((x: any) => String(x.cardNumber || x.bankAccount || x.iban || '').includes(refundFilters.destination)); + + setRefunds(items); + } catch (e: any) { + toast.error(e?.response?.data?.message || 'خطا در دریافت لیست استردادها از زیبال'); + } finally { + setLoadingRefunds(false); + } + }, [refundFilters]); + + const fetchCheckouts = useCallback(async () => { + try { + setLoadingCheckouts(true); + const res = await api.post('/payment/admin/checkout-report', { + page: checkoutFilters.page, + size: checkoutFilters.size, + fromDate: checkoutFilters.fromDate || undefined, + toDate: checkoutFilters.toDate || undefined, + }); + + let items = []; + if (Array.isArray(res.data)) items = res.data; + else if (res.data?.data && Array.isArray(res.data.data)) items = res.data.data; + else if (res.data?.checkoutList && Array.isArray(res.data.checkoutList)) items = res.data.checkoutList; + + if (checkoutFilters.refNumber) items = items.filter((x: any) => String(x.refNumber || x.referenceId || '').includes(checkoutFilters.refNumber)); + if (checkoutFilters.destinationAccount) items = items.filter((x: any) => String(x.accountIban || x.iban || x.destination || '').includes(checkoutFilters.destinationAccount)); + + setCheckouts(items); + } catch (e: any) { + toast.error(e?.response?.data?.message || 'خطا در دریافت گزارش تسویه‌ها از زیبال'); + } finally { + setLoadingCheckouts(false); + } + }, [checkoutFilters]); + + const fetchQueue = async () => { + try { + setLoadingQueue(true); + const res = await api.get('/payment/admin/checkout-queue'); + const items = Array.isArray(res.data) ? res.data : (res.data?.data || []); + setQueueItems(items); + } catch (e: any) { + toast.error('خطا در دریافت صف تسویه‌های در انتظار'); + } finally { + setLoadingQueue(false); + } + }; + + const fetchGatewayTx = useCallback(async () => { + try { + setLoadingGatewayTx(true); + const res = await api.post('/payment/admin/gateway-report', { + page: gatewayFilters.page, + size: gatewayFilters.size, + fromDate: gatewayFilters.fromDate || undefined, + toDate: gatewayFilters.toDate || undefined, + trackId: gatewayFilters.trackId ? Number(gatewayFilters.trackId) : undefined, + orderId: gatewayFilters.orderId || undefined, + mobile: gatewayFilters.mobile || undefined, + cardNumber: gatewayFilters.cardNumber || undefined, + status: gatewayFilters.status ? Number(gatewayFilters.status) : undefined, + }); + + let items = []; + if (Array.isArray(res.data)) items = res.data; + else if (res.data?.data && Array.isArray(res.data.data)) items = res.data.data; + else if (res.data?.transactions && Array.isArray(res.data.transactions)) items = res.data.transactions; + + setGatewayTxList(items); + } catch (e: any) { + toast.error('خطا در دریافت لیست تراکنش‌های زیبال'); + } finally { + setLoadingGatewayTx(false); + } + }, [gatewayFilters]); + + useEffect(() => { + if (activeMainTab === 'refunds') fetchRefunds(); + if (activeMainTab === 'checkouts') fetchCheckouts(); + if (activeMainTab === 'queue') fetchQueue(); + if (activeMainTab === 'gateway_transactions') fetchGatewayTx(); + }, [activeMainTab, fetchRefunds, fetchCheckouts, fetchGatewayTx]); + + // ------------------------------------------------------------- + // Actions + // ------------------------------------------------------------- + + const handleCreateRefundSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!refundForm.trackId) { + toast.error('شناسه تراکنش (Track ID) الزامی است'); + return; + } + try { + setIsSubmittingRefund(true); + const res = await api.post('/payment/admin/refund', { + trackId: refundForm.trackId, + amount: refundForm.amount ? Number(refundForm.amount) * 10 : undefined, // Convert to Rials + tryReverse: refundForm.tryReverse, + cardNumber: refundForm.cardNumber || undefined, + description: refundForm.description || undefined, + }); + + if (res.data?.result === 1 || res.data?.status === 1 || res.data?.success) { + toast.success(res.data?.message || 'درخواست استرداد با موفقیت به زیبال ارسال شد.'); + setNewRefundModal(false); + setRefundForm({ trackId: '', amount: '', tryReverse: true, cardNumber: '', description: '' }); + fetchRefunds(); + } else { + toast.error(res.data?.message || 'خطا در ثبت استرداد وجه'); + } + } catch (e: any) { + toast.error(e?.response?.data?.message || 'خطا در برقراری ارتباط با سرویس استرداد زیبال'); + } finally { + setIsSubmittingRefund(false); + } + }; + + const handleInquireRefundStatus = async (item: any) => { + try { + const res = await api.post('/payment/admin/refund/inquiry', { + refundId: item.id || item.refundId, + transactionTrackId: item.trackId || item.transactionTrackId, + }); + setDetailModalItem({ + title: `استعلام استرداد #${item.id || item.trackId}`, + data: res.data, + }); + toast.success('استعلام با موفقیت انجام شد'); + } catch (e: any) { + toast.error(e?.response?.data?.message || 'خطا در استعلام'); + } + }; + + const downloadCSV = (data: any[], filename: string) => { + if (!data || data.length === 0) { + toast.error('داده‌ای برای خروجی اکسل یافت نشد'); + return; + } + const headers = Object.keys(data[0]).join(','); + const rows = data.map((row) => + Object.values(row) + .map((v) => `"${String(v ?? '').replace(/"/g, '""')}"`) + .join(',') + ); + const csvContent = '\uFEFF' + [headers, ...rows].join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.setAttribute('href', url); + link.setAttribute('download', `${filename}-${new Date().toISOString().split('T')[0]}.csv`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + toast.success('فایل گزارش با موفقیت دانلود شد'); + }; + + return ( +
+ {/* Top Header */} +
+
+

+ + پرتال و داشبورد خدمات مالی زیبال +

+

+ مشاهده گزارشات استرداد وجه (Refund/Reverse)، تسویه‌ها و واریزها، صف تسویه و تراکنش‌های رسمی شاپرک +

+
+ +
+ +
+
+ + {/* Main Feature Tabs (Matching Zibal Panel layout) */} +
+
+ + + + + + + +
+
+ + {/* ======================================================== */} + {/* 1. TAB CONTENT: REFUNDS (استرداد وجه) */} + {/* ======================================================== */} + {activeMainTab === 'refunds' && ( +
+ {/* Top Bar with Filter & Search Controls */} +
+
+ + + +
+ + {/* Filter Inputs Bar */} +
+ {/* Date Filter */} +
+ + تاریخ از: + setRefundFilters({ ...refundFilters, fromDate: e.target.value })} + className="bg-transparent outline-none font-mono text-[11px]" + /> + تا: + setRefundFilters({ ...refundFilters, toDate: e.target.value })} + className="bg-transparent outline-none font-mono text-[11px]" + /> +
+ + {/* Status Selector */} + + + {/* Advanced Search Popup Toggle */} +
+ + + {/* Advanced Search Dropdown Window (Matching Exact User Screenshot) */} + {refundSearchOpen && ( +
+
+
+ setRefundFilters({ ...refundFilters, trackId: e.target.value })} + className="w-full border border-gray-200 rounded-xl p-2.5 pr-3 pl-8 text-xs outline-none focus:ring-1 focus:ring-indigo-500" + /> + +
+ +
+ setRefundFilters({ ...refundFilters, refundId: e.target.value })} + className="w-full border border-gray-200 rounded-xl p-2.5 pr-3 pl-8 text-xs outline-none focus:ring-1 focus:ring-indigo-500" + /> + +
+ +
+ setRefundFilters({ ...refundFilters, orderId: e.target.value })} + className="w-full border border-gray-200 rounded-xl p-2.5 pr-3 pl-8 text-xs outline-none focus:ring-1 focus:ring-indigo-500" + /> + +
+ +
+ setRefundFilters({ ...refundFilters, name: e.target.value })} + className="w-full border border-gray-200 rounded-xl p-2.5 pr-3 pl-8 text-xs outline-none focus:ring-1 focus:ring-indigo-500" + /> + +
+ +
+ setRefundFilters({ ...refundFilters, destination: e.target.value })} + className="w-full border border-gray-200 rounded-xl p-2.5 pr-3 pl-8 text-xs outline-none focus:ring-1 focus:ring-indigo-500" + /> + +
+ +
+ setRefundFilters({ ...refundFilters, refNumber: e.target.value })} + className="w-full border border-gray-200 rounded-xl p-2.5 pr-3 pl-8 text-xs outline-none focus:ring-1 focus:ring-indigo-500" + /> + +
+
+ +
+ + + +
+
+ )} +
+
+
+ + {/* Refund Table */} + {loadingRefunds ? ( +
+ +
+ ) : refunds.length === 0 ? ( +
+
+ +
+

نتیجه‌ای یافت نشد.

+
+ ) : ( +
+ + + + + + + + + + + + + + + + + + {refunds.map((rf: any, i: number) => ( + + + + + + + + + + + + + + ))} + +
شناسه استردادشماره تراکنششناسه سفارششماره مرجعنام / دارندهمقصدمبلغ استردادنوع / کارمزدوضعیتتاریخ ایجادعملیات
{rf.id || rf.refundId || '-'}{rf.trackId || rf.transactionTrackId || '-'}{rf.orderId || '-'}{rf.refNumber || '-'}{rf.name || rf.cardHolder || '-'}{rf.cardNumber || rf.bankAccount || '-'} + {(Number(rf.amount || 0) / 10).toLocaleString()} تومان + + + {rf.tryReverse ? 'Reverse' : 'Refund'} + + + + {rf.status === 1 || rf.status === 'SUCCESS' ? 'موفق' : rf.status === 0 || rf.status === 'PENDING' ? 'در صف' : 'ناموفق'} + + + {rf.createdAt ? new Date(rf.createdAt).toLocaleDateString('fa-IR') : '-'} + + +
+
+ )} +
+ )} + + {/* ======================================================== */} + {/* 2. TAB CONTENT: CHECKOUTS / PAYOUTS (گزارش واریز) */} + {/* ======================================================== */} + {activeMainTab === 'checkouts' && ( +
+
+
+ + + +
+ + {/* Filter Inputs Bar */} +
+
+ + از: + setCheckoutFilters({ ...checkoutFilters, fromDate: e.target.value })} + className="bg-transparent outline-none font-mono text-[11px]" + /> + تا: + setCheckoutFilters({ ...checkoutFilters, toDate: e.target.value })} + className="bg-transparent outline-none font-mono text-[11px]" + /> +
+ + {/* Advanced Checkout Search Popup */} +
+ + + {checkoutSearchOpen && ( +
+
+ setCheckoutFilters({ ...checkoutFilters, refNumber: e.target.value })} + className="w-full border border-gray-200 rounded-xl p-2.5 text-xs outline-none focus:ring-1 focus:ring-indigo-500" + /> + setCheckoutFilters({ ...checkoutFilters, destinationAccount: e.target.value })} + className="w-full border border-gray-200 rounded-xl p-2.5 text-xs outline-none focus:ring-1 focus:ring-indigo-500" + /> +
+ +
+ + +
+
+ )} +
+
+
+ + {/* Table */} + {loadingCheckouts ? ( +
+ +
+ ) : checkouts.length === 0 ? ( +
+
+ +
+

نتیجه‌ای یافت نشد.

+
+ ) : ( +
+ + + + + + + + + + + + + + {checkouts.map((chk: any, i: number) => ( + + + + + + + + + + ))} + +
حساب مقصدنام صاحب حسابمبلغ کل (تومان)نوع تسویهتاریخ واریزشناسه مرجعوضعیت
{chk.accountIban || chk.iban || chk.destination || '-'}{chk.accountName || chk.name || '-'} + {(Number(chk.amount || 0) / 10).toLocaleString()} تومان + {chk.type || 'پایا شاپرکی'} + {chk.paidAt || chk.createdAt ? new Date(chk.paidAt || chk.createdAt).toLocaleDateString('fa-IR') : '-'} + {chk.refNumber || chk.referenceId || '-'} + + واریز شده + +
+
+ )} +
+ )} + + {/* ======================================================== */} + {/* 3. TAB CONTENT: QUEUE (صف تسویه) */} + {/* ======================================================== */} + {activeMainTab === 'queue' && ( +
+
+

+ + لیست تسویه‌ها و واریزی‌های در انتظار تایید شاپرک +

+ +
+ + {loadingQueue ? ( +
+ +
+ ) : queueItems.length === 0 ? ( +
+ +

صف تسویه خالی است. کلیه مبالغ تسویه شده‌اند.

+
+ ) : ( +
+ + + + + + + + + + + + {queueItems.map((q: any, i: number) => ( + + + + + + + + ))} + +
شناسه درخواستحساب مقصد (شبا)مبلغ در صفزمان ثبت در صفوضعیت
{q.id || q.requestId || '-'}{q.iban || q.accountIban || '-'} + {(Number(q.amount || 0) / 10).toLocaleString()} تومان + + {q.createdAt ? new Date(q.createdAt).toLocaleDateString('fa-IR') : '-'} + + + در انتظار سیکل پایا + +
+
+ )} +
+ )} + + {/* ======================================================== */} + {/* 4. TAB CONTENT: GATEWAY TRANSACTIONS (تراکنش‌های درگاه) */} + {/* ======================================================== */} + {activeMainTab === 'gateway_transactions' && ( +
+
+
+ + + +
+ + {/* Filter Inputs */} +
+
+ + از: + setGatewayFilters({ ...gatewayFilters, fromDate: e.target.value })} + className="bg-transparent outline-none font-mono text-[11px]" + /> + تا: + setGatewayFilters({ ...gatewayFilters, toDate: e.target.value })} + className="bg-transparent outline-none font-mono text-[11px]" + /> +
+ + {/* Advanced Gateway Search Popup */} +
+ + + {gatewaySearchOpen && ( +
+
+ setGatewayFilters({ ...gatewayFilters, trackId: e.target.value })} + className="w-full border border-gray-200 rounded-xl p-2.5 text-xs outline-none focus:ring-1 focus:ring-indigo-500 font-mono" + dir="ltr" + /> + setGatewayFilters({ ...gatewayFilters, cardNumber: e.target.value })} + className="w-full border border-gray-200 rounded-xl p-2.5 text-xs outline-none focus:ring-1 focus:ring-indigo-500 font-mono" + dir="ltr" + /> + setGatewayFilters({ ...gatewayFilters, mobile: e.target.value })} + className="w-full border border-gray-200 rounded-xl p-2.5 text-xs outline-none focus:ring-1 focus:ring-indigo-500 font-mono" + dir="ltr" + /> +
+ +
+ + +
+
+ )} +
+
+
+ + {/* Table */} + {loadingGatewayTx ? ( +
+ +
+ ) : gatewayTxList.length === 0 ? ( +
+
+ +
+

تراکنشی در درگاه پرداخت اینترنتی یافت نشد.

+
+ ) : ( +
+ + + + + + + + + + + + + + {gatewayTxList.map((tx: any, i: number) => ( + + + + + + + + + + ))} + +
شماره تراکنش (Track ID)شناسه سفارششماره کارتمبلغ پرداختشماره موبایلتاریخ پرداختوضعیت تراکنش
{tx.trackId || '-'}{tx.orderId || '-'}{tx.cardNumber || '-'} + {(Number(tx.amount || 0) / 10).toLocaleString()} تومان + {tx.mobile || '-'} + {tx.paidAt ? new Date(tx.paidAt).toLocaleDateString('fa-IR') : '-'} + + + {tx.status === 1 || tx.status === 2 ? 'پرداخت موفق' : 'ناموفق'} + +
+
+ )} +
+ )} + + {/* ======================================================== */} + {/* MODAL: SUBMIT NEW REFUND / REVERSE */} + {/* ======================================================== */} + {newRefundModal && ( +
+
+
+

+ + ثبت درخواست استرداد وجه (Refund / Reverse) +

+ +
+ +
+
+ + setRefundForm({ ...refundForm, trackId: e.target.value })} + className="w-full border border-gray-200 rounded-xl p-3 font-mono outline-none focus:ring-2 focus:ring-rose-500" + dir="ltr" + /> +
+ +
+ + setRefundForm({ ...refundForm, amount: e.target.value })} + className="w-full border border-gray-200 rounded-xl p-3 font-mono outline-none focus:ring-2 focus:ring-rose-500" + dir="ltr" + /> +
+ +
+ + setRefundForm({ ...refundForm, cardNumber: e.target.value })} + className="w-full border border-gray-200 rounded-xl p-3 font-mono outline-none focus:ring-2 focus:ring-rose-500" + dir="ltr" + /> +
+ +
+ +