canina/frontend/admin-panel/src/pages/Transactions.tsx
parsa aghaei 9d63f9a7a4
All checks were successful
Deploy Canina / deploy (push) Successful in 31s
feat(admin): standardize UI components, modal viewports, URL syncing, and add live monitoring
2026-08-23 08:40:37 +03:30

1026 lines
43 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}