admin/src/pages/reports/payment-statistics/payment-methods-report/PaymentMethodsReportPage.tsx

526 lines
22 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 } from 'react';
import { usePaymentMethodsReport } from '../core/_hooks';
import { PaymentMethodsFilters } from '../core/_models';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Table } from '@/components/ui/Table';
import { TableColumn } from '@/types';
import { JalaliDateTimePicker } from '@/components/ui/JalaliDateTimePicker';
import { PageContainer, PageTitle } from '@/components/ui/Typography';
import { Pagination } from '@/components/ui/Pagination';
import { Filter, TrendingUp, Users, DollarSign, CreditCard, CheckCircle, XCircle, X } from 'lucide-react';
import { formatWithThousands, persianToEnglish } from '@/utils/numberUtils';
import { PieChart } from '@/components/charts/PieChart';
const formatCurrency = (amount: number) => {
return formatWithThousands(amount) + ' تومان';
};
const formatDate = (dateString: string) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const formatPercentage = (value: number) => {
return formatWithThousands(value.toFixed(2)) + '%';
};
const getPaymentTypeLabel = (type: string): string => {
const labels: Record<string, string> = {
'bank-topup': 'افزایش موجودی کیف پول',
'card-to-card': 'پرداخت به روش کارت به کارت',
'debit-rial-wallet': 'پرداخت از کیف ریالی',
'debit-gold18k-wallet': 'پرداخت از کیف طلا',
};
return labels[type] || type;
};
const PaymentMethodsReportSkeleton = () => (
<>
{/* Summary Cards Skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 animate-pulse">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-200 dark:bg-gray-700 rounded-lg w-12 h-12"></div>
<div className="flex-1">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2"></div>
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
</div>
</div>
</div>
))}
</div>
{/* Pie Chart and Total Amount Skeleton */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<div className="lg:col-span-2 bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 animate-pulse">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-6"></div>
<div className="h-64 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 animate-pulse">
<div className="h-16 w-16 bg-gray-200 dark:bg-gray-700 rounded-full mx-auto mb-4"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mx-auto mb-2"></div>
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-40 mx-auto"></div>
</div>
</div>
{/* Payment Type Cards Skeleton */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6 animate-pulse">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-6"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="border-2 border-gray-200 dark:border-gray-700 rounded-lg p-5 bg-gray-50 dark:bg-gray-700/50">
<div className="h-5 bg-gray-200 dark:bg-gray-600 rounded w-32 mb-4"></div>
<div className="space-y-2.5">
{[...Array(5)].map((_, j) => (
<div key={j} className="flex justify-between">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-16"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-12"></div>
</div>
))}
</div>
</div>
))}
</div>
</div>
{/* Table Skeleton */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
{[...Array(10)].map((_, i) => (
<th key={i} className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-20"></div>
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{[...Array(5)].map((_, i) => (
<tr key={i} className="animate-pulse">
{[...Array(10)].map((_, j) => (
<td key={j} className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded"></div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
const PaymentMethodsReportPage = () => {
const [filters, setFilters] = useState<PaymentMethodsFilters>({
limit: 50,
offset: 0,
group_by_user: false,
});
const { data, isLoading, error } = usePaymentMethodsReport(filters);
const handleFilterChange = (key: keyof PaymentMethodsFilters, value: any) => {
setFilters(prev => ({
...prev,
[key]: value,
offset: 0,
}));
};
const handleDateRangeChange = (from: string | undefined, to: string | undefined) => {
setFilters(prev => ({
...prev,
date_range: {
from,
to,
},
offset: 0,
}));
};
const handleNumericFilterChange = (key: 'user_id', raw: string) => {
const converted = persianToEnglish(raw).replace(/[^\d]/g, '');
const numeric = converted ? Number(converted) : undefined;
handleFilterChange(key, numeric);
};
const handlePageChange = (page: number) => {
setFilters(prev => ({
...prev,
offset: (page - 1) * prev.limit,
}));
};
const handleClearFilters = () => {
setFilters({
limit: 50,
offset: 0,
group_by_user: false,
});
};
const columns: TableColumn[] = [
{
key: 'customer_name',
label: 'نام مشتری',
align: 'right',
},
{
key: 'customer_phone',
label: 'شماره تماس',
align: 'right',
},
{
key: 'payment_type',
label: 'نوع پرداخت',
align: 'right',
},
{
key: 'successful_count',
label: 'موفق',
align: 'right',
},
{
key: 'failed_count',
label: 'ناموفق',
align: 'right',
},
{
key: 'total_attempts',
label: 'کل تلاش‌ها',
align: 'right',
},
{
key: 'total_amount',
label: 'مجموع مبلغ',
align: 'right',
},
{
key: 'success_rate',
label: 'نرخ موفقیت',
align: 'right',
},
{
key: 'first_used_at',
label: 'اولین استفاده',
align: 'right',
},
{
key: 'last_used_at',
label: 'آخرین استفاده',
align: 'right',
},
];
const tableData = (data?.payment_methods || []).map(method => ({
customer_name: method.customer_name || '-',
customer_phone: method.customer_phone || '-',
payment_type: getPaymentTypeLabel(method.payment_type),
successful_count: formatWithThousands(method.successful_count),
failed_count: formatWithThousands(method.failed_count),
total_attempts: formatWithThousands(method.total_attempts),
total_amount: formatCurrency(method.total_amount),
success_rate: formatPercentage(method.success_rate),
first_used_at: formatDate(method.first_used_at),
last_used_at: formatDate(method.last_used_at),
})) || [];
const currentPage = Math.floor(filters.offset / filters.limit) + 1;
const totalPages = data ? Math.ceil(data.total / filters.limit) : 1;
return (
<PageContainer>
<PageTitle>گزارش روشهای پرداخت</PageTitle>
{/* Filters */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Filter className="h-5 w-5 text-gray-500" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">فیلترها</h3>
</div>
<Button
variant="secondary"
size="sm"
onClick={handleClearFilters}
className="flex items-center gap-2"
>
<X className="h-4 w-4" />
پاک کردن فیلترها
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
شناسه کاربر
</label>
<Input
value={filters.user_id?.toString() || ''}
onChange={(e) => handleNumericFilterChange('user_id', e.target.value)}
placeholder="مثلاً 456"
numeric
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
نوع پرداخت
</label>
<select
value={filters.payment_type || ''}
onChange={(e) => handleFilterChange('payment_type', e.target.value || undefined)}
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
>
<option value="">همه</option>
<option value="bank-topup">افزایش موجودی کیف پول</option>
<option value="card-to-card">پرداخت به روش کارت به کارت</option>
<option value="debit-rial-wallet">پرداخت از کیف ریالی</option>
<option value="debit-gold18k-wallet">پرداخت از کیف طلا</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
وضعیت
</label>
<select
value={filters.status || ''}
onChange={(e) => handleFilterChange('status', e.target.value || undefined)}
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
>
<option value="">همه</option>
<option value="pending">در انتظار</option>
<option value="paid">پرداخت شده</option>
<option value="failed">ناموفق</option>
<option value="refunded">مرجوع شده</option>
<option value="cancelled">لغو شده</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
تاریخ شروع
</label>
<JalaliDateTimePicker
value={filters.date_range?.from}
onChange={(value) => handleDateRangeChange(value, filters.date_range?.to)}
placeholder="انتخاب تاریخ شروع"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
تاریخ پایان
</label>
<JalaliDateTimePicker
value={filters.date_range?.to}
onChange={(value) => handleDateRangeChange(filters.date_range?.from, value)}
placeholder="انتخاب تاریخ پایان"
/>
</div>
<div className="flex items-end">
<label className="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={filters.group_by_user || false}
onChange={(e) => handleFilterChange('group_by_user', e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600"
/>
گروهبندی بر اساس کاربر
</label>
</div>
</div>
</div>
{/* Summary Cards */}
{data?.summary && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
<CreditCard className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">کل تراکنشها</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.summary.total_transactions)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">تراکنشهای موفق</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.summary.successful_transactions)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-red-100 dark:bg-red-900 rounded-lg">
<XCircle className="h-5 w-5 text-red-600 dark:text-red-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">تراکنشهای ناموفق</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.summary.failed_transactions)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
<TrendingUp className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">نرخ موفقیت کلی</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatPercentage(data.summary.overall_success_rate)}
</p>
</div>
</div>
</div>
</div>
{/* Payment Type Breakdown */}
{Object.keys(data.summary.by_payment_type).length > 0 && (
<>
{/* Pie Chart and Total Amount */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
{/* Pie Chart */}
<div className="lg:col-span-2 bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<h3 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-6">
نمودار توزیع روشهای پرداخت
</h3>
<PieChart
data={Object.entries(data.summary.by_payment_type).map(([type, stats]) => ({
name: getPaymentTypeLabel(type),
value: stats.percentage,
}))}
title="درصد استفاده از هر روش پرداخت"
colors={['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#14b8a6', '#f97316']}
/>
</div>
{/* Total Amount Card */}
<div className="bg-gradient-to-br from-yellow-50 to-yellow-100 dark:from-yellow-900/20 dark:to-yellow-800/20 shadow-sm border-2 border-yellow-200 dark:border-yellow-800 rounded-lg p-6 flex flex-col justify-center">
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 bg-yellow-500 dark:bg-yellow-600 rounded-full mb-4 shadow-lg">
<DollarSign className="h-8 w-8 text-white" />
</div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">مجموع مبلغ</p>
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">
{formatCurrency(data.summary.total_amount)}
</p>
</div>
</div>
</div>
{/* Payment Type Cards */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6">
<h3 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-6">
آمار تفکیکی هر روش پرداخت
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
{Object.entries(data.summary.by_payment_type).map(([type, stats]) => (
<div
key={type}
className="border-2 border-gray-200 dark:border-gray-700 rounded-lg p-5 bg-gray-50 dark:bg-gray-700/50 hover:shadow-md transition-shadow"
>
<h4 className="font-bold text-base text-gray-900 dark:text-gray-100 mb-4 pb-2 border-b border-gray-200 dark:border-gray-600">{getPaymentTypeLabel(type)}</h4>
<div className="space-y-2.5 text-base">
<div className="flex justify-between items-center">
<span className="text-gray-700 dark:text-gray-300 font-medium">کل:</span>
<span className="font-bold text-gray-900 dark:text-gray-100">{formatWithThousands(stats.count)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-700 dark:text-gray-300 font-medium">موفق:</span>
<span className="font-bold text-green-600 dark:text-green-400">{formatWithThousands(stats.success_count)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-700 dark:text-gray-300 font-medium">ناموفق:</span>
<span className="font-bold text-red-600 dark:text-red-400">{formatWithThousands(stats.failed_count)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-700 dark:text-gray-300 font-medium">نرخ موفقیت:</span>
<span className="font-bold text-blue-600 dark:text-blue-400">{formatPercentage(stats.success_rate)}</span>
</div>
<div className="flex justify-between items-center pt-2 border-t border-gray-200 dark:border-gray-600">
<span className="text-gray-700 dark:text-gray-300 font-medium">درصد از کل:</span>
<span className="font-bold text-purple-600 dark:text-purple-400">{formatPercentage(stats.percentage)}</span>
</div>
</div>
</div>
))}
</div>
</div>
</>
)}
</>
)}
{/* Table */}
{isLoading ? (
<PaymentMethodsReportSkeleton />
) : error ? (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-600 dark:text-red-400">خطا در دریافت دادهها</p>
</div>
) : (
<>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="overflow-x-auto">
<Table columns={columns} data={tableData} loading={isLoading} />
</div>
</div>
{data && data.total > 0 && totalPages > 1 && (
<div className="mt-4 flex justify-center">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
itemsPerPage={filters.limit}
totalItems={data.total}
/>
</div>
)}
{data && data.total === 0 && (
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8 text-center">
<p className="text-gray-600 dark:text-gray-400">دادهای یافت نشد</p>
</div>
)}
</>
)}
</PageContainer>
);
};
export default PaymentMethodsReportPage;