feat(sales-stats): вкладка «Оплаты» — success-rate и неудачные покупки

Новая вкладка в статистике продаж:
- success-rate платежей (оплачено/создано) — общий + по каждому шлюзу с
  цветными барами (зелёный ≥90%, жёлтый ≥70%, красный ниже);
- кол-во попыток оплат;
- «Неудачные покупки» — внутренние откаты при ошибке покупки (сигнал
  надёжности, не возвраты).
Данные из нового эндпоинта /payment-health. Шлюзы без активности не
показываются. Карточки 1/3 (без сирот), адаптив. 4 локали.
This commit is contained in:
c0mrade
2026-06-02 18:46:42 +03:00
parent ec23618a56
commit 436d9f860c
8 changed files with 172 additions and 5 deletions

View File

@@ -165,6 +165,21 @@ export interface DepositsStats {
daily_by_method: DailyDepositByMethodItem[];
}
export interface GatewaySuccessItem {
method: string;
total: number;
paid: number;
success_rate: number;
}
export interface PaymentHealth {
total_attempts: number;
total_paid: number;
success_rate: number;
failed_purchases: number;
by_gateway: GatewaySuccessItem[];
}
// ============ API ============
export const salesStatsApi = {
@@ -197,4 +212,9 @@ export const salesStatsApi = {
const response = await apiClient.get('/cabinet/admin/stats/sales/deposits', { params });
return response.data;
},
getPaymentHealth: async (params: SalesStatsParams = {}): Promise<PaymentHealth> => {
const response = await apiClient.get('/cabinet/admin/stats/sales/payment-health', { params });
return response.data;
},
};

View File

