1026 lines
43 KiB
TypeScript
1026 lines
43 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
||
import {
|
||
CreditCard,
|
||
Search,
|
||
RefreshCw,
|
||
Eye,
|
||
Copy,
|
||
CheckCircle2,
|
||
XCircle,
|
||
Clock,
|
||
Filter,
|
||
DollarSign,
|
||
TrendingUp,
|
||
AlertTriangle,
|
||
Receipt,
|
||
User,
|
||
ShoppingBag,
|
||
ArrowUpDown,
|
||
ExternalLink,
|
||
ShieldCheck,
|
||
RotateCcw,
|
||
Activity,
|
||
Globe,
|
||
Monitor,
|
||
Code,
|
||
Send,
|
||
Undo2,
|
||
Printer,
|
||
Wallet,
|
||
} from 'lucide-react';
|
||
import { toast } from 'react-hot-toast';
|
||
import api from '../services/api';
|
||
import Spinner from '../components/ui/Spinner';
|
||
import Pagination from '../components/ui/Pagination';
|
||
import Button from '../components/ui/Button';
|
||
import Badge from '../components/ui/Badge';
|
||
import ThSort from '../components/ui/ThSort';
|
||
import Modal from '../components/ui/Modal';
|
||
import MaskableField from '../components/ui/MaskableField';
|
||
import TransactionReceiptModal, { type TransactionReceiptData } from '../components/TransactionReceiptModal';
|
||
import { useTableParams } from '../utils/useTableParams';
|
||
|
||
interface Transaction {
|
||
id: string;
|
||
userId: string;
|
||
orderId?: string | null;
|
||
amount: number | string;
|
||
amountRials?: string | number | null;
|
||
gateway: string;
|
||
trackId?: string | null;
|
||
refNumber?: string | null;
|
||
cardNumber?: string | null;
|
||
status: string;
|
||
resultCode?: number | null;
|
||
message?: string | null;
|
||
description?: string | null;
|
||
type: string;
|
||
ipAddress?: string | null;
|
||
userAgent?: string | null;
|
||
rawRequest?: any;
|
||
rawResponse?: any;
|
||
paidAt?: string | null;
|
||
createdAt: string;
|
||
user?: {
|
||
id: string;
|
||
firstName: string;
|
||
lastName: string;
|
||
mobile: string;
|
||
email?: string;
|
||
} | null;
|
||
order?: {
|
||
orderNumber: string;
|
||
id: string;
|
||
trackingNumber?: string;
|
||
totalAmount: number | string;
|
||
status: string;
|
||
paymentMethod?: string;
|
||
} | null;
|
||
}
|
||
|
||
interface Stats {
|
||
totalCount: number;
|
||
verifiedCount: number;
|
||
pendingCount: number;
|
||
failedCount: number;
|
||
totalVolume: number;
|
||
todayVolume: number;
|
||
successRate: number;
|
||
}
|
||
|
||
interface GatewayHealth {
|
||
gatewayName: string;
|
||
status: 'ONLINE' | 'DEGRADED' | 'OFFLINE';
|
||
latencyMs: number;
|
||
merchantConfigured: boolean;
|
||
activeMerchant: string;
|
||
message: string;
|
||
checkedAt: string;
|
||
}
|
||
|
||
export default function Transactions() {
|
||
const {
|
||
search,
|
||
status: statusFilter,
|
||
gateway: gatewayFilter,
|
||
type: typeFilter,
|
||
sortBy,
|
||
sortOrder,
|
||
page,
|
||
setParam,
|
||
handleSort,
|
||
clearFilters,
|
||
} = useTableParams({
|
||
defaultSortBy: 'createdAt',
|
||
defaultSortOrder: 'desc',
|
||
defaultPage: 1,
|
||
defaultLimit: 15,
|
||
});
|
||
|
||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||
const [stats, setStats] = useState<Stats | null>(null);
|
||
const [health, setHealth] = useState<GatewayHealth | null>(null);
|
||
const [healthLoading, setHealthLoading] = useState(false);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [totalPages, setTotalPages] = useState(1);
|
||
const [totalCount, setTotalCount] = useState(0);
|
||
|
||
// Details Modal
|
||
const [selectedTx, setSelectedTx] = useState<Transaction | null>(null);
|
||
const [liveInquiryLoading, setLiveInquiryLoading] = useState(false);
|
||
const [liveInquiryData, setLiveInquiryData] = useState<any>(null);
|
||
const [showRawLogs, setShowRawLogs] = useState(false);
|
||
|
||
// Receipt Modal State
|
||
const [receiptModalOpen, setReceiptModalOpen] = useState(false);
|
||
const [receiptData, setReceiptData] = useState<TransactionReceiptData | null>(null);
|
||
|
||
// Refund Modal State & Confirmation (Supports Wallet and Zibal Gateway)
|
||
const [refundModalTx, setRefundModalTx] = useState<Transaction | null>(null);
|
||
const [refundTarget, setRefundTarget] = useState<'wallet' | 'zibal'>('wallet');
|
||
const [refundAmount, setRefundAmount] = useState('');
|
||
const [refundReason, setRefundReason] = useState('');
|
||
const [tryReverse, setTryReverse] = useState(true);
|
||
const [isSubmittingRefund, setIsSubmittingRefund] = useState(false);
|
||
|
||
const openReceiptModal = (tx: Transaction) => {
|
||
setReceiptData({
|
||
trackId: tx.trackId || '-',
|
||
refNumber: tx.refNumber || '-',
|
||
amountTomans: tx.amount,
|
||
amountRials: tx.amountRials || Number(tx.amount) * 10,
|
||
statusText: tx.status === 'VERIFIED' ? 'پرداخت موفق' : tx.status === 'PENDING' ? 'در انتظار پرداخت' : 'ناموفق',
|
||
orderNumber: tx.order?.orderNumber || (tx.orderId ? `سفارش ${tx.orderId.slice(0, 8)}` : '-'),
|
||
orderId: tx.orderId || undefined,
|
||
cardNumber: tx.cardNumber || '-',
|
||
paidAt: tx.paidAt ? new Date(tx.paidAt).toLocaleDateString('fa-IR') : undefined,
|
||
createdAt: new Date(tx.createdAt).toLocaleDateString('fa-IR', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
}),
|
||
mobile: tx.user?.mobile || '-',
|
||
description: tx.description || 'پرداخت سفارش آنلاین کنینا',
|
||
psp: tx.gateway === 'zibal' ? 'زیبال / بهپرداخت ملت' : tx.gateway,
|
||
ip: tx.ipAddress || '-',
|
||
});
|
||
setReceiptModalOpen(true);
|
||
};
|
||
|
||
const openDetailsModal = (tx: Transaction) => {
|
||
setSelectedTx(tx);
|
||
setLiveInquiryData(null);
|
||
setShowRawLogs(false);
|
||
};
|
||
|
||
const handleCopy = (text: string, label: string) => {
|
||
navigator.clipboard.writeText(text);
|
||
toast.success(`${label} با موفقیت کپی شد`);
|
||
};
|
||
|
||
const handleLiveInquiry = async (trackId: string) => {
|
||
try {
|
||
setLiveInquiryLoading(true);
|
||
const res = await api.post('/payment/admin/inquiry', { trackId });
|
||
if (res.data) {
|
||
setLiveInquiryData(res.data);
|
||
toast.success('استعلام تراکنش با موفقیت دریافت شد');
|
||
}
|
||
} catch (e: any) {
|
||
toast.error(e?.response?.data?.message || 'خطا در دریافت استعلام تراکنش');
|
||
} finally {
|
||
setLiveInquiryLoading(false);
|
||
}
|
||
};
|
||
|
||
|
||
const openRefundModal = (tx: Transaction) => {
|
||
setRefundModalTx(tx);
|
||
setRefundTarget(tx.type === 'WALLET_TOPUP' || !tx.trackId ? 'wallet' : 'wallet');
|
||
setRefundAmount(String(tx.amount || ''));
|
||
setRefundReason(`استرداد تراکنش #${tx.trackId || tx.id.slice(0, 8)}`);
|
||
setTryReverse(true);
|
||
};
|
||
|
||
const handleExecuteRefund = async () => {
|
||
if (!refundModalTx) return;
|
||
|
||
try {
|
||
setIsSubmittingRefund(true);
|
||
const parsedAmount = refundAmount ? Number(refundAmount) : Number(refundModalTx.amount);
|
||
|
||
if (refundTarget === 'wallet') {
|
||
const userId = refundModalTx.userId || refundModalTx.user?.id;
|
||
if (!userId) {
|
||
toast.error('کاربر مرتبط با این تراکنش یافت نشد');
|
||
return;
|
||
}
|
||
await api.post(`/users/${userId}/wallet/adjust`, {
|
||
amount: parsedAmount,
|
||
type: 'CREDIT',
|
||
description: refundReason || `استرداد وجه تراکنش #${refundModalTx.trackId || refundModalTx.id.slice(0, 8)}`,
|
||
});
|
||
toast.success('مبلغ با موفقیت به کیف پول کاربر عودت داده شد');
|
||
} else {
|
||
if (!refundModalTx.trackId) {
|
||
toast.error('شناسه تراکنش زیبال (Track ID) برای این پرداخت موجود نیست');
|
||
return;
|
||
}
|
||
const res = await api.post('/payment/admin/refund', {
|
||
trackId: refundModalTx.trackId,
|
||
amount: parsedAmount ? parsedAmount * 10 : undefined, // Rial
|
||
tryReverse: tryReverse,
|
||
cardNumber: refundModalTx.cardNumber || undefined,
|
||
description: refundReason || undefined,
|
||
});
|
||
if (res.data?.result === 1 || res.data?.status === 1 || res.data?.success) {
|
||
toast.success(res.data?.message || 'درخواست استرداد وجه با موفقیت به زیبال ارسال شد.');
|
||
} else {
|
||
toast.error(res.data?.message || 'خطا در استرداد آنلاین از زیبال');
|
||
}
|
||
}
|
||
|
||
setRefundModalTx(null);
|
||
fetchTransactions();
|
||
fetchStats();
|
||
} catch (e: any) {
|
||
toast.error(e?.response?.data?.message || 'خطا در انجام عملیات استرداد');
|
||
} finally {
|
||
setIsSubmittingRefund(false);
|
||
}
|
||
};
|
||
|
||
const fetchStats = async () => {
|
||
try {
|
||
const res = await api.get('/payment/admin/stats');
|
||
if (res.data) {
|
||
setStats(res.data);
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to fetch stats', e);
|
||
}
|
||
};
|
||
|
||
const fetchHealth = async (silent = false) => {
|
||
try {
|
||
if (!silent) setHealthLoading(true);
|
||
const res = await api.get('/payment/admin/health');
|
||
if (res.data) {
|
||
setHealth(res.data);
|
||
if (!silent) toast.success(`وضعیت درگاه زیبال: ${res.data.status} (${res.data.latencyMs}ms)`);
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to fetch gateway health', e);
|
||
if (!silent) toast.error('عدم دسترسی به سرویس Health Check درگاه');
|
||
} finally {
|
||
if (!silent) setHealthLoading(false);
|
||
}
|
||
};
|
||
|
||
const fetchTransactions = useCallback(async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
const params = new URLSearchParams();
|
||
params.append('page', String(page));
|
||
params.append('limit', '15');
|
||
if (search) params.append('search', search);
|
||
if (statusFilter) params.append('status', statusFilter);
|
||
if (gatewayFilter) params.append('gateway', gatewayFilter);
|
||
if (typeFilter) params.append('type', typeFilter);
|
||
if (sortBy) params.append('sortBy', sortBy);
|
||
if (sortOrder) params.append('sortOrder', sortOrder);
|
||
|
||
const res = await api.get(`/payment/admin/transactions?${params.toString()}`);
|
||
if (res.data) {
|
||
setTransactions(res.data.transactions || res.data.data || []);
|
||
setTotalPages(res.data.totalPages || res.data.meta?.lastPage || 1);
|
||
setTotalCount(res.data.totalCount || res.data.meta?.total || 0);
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to fetch transactions', e);
|
||
toast.error('خطا در دریافت لیست تراکنشها');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [page, search, statusFilter, gatewayFilter, typeFilter, sortBy, sortOrder]);
|
||
|
||
useEffect(() => {
|
||
|
||
fetchTransactions();
|
||
fetchStats();
|
||
fetchHealth(true);
|
||
}, [fetchTransactions]);
|
||
|
||
const handleReconcile = async () => {
|
||
try {
|
||
setIsReconciling(true);
|
||
const res = await api.post('/payment/admin/reconcile');
|
||
toast.success(
|
||
`انطباق انجام شد: ${res.data.verified} تایید شده، ${res.data.failed} ناموفق`,
|
||
);
|
||
fetchTransactions();
|
||
fetchStats();
|
||
} catch (e) {
|
||
console.error('Reconcile error', e);
|
||
toast.error('خطا در اجرای استعلام خودکار');
|
||
} finally {
|
||
setIsReconciling(false);
|
||
}
|
||
};
|
||
|
||
|
||
const handleManualVerify = async (txId: string) => {
|
||
if (!window.confirm('آیا از تایید دستی این تراکنش (افزایش موجودی کیف پول یا تایید سفارش) اطمینان دارید؟')) return;
|
||
try {
|
||
const res = await api.post(`/payment/admin/manual-verify/${txId}`, {
|
||
adminNote: 'تایید دستی فیش واریزی توسط مدیر سیستم',
|
||
});
|
||
toast.success(res.data?.message || 'تراکنش با موفقیت تایید شد');
|
||
fetchTransactions();
|
||
fetchStats();
|
||
if (selectedTx) setSelectedTx(null);
|
||
} catch (e: any) {
|
||
toast.error(e.response?.data?.message || 'خطا در تایید دستی تراکنش');
|
||
}
|
||
};
|
||
|
||
const handleManualReject = async (txId: string) => {
|
||
const reason = window.prompt('لطفاً دلیل رد تراکنش را وارد کنید:', 'فیش واریزی نامعتبر است');
|
||
if (reason === null) return;
|
||
try {
|
||
const res = await api.post(`/payment/admin/manual-reject/${txId}`, { reason });
|
||
toast.success(res.data?.message || 'تراکنش رد شد');
|
||
fetchTransactions();
|
||
fetchStats();
|
||
if (selectedTx) setSelectedTx(null);
|
||
} catch (e: any) {
|
||
toast.error(e.response?.data?.message || 'خطا در رد تراکنش');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* 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">
|
||
<Receipt className="w-7 h-7 text-purple-600" />
|
||
لاگها و مدیریت تراکنشهای مالی
|
||
</h2>
|
||
<p className="text-gray-500 font-medium mt-1">
|
||
رهگیری لحظهای پرداختهای آنلاین زیبال، بررسی لاگهای خام و عیبیابی تراکنشها
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<button
|
||
onClick={() => fetchHealth(false)}
|
||
disabled={healthLoading}
|
||
className="bg-purple-50 text-purple-700 hover:bg-purple-100 border border-purple-200 text-xs font-bold px-3.5 py-2.5 rounded-xl transition-all flex items-center gap-2 shadow-sm disabled:opacity-50"
|
||
>
|
||
<Activity className={`w-4 h-4 text-purple-600 ${healthLoading ? 'animate-spin' : ''}`} />
|
||
<span>پایش سلامت درگاه (Ping)</span>
|
||
</button>
|
||
|
||
<button
|
||
onClick={handleReconcile}
|
||
disabled={isReconciling}
|
||
className="bg-indigo-50 text-indigo-700 hover:bg-indigo-100 border border-indigo-200 text-xs font-bold px-3.5 py-2.5 rounded-xl transition-all flex items-center gap-2 shadow-sm disabled:opacity-50"
|
||
>
|
||
<RotateCcw className={`w-4 h-4 ${isReconciling ? 'animate-spin' : ''}`} />
|
||
<span>{isReconciling ? 'در حال استعلام...' : 'انطباق خودکار تراکنشها'}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Gateway Health Monitor Banner */}
|
||
{health && (
|
||
<div className="bg-gradient-to-r from-slate-900 via-indigo-950 to-purple-950 p-4 rounded-2xl text-white shadow-md flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 border border-indigo-800/40">
|
||
<div className="flex items-center gap-3">
|
||
<div className="relative flex items-center justify-center">
|
||
<span className={`w-3.5 h-3.5 rounded-full ${health.status === 'ONLINE' ? 'bg-emerald-500 animate-ping' : health.status === 'DEGRADED' ? 'bg-amber-500' : 'bg-rose-500'}`} />
|
||
<span className={`absolute w-2.5 h-2.5 rounded-full ${health.status === 'ONLINE' ? 'bg-emerald-400' : health.status === 'DEGRADED' ? 'bg-amber-400' : 'bg-rose-400'}`} />
|
||
</div>
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-black text-sm">{health.gatewayName}</span>
|
||
<span className={`text-[10px] font-black px-2 py-0.5 rounded-full ${health.status === 'ONLINE' ? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30' : 'bg-amber-500/20 text-amber-300 border border-amber-500/30'}`}>
|
||
{health.status}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-4 text-xs font-mono font-bold text-gray-300 mt-1">
|
||
<span>Latency: {health.latencyMs}ms</span>
|
||
<span>•</span>
|
||
<span>Updated: {new Date(health.lastChecked).toLocaleTimeString('fa-IR')}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
|
||
{/* Summary Stat Cards - 2 cols on mobile (2 rows), 4 cols on desktop */}
|
||
{stats && (
|
||
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
||
<div className="bg-white p-4 sm:p-5 rounded-2xl border border-gray-200 shadow-xs flex items-center justify-between">
|
||
<div className="min-w-0">
|
||
<p className="text-[11px] sm:text-xs font-bold text-gray-500 truncate">حجم کل تراکنشهای موفق</p>
|
||
<p className="text-base sm:text-xl font-black text-gray-900 mt-1">
|
||
{Number(stats.totalVolume).toLocaleString('fa-IR')}{' '}
|
||
<span className="text-[10px] sm:text-xs font-normal text-gray-400">تومان</span>
|
||
</p>
|
||
</div>
|
||
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-purple-50 text-purple-600 rounded-2xl flex items-center justify-center shrink-0">
|
||
<DollarSign className="w-5 h-5 sm:w-6 sm:h-6" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white p-4 sm:p-5 rounded-2xl border border-gray-200 shadow-xs flex items-center justify-between">
|
||
<div className="min-w-0">
|
||
<p className="text-[11px] sm:text-xs font-bold text-gray-500 truncate">پرداختهای موفق امروز</p>
|
||
<p className="text-base sm:text-xl font-black text-emerald-600 mt-1">
|
||
{Number(stats.todayVolume).toLocaleString('fa-IR')}{' '}
|
||
<span className="text-[10px] sm:text-xs font-normal text-gray-400">تومان</span>
|
||
</p>
|
||
</div>
|
||
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-emerald-50 text-emerald-600 rounded-2xl flex items-center justify-center shrink-0">
|
||
<TrendingUp className="w-5 h-5 sm:w-6 sm:h-6" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white p-4 sm:p-5 rounded-2xl border border-gray-200 shadow-xs flex items-center justify-between">
|
||
<div className="min-w-0">
|
||
<p className="text-[11px] sm:text-xs font-bold text-gray-500 truncate">نرخ موفقیت پرداختها</p>
|
||
<p className="text-base sm:text-xl font-black text-indigo-600 mt-1">
|
||
٪{Number(stats.successRate).toLocaleString('fa-IR')}
|
||
</p>
|
||
</div>
|
||
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-indigo-50 text-indigo-600 rounded-2xl flex items-center justify-center shrink-0">
|
||
<ShieldCheck className="w-5 h-5 sm:w-6 sm:h-6" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white p-4 sm:p-5 rounded-2xl border border-gray-200 shadow-xs flex items-center justify-between">
|
||
<div className="min-w-0">
|
||
<p className="text-[11px] sm:text-xs font-bold text-gray-500 truncate">وضعیت تراکنشها</p>
|
||
<div className="flex flex-wrap items-center gap-2 mt-1 text-[10px] sm:text-xs font-bold">
|
||
<span className="text-emerald-600">✓ {stats.verifiedCount}</span>
|
||
<span className="text-amber-500">⏳ {stats.pendingCount}</span>
|
||
<span className="text-rose-600">✗ {stats.failedCount}</span>
|
||
</div>
|
||
</div>
|
||
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-gray-50 text-gray-600 rounded-2xl flex items-center justify-center shrink-0">
|
||
<Receipt className="w-5 h-5 sm:w-6 sm:h-6" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Filter and Search Bar */}
|
||
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-xs space-y-4">
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">
|
||
{/* Search Input */}
|
||
<div className="lg:col-span-2 relative">
|
||
<Search className="w-4 h-4 text-gray-400 absolute right-3.5 top-3.5" />
|
||
<input
|
||
type="text"
|
||
value={search}
|
||
onChange={(e) => setParam('search', e.target.value)}
|
||
placeholder="جستجو بر اساس نام، موبایل، Track ID، شماره مرجع..."
|
||
className="w-full pl-3 pr-10 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-xs font-bold outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500"
|
||
/>
|
||
</div>
|
||
|
||
{/* Status Filter */}
|
||
<div>
|
||
<select
|
||
value={statusFilter}
|
||
onChange={(e) => setParam('status', e.target.value)}
|
||
className="w-full py-2.5 px-3 bg-gray-50 border border-gray-200 rounded-xl text-xs font-bold outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500"
|
||
>
|
||
<option value="">همه وضعیتها</option>
|
||
<option value="VERIFIED">موفق (VERIFIED)</option>
|
||
<option value="PENDING">در انتظار (PENDING)</option>
|
||
<option value="FAILED">ناموفق (FAILED)</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Gateway Filter */}
|
||
<div>
|
||
<select
|
||
value={gatewayFilter}
|
||
onChange={(e) => setParam('gateway', e.target.value)}
|
||
className="w-full py-2.5 px-3 bg-gray-50 border border-gray-200 rounded-xl text-xs font-bold outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500"
|
||
>
|
||
<option value="">همه درگاهها</option>
|
||
<option value="zibal">درگاه زیبال (Zibal)</option>
|
||
<option value="wallet">کیف پول (Wallet)</option>
|
||
<option value="card">کارت به کارت</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Type Filter */}
|
||
<div>
|
||
<select
|
||
value={typeFilter}
|
||
onChange={(e) => setParam('type', e.target.value)}
|
||
className="w-full py-2.5 px-3 bg-gray-50 border border-gray-200 rounded-xl text-xs font-bold outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500"
|
||
>
|
||
<option value="">همه انواع</option>
|
||
<option value="ORDER">پرداخت سفارش (ORDER)</option>
|
||
<option value="WALLET_TOPUP">شارژ کیف پول (TOPUP)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Total records found */}
|
||
<div className="flex items-center justify-between text-xs text-gray-500 font-bold pt-2 border-t border-gray-100">
|
||
<span>مجموع {totalCount.toLocaleString('fa-IR')} تراکنش ثبت شده</span>
|
||
<button
|
||
type="button"
|
||
onClick={clearFilters}
|
||
className="text-purple-600 hover:underline cursor-pointer"
|
||
>
|
||
پاک کردن فیلترها
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Transactions Table */}
|
||
<div className="bg-white rounded-2xl border border-gray-200 shadow-xs overflow-hidden">
|
||
{isLoading ? (
|
||
<div className="p-12 flex justify-center">
|
||
<Spinner size="lg" className="text-purple-600" />
|
||
</div>
|
||
) : transactions.length === 0 ? (
|
||
<div className="p-12 text-center text-gray-500 font-bold">
|
||
هیچ تراکنشی با فیلترهای مشخص شده یافت نشد.
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-right border-collapse">
|
||
<thead>
|
||
<tr className="bg-gray-50/80 border-b border-gray-200 text-[11px] font-black text-gray-500">
|
||
<th className="p-4">کاربر خریدار</th>
|
||
<th className="p-4">سفارش / نوع</th>
|
||
<ThSort
|
||
column="amount"
|
||
label="مبلغ (تومان)"
|
||
currentSortBy={sortBy}
|
||
currentSortOrder={sortOrder}
|
||
onSort={handleSort}
|
||
/>
|
||
<th className="p-4">درگاه</th>
|
||
<th className="p-4">شناسههای پرداخت</th>
|
||
<ThSort
|
||
column="status"
|
||
label="وضعیت"
|
||
currentSortBy={sortBy}
|
||
currentSortOrder={sortOrder}
|
||
onSort={handleSort}
|
||
/>
|
||
<ThSort
|
||
column="createdAt"
|
||
label="تاریخ و زمان"
|
||
currentSortBy={sortBy}
|
||
currentSortOrder={sortOrder}
|
||
onSort={handleSort}
|
||
/>
|
||
<th className="p-4 text-center">عملیات</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100 text-xs font-bold font-vazir">
|
||
{transactions.map((tx) => {
|
||
const isVerified = tx.status === 'VERIFIED';
|
||
const isPending = tx.status === 'PENDING';
|
||
const isFailed = tx.status === 'FAILED';
|
||
|
||
return (
|
||
<tr key={tx.id} className="hover:bg-gray-50/60 transition-colors">
|
||
{/* User Info */}
|
||
<td className="p-4">
|
||
{tx.user ? (
|
||
<div>
|
||
<p className="font-black text-gray-900">
|
||
{tx.user.firstName} {tx.user.lastName}
|
||
</p>
|
||
<p className="text-[11px] text-gray-400 font-mono mt-0.5 dir-ltr text-right">
|
||
{tx.user.mobile}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<span className="text-gray-400">کاربر مهمان</span>
|
||
)}
|
||
</td>
|
||
|
||
{/* Order / Type */}
|
||
<td className="p-4">
|
||
{tx.type === 'WALLET_TOPUP' ? (
|
||
<Badge variant="blue" size="sm">شارژ کیف پول</Badge>
|
||
) : (
|
||
<div>
|
||
<span className="text-purple-700 font-mono text-[11px] font-black dir-ltr block">
|
||
{tx.order?.trackingNumber || 'سفارش آنلاین'}
|
||
</span>
|
||
<span className="text-[10px] text-gray-400 font-medium">
|
||
خرید محصول
|
||
</span>
|
||
</div>
|
||
)}
|
||
</td>
|
||
|
||
{/* Amount */}
|
||
<td className="p-4 font-black text-gray-900 text-sm whitespace-nowrap">
|
||
{Number(tx.amount).toLocaleString('fa-IR')} <span className="text-[10px] text-gray-400 font-normal">تومان</span>
|
||
</td>
|
||
|
||
{/* Gateway */}
|
||
<td className="p-4">
|
||
<Badge variant="purple" size="sm">
|
||
{tx.gateway === 'zibal' ? 'زیبال (Zibal)' : tx.gateway}
|
||
</Badge>
|
||
</td>
|
||
|
||
{/* Tracking Numbers */}
|
||
<td className="p-4 font-mono text-[11px]">
|
||
{tx.trackId && (
|
||
<div className="flex items-center gap-1 text-gray-800">
|
||
<span className="text-gray-400 text-[10px]">Track:</span>
|
||
<span className="dir-ltr">{tx.trackId}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleCopy(tx.trackId!, 'Track ID')}
|
||
className="text-gray-400 hover:text-purple-600 cursor-pointer"
|
||
>
|
||
<Copy className="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
{tx.refNumber && (
|
||
<div className="flex items-center gap-1 text-emerald-700 mt-0.5">
|
||
<span className="text-gray-400 text-[10px]">Ref:</span>
|
||
<span className="dir-ltr">{tx.refNumber}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleCopy(tx.refNumber!, 'شماره مرجع')}
|
||
className="text-gray-400 hover:text-purple-600 cursor-pointer"
|
||
>
|
||
<Copy className="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</td>
|
||
|
||
{/* Status */}
|
||
<td className="p-4">
|
||
{isVerified && (
|
||
<Badge variant="success" size="sm" icon={<CheckCircle2 className="w-3 h-3" />}>
|
||
پرداخت تایید شده
|
||
</Badge>
|
||
)}
|
||
{isPending && (
|
||
<Badge variant="warning" size="sm" icon={<Clock className="w-3 h-3" />}>
|
||
در انتظار تایید
|
||
</Badge>
|
||
)}
|
||
{isFailed && (
|
||
<Badge variant="danger" size="sm" icon={<XCircle className="w-3 h-3" />}>
|
||
ناموفق / لغو شده
|
||
</Badge>
|
||
)}
|
||
</td>
|
||
|
||
{/* Date & Time */}
|
||
<td className="p-4 text-gray-500 text-[11px] whitespace-nowrap">
|
||
{new Date(tx.createdAt).toLocaleDateString('fa-IR', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})}
|
||
</td>
|
||
|
||
{/* Actions */}
|
||
<td className="p-4 text-center">
|
||
<div className="flex items-center justify-center gap-1.5">
|
||
<button
|
||
type="button"
|
||
title="مشاهده رسید دیجیتال تراکنش"
|
||
onClick={() => openReceiptModal(tx)}
|
||
className="p-1.5 bg-purple-50 text-purple-600 hover:bg-purple-100 rounded-lg transition-colors cursor-pointer"
|
||
>
|
||
<Printer className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
title="مشاهده جزئیات کامل و استعلام زنده"
|
||
onClick={() => openDetailsModal(tx)}
|
||
className="p-1.5 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg transition-colors cursor-pointer"
|
||
>
|
||
<Eye className="w-4 h-4" />
|
||
</button>
|
||
{isVerified && (
|
||
<button
|
||
type="button"
|
||
title="استرداد وجه تراکنش (کیف پول یا درگاه زیبال)"
|
||
onClick={() => openRefundModal(tx)}
|
||
className="p-1.5 bg-rose-50 text-rose-600 hover:bg-rose-100 rounded-lg transition-colors cursor-pointer"
|
||
>
|
||
<Undo2 className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{/* Pagination */}
|
||
{totalPages > 1 && (
|
||
<div className="p-4 border-t border-gray-100">
|
||
<Pagination
|
||
currentPage={page}
|
||
totalPages={totalPages}
|
||
onPageChange={(p) => setParam('page', p, false)}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Transaction Details Modal */}
|
||
{selectedTx && (
|
||
<Modal
|
||
isOpen={!!selectedTx}
|
||
onClose={() => setSelectedTx(null)}
|
||
title={`جزئیات تراکنش #${selectedTx.trackId || selectedTx.id.slice(0, 8)}`}
|
||
icon={Receipt}
|
||
maxWidth="3xl"
|
||
footer={
|
||
<div className="flex flex-wrap items-center gap-2 w-full justify-between">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
startIcon={Printer}
|
||
onClick={() => {
|
||
const tx = selectedTx;
|
||
setSelectedTx(null);
|
||
openReceiptModal(tx);
|
||
}}
|
||
>
|
||
چاپ فاکتور و رسید
|
||
</Button>
|
||
|
||
<div className="flex items-center gap-2">
|
||
{selectedTx.status === 'VERIFIED' && (
|
||
<Button
|
||
variant="danger"
|
||
size="sm"
|
||
startIcon={Undo2}
|
||
onClick={() => {
|
||
const tx = selectedTx;
|
||
setSelectedTx(null);
|
||
openRefundModal(tx);
|
||
}}
|
||
>
|
||
استرداد وجه
|
||
</Button>
|
||
)}
|
||
{selectedTx.status === 'PENDING' && (
|
||
<>
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
onClick={() => handleManualVerify(selectedTx.id)}
|
||
>
|
||
تایید دستی
|
||
</Button>
|
||
<Button
|
||
variant="danger"
|
||
size="sm"
|
||
onClick={() => handleManualReject(selectedTx.id)}
|
||
>
|
||
رد تراکنش
|
||
</Button>
|
||
</>
|
||
)}
|
||
<Button variant="secondary" size="sm" onClick={() => setSelectedTx(null)}>
|
||
بستن
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
>
|
||
<div className="space-y-4">
|
||
{/* Quick Overview Badges */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 bg-gray-50 p-4 rounded-2xl border border-gray-100 text-xs">
|
||
<div>
|
||
<p className="text-gray-400 font-bold">مبلغ تراکنش:</p>
|
||
<p className="text-base font-black text-gray-900 mt-0.5">
|
||
{Number(selectedTx.amount).toLocaleString('fa-IR')} تومان
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-gray-400 font-bold">درگاه پرداخت:</p>
|
||
<p className="font-bold text-indigo-700 mt-0.5">{selectedTx.gateway}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-gray-400 font-bold">وضعیت:</p>
|
||
<p className="font-bold mt-0.5">
|
||
{selectedTx.status === 'VERIFIED' ? (
|
||
<Badge variant="success" size="sm">موفق</Badge>
|
||
) : (
|
||
<Badge variant="danger" size="sm">{selectedTx.status}</Badge>
|
||
)}
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-gray-400 font-bold">نوع عملیات:</p>
|
||
<p className="font-bold text-gray-800 mt-0.5">
|
||
{selectedTx.type === 'WALLET_TOPUP' ? 'شارژ کیف پول' : 'خرید محصول'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Technical Identifiers */}
|
||
<div className="space-y-2 bg-slate-900 text-slate-200 p-4 rounded-2xl font-mono text-xs">
|
||
<div className="flex justify-between">
|
||
<span className="text-slate-400">Transaction ID:</span>
|
||
<span className="text-purple-300 font-bold">{selectedTx.id}</span>
|
||
</div>
|
||
{selectedTx.trackId && (
|
||
<div className="flex justify-between">
|
||
<span className="text-slate-400">Zibal TrackID:</span>
|
||
<span className="text-emerald-300 font-bold">{selectedTx.trackId}</span>
|
||
</div>
|
||
)}
|
||
{selectedTx.refNumber && (
|
||
<div className="flex justify-between">
|
||
<span className="text-slate-400">Shaparak RRN:</span>
|
||
<span className="text-amber-300 font-bold">{selectedTx.refNumber}</span>
|
||
</div>
|
||
)}
|
||
{selectedTx.cardNumber && (
|
||
<div className="flex justify-between">
|
||
<span className="text-slate-400">Card Mask:</span>
|
||
<span className="text-sky-300 font-bold">{selectedTx.cardNumber}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Live Shaparak Inquiry Section */}
|
||
{selectedTx.trackId && (
|
||
<div className="border border-purple-100 bg-purple-50/50 p-4 rounded-2xl space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2 text-purple-900 font-black text-xs">
|
||
<Activity className="w-4 h-4 text-purple-600" />
|
||
<span>استعلام برخط و زنده از درگاه پرداخت زیبال</span>
|
||
</div>
|
||
<Button
|
||
size="xs"
|
||
variant="outline"
|
||
isLoading={liveInquiryLoading}
|
||
onClick={() => handleLiveInquiry(selectedTx.trackId!)}
|
||
>
|
||
استعلام مجدد
|
||
</Button>
|
||
</div>
|
||
{liveInquiryData && (
|
||
<pre className="bg-slate-900 text-emerald-400 p-3 rounded-xl text-[11px] font-mono overflow-x-auto dir-ltr">
|
||
{JSON.stringify(liveInquiryData, null, 2)}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Modal>
|
||
)}
|
||
|
||
{/* Parity Refund Modal (Supports Wallet and Zibal Gateway) */}
|
||
{refundModalTx && (
|
||
<Modal
|
||
isOpen={!!refundModalTx}
|
||
onClose={() => setRefundModalTx(null)}
|
||
title={`استرداد وجه تراکنش #${refundModalTx.trackId || refundModalTx.id.slice(0, 8)}`}
|
||
icon={Undo2}
|
||
maxWidth="lg"
|
||
footer={
|
||
<div className="flex items-center justify-end gap-2 w-full">
|
||
<Button variant="outline" size="sm" onClick={() => setRefundModalTx(null)}>
|
||
انصراف
|
||
</Button>
|
||
<Button
|
||
variant={refundTarget === 'wallet' ? 'primary' : 'danger'}
|
||
size="sm"
|
||
isLoading={isSubmittingRefund}
|
||
onClick={handleExecuteRefund}
|
||
>
|
||
{refundTarget === 'wallet' ? 'تایید و افزایش اعتبار کیف پول' : 'ارسال درخواست استرداد به زیبال'}
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="space-y-4 text-xs font-vazir">
|
||
{/* Destination selector */}
|
||
<div>
|
||
<label className="block font-bold text-gray-700 mb-2">روش عودت و بازگشت وجه:</label>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => setRefundTarget('wallet')}
|
||
className={`p-3.5 rounded-2xl border text-right transition-all cursor-pointer flex flex-col gap-1.5 ${
|
||
refundTarget === 'wallet'
|
||
? 'border-purple-600 bg-purple-50/60 ring-2 ring-purple-600/20'
|
||
: 'border-gray-200 hover:border-gray-300'
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-2 font-black text-gray-900">
|
||
<Wallet className="w-4 h-4 text-purple-600" />
|
||
<span>شارژ کیف پول کاربر</span>
|
||
</div>
|
||
<p className="text-[11px] text-gray-500 font-medium leading-relaxed">
|
||
افزایش فوری اعتبار کیف پول جهت خریدهای بعدی مشتری
|
||
</p>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setRefundTarget('zibal')}
|
||
className={`p-3.5 rounded-2xl border text-right transition-all cursor-pointer flex flex-col gap-1.5 ${
|
||
refundTarget === 'zibal'
|
||
? 'border-rose-600 bg-rose-50/60 ring-2 ring-rose-600/20'
|
||
: 'border-gray-200 hover:border-gray-300'
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-2 font-black text-gray-900">
|
||
<CreditCard className="w-4 h-4 text-rose-600" />
|
||
<span>استرداد شاپرک (زیبال)</span>
|
||
</div>
|
||
<p className="text-[11px] text-gray-500 font-medium leading-relaxed">
|
||
برگشت مستقیم به کارت بانکی مشتری از طریق درگاه پرداخت
|
||
</p>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block font-bold text-gray-700 mb-1">مبلغ استرداد به تومان</label>
|
||
<input
|
||
type="number"
|
||
value={refundAmount}
|
||
onChange={(e) => setRefundAmount(e.target.value)}
|
||
placeholder="مبلغ استرداد"
|
||
className="w-full border border-gray-200 rounded-xl p-3 font-mono outline-none focus:ring-2 focus:ring-purple-500 font-bold"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block font-bold text-gray-700 mb-1">علت و توضیحات استرداد</label>
|
||
<textarea
|
||
rows={2}
|
||
value={refundReason}
|
||
onChange={(e) => setRefundReason(e.target.value)}
|
||
placeholder="علت لغو سفارش یا مرجوعی کالا..."
|
||
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-purple-500"
|
||
/>
|
||
</div>
|
||
|
||
{refundTarget === 'zibal' && (
|
||
<div className="flex items-center gap-2 pt-1">
|
||
<input
|
||
type="checkbox"
|
||
id="txReverseOption"
|
||
checked={tryReverse}
|
||
onChange={(e) => setTryReverse(e.target.checked)}
|
||
className="rounded text-rose-600 focus:ring-rose-500 cursor-pointer"
|
||
/>
|
||
<label htmlFor="txReverseOption" className="font-bold text-gray-700 cursor-pointer text-[11px]">
|
||
اولویت با برگشت آنی شاپرک (Reverse) قبل از تسویه حساب
|
||
</label>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Modal>
|
||
)}
|
||
|
||
{/* Digital Receipt Modal */}
|
||
{receiptModalOpen && receiptData && (
|
||
<TransactionReceiptModal
|
||
isOpen={receiptModalOpen}
|
||
onClose={() => setReceiptModalOpen(false)}
|
||
data={receiptData}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|