870 lines
37 KiB
TypeScript
870 lines
37 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
|
||
} 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';
|
||
|
||
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?: {
|
||
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 [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 [isReconciling, setIsReconciling] = useState(false);
|
||
const [page, setPage] = useState(1);
|
||
const [totalPages, setTotalPages] = useState(1);
|
||
const [totalItems, setTotalItems] = useState(0);
|
||
|
||
// Filters
|
||
const [search, setSearch] = useState('');
|
||
const [statusFilter, setStatusFilter] = useState('');
|
||
const [gatewayFilter, setGatewayFilter] = useState('');
|
||
const [typeFilter, setTypeFilter] = useState('');
|
||
const [sortBy, setSortBy] = useState('createdAt');
|
||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||
|
||
// 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);
|
||
|
||
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);
|
||
params.append('sortBy', sortBy);
|
||
params.append('sortOrder', sortOrder);
|
||
|
||
const res = await api.get(`/payment/admin/transactions?${params.toString()}`);
|
||
if (res.data) {
|
||
setTransactions(res.data.data || []);
|
||
setTotalPages(res.data.meta?.lastPage || 1);
|
||
setTotalItems(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 handleCopy = (text: string, title: string) => {
|
||
navigator.clipboard.writeText(text);
|
||
toast.success(`${title} کپی شد!`);
|
||
};
|
||
|
||
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 handleLiveInquiry = async (txId: string) => {
|
||
try {
|
||
setLiveInquiryLoading(true);
|
||
const res = await api.get(`/payment/admin/live-inquiry/${txId}`);
|
||
setLiveInquiryData(res.data);
|
||
toast.success('استعلام زنده زیبال با موفقیت دریافت شد');
|
||
fetchTransactions();
|
||
fetchStats();
|
||
} catch (e: any) {
|
||
const msg = e.response?.data?.message || 'خطا در استعلام از درگاه زیبال';
|
||
toast.error(msg);
|
||
} finally {
|
||
setLiveInquiryLoading(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>
|
||
<p className="text-[11px] text-gray-300 mt-0.5">{health.message}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-4 text-xs font-mono text-gray-300 mr-auto sm:mr-0">
|
||
<div className="bg-white/10 px-3 py-1.5 rounded-xl border border-white/10 flex items-center gap-1.5">
|
||
<Clock className="w-3.5 h-3.5 text-purple-300" />
|
||
<span>تاخیر: <strong>{health.latencyMs} ms</strong></span>
|
||
</div>
|
||
<div className="bg-white/10 px-3 py-1.5 rounded-xl border border-white/10 flex items-center gap-1.5">
|
||
<ShieldCheck className="w-3.5 h-3.5 text-emerald-300" />
|
||
<span>مرچنت: <strong>{health.activeMerchant}</strong></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Summary Stat Cards */}
|
||
{stats && (
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm flex items-center justify-between">
|
||
<div>
|
||
<p className="text-xs font-bold text-gray-500">حجم کل تراکنشهای موفق</p>
|
||
<p className="text-xl font-black text-gray-900 mt-1">
|
||
{Number(stats.totalVolume).toLocaleString('fa-IR')}{' '}
|
||
<span className="text-xs font-normal text-gray-400">تومان</span>
|
||
</p>
|
||
</div>
|
||
<div className="w-12 h-12 bg-purple-50 text-purple-600 rounded-2xl flex items-center justify-center">
|
||
<DollarSign className="w-6 h-6" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm flex items-center justify-between">
|
||
<div>
|
||
<p className="text-xs font-bold text-gray-500">پرداختهای موفق امروز</p>
|
||
<p className="text-xl font-black text-emerald-600 mt-1">
|
||
{Number(stats.todayVolume).toLocaleString('fa-IR')}{' '}
|
||
<span className="text-xs font-normal text-gray-400">تومان</span>
|
||
</p>
|
||
</div>
|
||
<div className="w-12 h-12 bg-emerald-50 text-emerald-600 rounded-2xl flex items-center justify-center">
|
||
<TrendingUp className="w-6 h-6" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm flex items-center justify-between">
|
||
<div>
|
||
<p className="text-xs font-bold text-gray-500">نرخ موفقیت پرداختها</p>
|
||
<p className="text-xl font-black text-indigo-600 mt-1">
|
||
٪{Number(stats.successRate).toLocaleString('fa-IR')}
|
||
</p>
|
||
</div>
|
||
<div className="w-12 h-12 bg-indigo-50 text-indigo-600 rounded-2xl flex items-center justify-center">
|
||
<ShieldCheck className="w-6 h-6" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm flex items-center justify-between">
|
||
<div>
|
||
<p className="text-xs font-bold text-gray-500">وضعیت تراکنشها</p>
|
||
<div className="flex items-center gap-3 mt-1 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-12 h-12 bg-gray-50 text-gray-600 rounded-2xl flex items-center justify-center">
|
||
<Receipt className="w-6 h-6" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Filter and Search Bar */}
|
||
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm 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) => {
|
||
setSearch(e.target.value);
|
||
setPage(1);
|
||
}}
|
||
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"
|
||
/>
|
||
</div>
|
||
|
||
{/* Status Filter */}
|
||
<div>
|
||
<select
|
||
value={statusFilter}
|
||
onChange={(e) => {
|
||
setStatusFilter(e.target.value);
|
||
setPage(1);
|
||
}}
|
||
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"
|
||
>
|
||
<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) => {
|
||
setGatewayFilter(e.target.value);
|
||
setPage(1);
|
||
}}
|
||
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"
|
||
>
|
||
<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) => {
|
||
setTypeFilter(e.target.value);
|
||
setPage(1);
|
||
}}
|
||
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"
|
||
>
|
||
<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>مجموع {totalItems.toLocaleString('fa-IR')} تراکنش ثبت شده</span>
|
||
<button
|
||
onClick={() => {
|
||
setSearch('');
|
||
setStatusFilter('');
|
||
setGatewayFilter('');
|
||
setTypeFilter('');
|
||
setPage(1);
|
||
}}
|
||
className="text-purple-600 hover:underline"
|
||
>
|
||
پاک کردن فیلترها
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Transactions Table */}
|
||
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm 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>
|
||
<th className="p-4">مبلغ (تومان)</th>
|
||
<th className="p-4">درگاه</th>
|
||
<th className="p-4">شناسههای پرداخت</th>
|
||
<th className="p-4">وضعیت</th>
|
||
<th className="p-4">تاریخ و زمان</th>
|
||
<th className="p-4 text-center">عملیات</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100 text-xs font-bold">
|
||
{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' ? (
|
||
<span className="bg-blue-50 text-blue-700 px-2.5 py-1 rounded-lg text-[10px] font-black">
|
||
شارژ کیف پول
|
||
</span>
|
||
) : (
|
||
<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 font-mono text-sm">
|
||
{Number(tx.amount).toLocaleString('fa-IR')}
|
||
</td>
|
||
|
||
{/* Gateway */}
|
||
<td className="p-4">
|
||
<span className="bg-indigo-50 text-indigo-700 px-2.5 py-1 rounded-lg text-[10px] font-black">
|
||
{tx.gateway === 'zibal' ? 'زیبال (Zibal)' : tx.gateway}
|
||
</span>
|
||
</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"
|
||
>
|
||
<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"
|
||
>
|
||
<Copy className="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</td>
|
||
|
||
{/* Status */}
|
||
<td className="p-4">
|
||
{isVerified && (
|
||
<span className="inline-flex items-center gap-1 bg-emerald-50 text-emerald-700 border border-emerald-200 px-2.5 py-1 rounded-full text-[10px] font-black">
|
||
<CheckCircle2 className="w-3 h-3" />
|
||
موفق
|
||
</span>
|
||
)}
|
||
{isPending && (
|
||
<span className="inline-flex items-center gap-1 bg-amber-50 text-amber-700 border border-amber-200 px-2.5 py-1 rounded-full text-[10px] font-black">
|
||
<Clock className="w-3 h-3" />
|
||
در انتظار
|
||
</span>
|
||
)}
|
||
{isFailed && (
|
||
<span className="inline-flex items-center gap-1 bg-rose-50 text-rose-700 border border-rose-200 px-2.5 py-1 rounded-full text-[10px] font-black">
|
||
<XCircle className="w-3 h-3" />
|
||
ناموفق
|
||
</span>
|
||
)}
|
||
</td>
|
||
|
||
{/* Date */}
|
||
<td className="p-4 text-[11px] text-gray-500">
|
||
{new Date(tx.createdAt).toLocaleDateString('fa-IR', {
|
||
year: 'numeric',
|
||
month: 'short',
|
||
day: 'numeric',
|
||
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
|
||
onClick={() => {
|
||
setSelectedTx(tx);
|
||
setLiveInquiryData(null);
|
||
setShowRawLogs(false);
|
||
}}
|
||
className="p-2 bg-gray-100 hover:bg-purple-50 text-gray-600 hover:text-purple-600 rounded-xl transition-all"
|
||
title="مشاهده جزئیات و عیبیابی"
|
||
>
|
||
<Eye className="w-4 h-4" />
|
||
</button>
|
||
|
||
{tx.gateway === 'zibal' && tx.trackId && (
|
||
<button
|
||
onClick={() => {
|
||
setSelectedTx(tx);
|
||
handleLiveInquiry(tx.id);
|
||
}}
|
||
className="p-2 bg-indigo-50 hover:bg-indigo-100 text-indigo-600 rounded-xl transition-all"
|
||
title="استعلام زنده از زیبال"
|
||
>
|
||
<RotateCcw className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{/* Pagination */}
|
||
<div className="p-4 border-t border-gray-100">
|
||
<Pagination
|
||
currentPage={page}
|
||
totalPages={totalPages}
|
||
onPageChange={(p) => setPage(p)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Diagnostics and Details Modal */}
|
||
{selectedTx && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||
<div className="bg-white rounded-3xl w-full max-w-2xl max-h-[90vh] overflow-y-auto border border-gray-200 shadow-2xl p-6 sm:p-8 space-y-6">
|
||
<div className="flex items-center justify-between border-b border-gray-100 pb-4">
|
||
<h3 className="text-lg font-black text-gray-900 flex items-center gap-2">
|
||
<Receipt className="w-5 h-5 text-purple-600" />
|
||
جزئیات تراکنش و ریشهیابی خطا
|
||
</h3>
|
||
<button
|
||
onClick={() => {
|
||
setSelectedTx(null);
|
||
setLiveInquiryData(null);
|
||
}}
|
||
className="text-gray-400 hover:text-gray-700 text-xl font-bold"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
{/* Quick Grid Info */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-xs bg-gray-50 p-4 rounded-2xl border border-gray-200">
|
||
<div>
|
||
<span className="text-gray-400 block font-medium">وضعیت تراکنش:</span>
|
||
<span
|
||
className={`font-black mt-0.5 inline-block ${
|
||
selectedTx.status === 'VERIFIED'
|
||
? 'text-emerald-600'
|
||
: selectedTx.status === 'PENDING'
|
||
? 'text-amber-500'
|
||
: 'text-rose-600'
|
||
}`}
|
||
>
|
||
{selectedTx.status}
|
||
</span>
|
||
</div>
|
||
|
||
<div>
|
||
<span className="text-gray-400 block font-medium">مبلغ تراکنش:</span>
|
||
<span className="font-black text-gray-900 mt-0.5 inline-block">
|
||
{Number(selectedTx.amount).toLocaleString('fa-IR')} تومان
|
||
</span>
|
||
</div>
|
||
|
||
<div>
|
||
<span className="text-gray-400 block font-medium">درگاه پرداخت:</span>
|
||
<span className="font-black text-gray-900 mt-0.5 inline-block">
|
||
{selectedTx.gateway}
|
||
</span>
|
||
</div>
|
||
|
||
<div>
|
||
<span className="text-gray-400 block font-medium">شناسه پیگیری (Track ID):</span>
|
||
<span className="font-mono font-bold text-gray-900 dir-ltr inline-block">
|
||
{selectedTx.trackId || 'ثبت نشده'}
|
||
</span>
|
||
</div>
|
||
|
||
<div>
|
||
<span className="text-gray-400 block font-medium">شماره مرجع بانکی:</span>
|
||
<span className="font-mono font-bold text-gray-900 dir-ltr inline-block">
|
||
{selectedTx.refNumber || 'ثبت نشده'}
|
||
</span>
|
||
</div>
|
||
|
||
<div>
|
||
<span className="text-gray-400 block font-medium">شماره کارت ماسک شده:</span>
|
||
<span className="font-mono font-bold text-gray-900 dir-ltr inline-block">
|
||
{selectedTx.cardNumber || 'ثبت نشده'}
|
||
</span>
|
||
</div>
|
||
|
||
{selectedTx.ipAddress && (
|
||
<div>
|
||
<span className="text-gray-400 block font-medium">IP کلاینت:</span>
|
||
<span className="font-mono font-bold text-gray-700 dir-ltr inline-block">
|
||
{selectedTx.ipAddress}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{selectedTx.userAgent && (
|
||
<div className="sm:col-span-2">
|
||
<span className="text-gray-400 block font-medium">User-Agent دستگاه:</span>
|
||
<span className="text-[10px] font-mono text-gray-600 line-clamp-1 dir-ltr inline-block">
|
||
{selectedTx.userAgent}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Error Message / Reason */}
|
||
{selectedTx.message && (
|
||
<div
|
||
className={`p-4 rounded-2xl border ${
|
||
selectedTx.status === 'VERIFIED'
|
||
? 'bg-emerald-50 border-emerald-200 text-emerald-900'
|
||
: 'bg-rose-50 border-rose-200 text-rose-900'
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-2 font-black text-xs mb-1">
|
||
{selectedTx.status === 'VERIFIED' ? (
|
||
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
|
||
) : (
|
||
<AlertTriangle className="w-4 h-4 text-rose-600" />
|
||
)}
|
||
<span>پیام و نتیجه گزارش درگاه:</span>
|
||
</div>
|
||
<p className="text-xs font-bold leading-relaxed">{selectedTx.message}</p>
|
||
{selectedTx.resultCode && (
|
||
<p className="text-[11px] font-mono mt-1 text-gray-500">
|
||
کد نتیجه زیبال: {selectedTx.resultCode}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Live Inquiry Section */}
|
||
<div className="border border-indigo-100 bg-indigo-50/50 p-5 rounded-2xl space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<RotateCcw className="w-4 h-4 text-indigo-600" />
|
||
<h4 className="font-black text-xs text-indigo-900">
|
||
استعلام زنده از سرور زیبال (Live Gateway Inquiry)
|
||
</h4>
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => handleLiveInquiry(selectedTx.id)}
|
||
disabled={liveInquiryLoading}
|
||
className="bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-bold px-3 py-1.5 rounded-xl transition-all flex items-center gap-1.5 disabled:opacity-50"
|
||
>
|
||
<RefreshCw
|
||
className={`w-3.5 h-3.5 ${liveInquiryLoading ? 'animate-spin' : ''}`}
|
||
/>
|
||
<span>استعلام زنده لحظهای</span>
|
||
</button>
|
||
</div>
|
||
|
||
{liveInquiryData && (
|
||
<div className="bg-white p-4 rounded-xl border border-indigo-200 space-y-2 text-xs font-mono">
|
||
<div className="flex justify-between text-gray-700 font-bold font-vazir">
|
||
<span>وضعیت زیبال:</span>
|
||
<span className="text-indigo-600">{liveInquiryData.statusMessage}</span>
|
||
</div>
|
||
<pre className="text-[11px] bg-slate-900 text-emerald-400 p-3 rounded-lg overflow-x-auto dir-ltr">
|
||
{JSON.stringify(liveInquiryData.gatewayResponse, null, 2)}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Raw Request & Response Inspector Toggle */}
|
||
<div className="border border-gray-200 rounded-2xl overflow-hidden">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowRawLogs(!showRawLogs)}
|
||
className="w-full bg-gray-50 hover:bg-gray-100 px-4 py-3 text-xs font-bold text-gray-700 flex items-center justify-between transition-colors"
|
||
>
|
||
<span className="flex items-center gap-2">
|
||
<Code className="w-4 h-4 text-purple-600" />
|
||
مشاهده لاگ خام درخواست و پاسخ (Raw JSON Payload)
|
||
</span>
|
||
<span>{showRawLogs ? '▲ بستن' : '▼ باز کردن'}</span>
|
||
</button>
|
||
|
||
{showRawLogs && (
|
||
<div className="p-4 bg-slate-950 text-emerald-400 text-[11px] font-mono space-y-4 dir-ltr overflow-x-auto">
|
||
{selectedTx.rawRequest && (
|
||
<div>
|
||
<p className="text-purple-400 font-bold mb-1">// Raw Request:</p>
|
||
<pre className="p-2 bg-slate-900 rounded-lg">{JSON.stringify(selectedTx.rawRequest, null, 2)}</pre>
|
||
</div>
|
||
)}
|
||
{selectedTx.rawResponse && (
|
||
<div>
|
||
<p className="text-cyan-400 font-bold mb-1">// Raw Response:</p>
|
||
<pre className="p-2 bg-slate-900 rounded-lg">{JSON.stringify(selectedTx.rawResponse, null, 2)}</pre>
|
||
</div>
|
||
)}
|
||
{!selectedTx.rawRequest && !selectedTx.rawResponse && (
|
||
<p className="text-gray-500">// لاگ خامی برای این تراکنش ثبت نشده است.</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Modal Actions */}
|
||
<div className="flex items-center justify-between gap-3 pt-2">
|
||
<div className="flex items-center gap-2">
|
||
{selectedTx.status !== 'VERIFIED' && (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleManualVerify(selectedTx.id)}
|
||
className="bg-emerald-600 hover:bg-emerald-700 text-white font-black px-4 py-2.5 rounded-xl text-xs flex items-center gap-1.5 shadow-md shadow-emerald-600/20 transition-all cursor-pointer"
|
||
>
|
||
<CheckCircle2 className="w-4 h-4" />
|
||
<span>تایید دستی و اعمال مالی</span>
|
||
</button>
|
||
)}
|
||
{selectedTx.status === 'PENDING' && (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleManualReject(selectedTx.id)}
|
||
className="bg-rose-50 hover:bg-rose-100 text-rose-700 border border-rose-200 font-bold px-4 py-2.5 rounded-xl text-xs flex items-center gap-1.5 transition-all cursor-pointer"
|
||
>
|
||
<XCircle className="w-4 h-4" />
|
||
<span>رد کردن تراکنش</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setSelectedTx(null);
|
||
setLiveInquiryData(null);
|
||
setShowRawLogs(false);
|
||
}}
|
||
className="bg-gray-100 hover:bg-gray-200 text-gray-800 font-bold px-6 py-2.5 rounded-xl text-xs transition-colors cursor-pointer"
|
||
>
|
||
بستن
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|