@@ -0,0 +1,115 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import type { SalesStatsParams } from '../../api/adminSalesStats';
import { salesStatsApi } from '../../api/adminSalesStats';
import { METHOD_LABELS } from '../../constants/paymentMethods';
import { SALES_STATS } from '../../constants/salesStats';
import { CardIcon, PercentIcon, WarningIcon } from '../../components/icons';
import PaymentMethodIcon from '../PaymentMethodIcon';
import { StatCard } from '../stats';
interface PaymentHealthTabProps {
params: SalesStatsParams;
}
/** Green when healthy, amber when shaky, red when most attempts fail. */
function rateColor(rate: number): string {
if (rate >= 90) return 'text-success-400';
if (rate >= 70) return 'text-warning-400';
return 'text-error-400';
}
function barColor(rate: number): string {
if (rate >= 90) return 'bg-success-500';
if (rate >= 70) return 'bg-warning-500';
return 'bg-error-500';
}
export function PaymentHealthTab({ params }: PaymentHealthTabProps) {
const { t } = useTranslation();
const { data, isLoading, isError } = useQuery({
queryKey: ['sales-stats', 'payment-health', params],
queryFn: () => salesStatsApi.getPaymentHealth(params),
staleTime: SALES_STATS.STALE_TIME,
placeholderData: keepPreviousData,
});
if (isLoading) {
return (
<div className="animate-pulse space-y-4">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="h-24 rounded-xl bg-dark-800/30" />
))}
</div>
);
}
if (isError || !data) {
return <div className="py-8 text-center text-error-400">{t('admin.salesStats.loadError')}</div>;
}
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard
label={t('admin.salesStats.payments.successRate')}
value={`${data.success_rate}%`}
icon={<PercentIcon className="h-5 w-5" />}
tone={data.success_rate >= 90 ? 'success' : data.success_rate >= 70 ? 'warning' : 'error'}
/>
<StatCard
label={t('admin.salesStats.payments.attempts')}
value={data.total_attempts}
icon={<CardIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('admin.salesStats.payments.failedPurchases')}
value={data.failed_purchases}
icon={<WarningIcon className="h-5 w-5" />}
tone="warning"
/>
</div>
<div className="bento-card">
<h4 className="mb-3 text-sm font-semibold text-dark-200">
{t('admin.salesStats.payments.byGateway')}
</h4>
{data.by_gateway.length === 0 ? (
<div className="py-6 text-center text-sm text-dark-400">{t('common.noData')}</div>
) : (
<div className="space-y-3">
{data.by_gateway.map((g) => (
<div key={g.method} className="flex items-center gap-3">
<PaymentMethodIcon method={g.method} className="h-5 w-5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-sm text-dark-200">
{METHOD_LABELS[g.method] || g.method}
</span>
<span className="shrink-0 text-xs text-dark-500">
{g.paid}/{g.total}
</span>
</div>
<div className="mt-1 flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-dark-700/60">
<div
className={`h-full rounded-full ${barColor(g.success_rate)}`}
style={{ width: `${g.success_rate}%` }}
/>
</div>
<span className={`shrink-0 text-xs font-medium ${rateColor(g.success_rate)}`}>
{g.success_rate}%
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@ export { DepositsTab } from './DepositsTab';
export { DonutChart } from './DonutChart';
export { DualAreaChart } from './DualAreaChart';
export { MultiSeriesAreaChart } from './MultiSeriesAreaChart';
export { PaymentHealthTab } from './PaymentHealthTab';
export { PeriodSelector } from './PeriodSelector';
export { RenewalsTab } from './RenewalsTab';
export { SalesTab } from './SalesTab';

View File

@@ -1296,7 +1296,8 @@
"sales": "Sales",
"renewals": "Renewals",
"addons": "Add-ons",
"deposits": "Deposits"
"deposits": "Deposits",
"payment": "Payments"
},
"summary": {
"revenue": "Revenue",
@@ -1360,6 +1361,12 @@
"dailyByMethod": "Daily Deposits by Payment Method",
"revenue": "Revenue"
},
"payments": {
"successRate": "Payment success rate",
"attempts": "Payment attempts",
"failedPurchases": "Failed purchases",
"byGateway": "Success rate by gateway"
},
"loadError": "Failed to load statistics"
},
"referralNetwork": {

View File

@@ -1169,7 +1169,8 @@
"sales": "فروش",
"renewals": "تمدید",
"addons": "خدمات اضافی",
"deposits": "شارژ"
"deposits": "شارژ",
"payment": "پرداخت‌ها"
},
"summary": {
"revenue": "درآمد",
@@ -1233,6 +1234,12 @@
"dailyByMethod": "شارژ روزانه بر اساس روش پرداخت",
"revenue": "درآمد"
},
"payments": {
"successRate": "نرخ موفقیت پرداخت",
"attempts": "تلاش‌های پرداخت",
"failedPurchases": "خریدهای ناموفق",
"byGateway": "نرخ موفقیت بر اساس درگاه"
},
"loadError": "بارگذاری آمار ناموفق بود"
},
"referralNetwork": {

View File

@@ -1318,7 +1318,8 @@
"sales": "Продажи",
"renewals": "Продления",
"addons": "Доп. услуги",
"deposits": "Пополнения"
"deposits": "Пополнения",
"payment": "Оплаты"
},
"summary": {
"revenue": "Доход",
@@ -1382,6 +1383,12 @@
"dailyByMethod": "Пополнения по дням по платёжкам",
"revenue": "Доход"
},
"payments": {
"successRate": "Успешность оплат",
"attempts": "Попыток оплат",
"failedPurchases": "Неудачные покупки",
"byGateway": "Успешность по шлюзам"
},
"loadError": "Не удалось загрузить статистику"
},
"referralNetwork": {

View File

@@ -1169,7 +1169,8 @@
"sales": "销售",
"renewals": "续订",
"addons": "附加服务",
"deposits": "充值"
"deposits": "充值",
"payment": "支付"
},
"summary": {
"revenue": "收入",
@@ -1233,6 +1234,12 @@
"dailyByMethod": "每日按支付方式充值",
"revenue": "收入"
},
"payments": {
"successRate": "支付成功率",
"attempts": "支付尝试",
"failedPurchases": "失败购买",
"byGateway": "各网关成功率"
},
"loadError": "加载统计失败"
},
"referralNetwork": {

View File

@@ -22,13 +22,14 @@ import { StatCard } from '../components/stats';
import {
AddonsTab,
DepositsTab,
PaymentHealthTab,
PeriodSelector,
RenewalsTab,
SalesTab,
TrialsTab,
} from '../components/sales-stats';
type TabId = 'trials' | 'sales' | 'renewals' | 'addons' | 'deposits';
type TabId = 'trials' | 'sales' | 'renewals' | 'addons' | 'deposits' | 'payment';
type Delta = { percent: number; trend: 'up' | 'down' | 'stable' };
@@ -124,6 +125,7 @@ export default function AdminSalesStats() {
{ id: 'renewals', label: t('admin.salesStats.tabs.renewals') },
{ id: 'addons', label: t('admin.salesStats.tabs.addons') },
{ id: 'deposits', label: t('admin.salesStats.tabs.deposits') },
{ id: 'payment', label: t('admin.salesStats.tabs.payment') },
];
return (
@@ -258,6 +260,7 @@ export default function AdminSalesStats() {
{activeTab === 'renewals' && <RenewalsTab params={params} />}
{activeTab === 'addons' && <AddonsTab params={params} />}
{activeTab === 'deposits' && <DepositsTab params={params} />}
{activeTab === 'payment' && <PaymentHealthTab params={params} />}
</div>
)}
</div>