mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat(sales-stats): вкладка «Оплаты» — success-rate и неудачные покупки
Новая вкладка в статистике продаж: - success-rate платежей (оплачено/создано) — общий + по каждому шлюзу с цветными барами (зелёный ≥90%, жёлтый ≥70%, красный ниже); - кол-во попыток оплат; - «Неудачные покупки» — внутренние откаты при ошибке покупки (сигнал надёжности, не возвраты). Данные из нового эндпоинта /payment-health. Шлюзы без активности не показываются. Карточки 1/3 (без сирот), адаптив. 4 локали.
This commit is contained in:
@@ -165,6 +165,21 @@ export interface DepositsStats {
|
|||||||
daily_by_method: DailyDepositByMethodItem[];
|
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 ============
|
// ============ API ============
|
||||||
|
|
||||||
export const salesStatsApi = {
|
export const salesStatsApi = {
|
||||||
@@ -197,4 +212,9 @@ export const salesStatsApi = {
|
|||||||
const response = await apiClient.get('/cabinet/admin/stats/sales/deposits', { params });
|
const response = await apiClient.get('/cabinet/admin/stats/sales/deposits', { params });
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getPaymentHealth: async (params: SalesStatsParams = {}): Promise<PaymentHealth> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/stats/sales/payment-health', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
115
src/components/sales-stats/PaymentHealthTab.tsx
Normal file
115
src/components/sales-stats/PaymentHealthTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ export { DepositsTab } from './DepositsTab';
|
|||||||
export { DonutChart } from './DonutChart';
|
export { DonutChart } from './DonutChart';
|
||||||
export { DualAreaChart } from './DualAreaChart';
|
export { DualAreaChart } from './DualAreaChart';
|
||||||
export { MultiSeriesAreaChart } from './MultiSeriesAreaChart';
|
export { MultiSeriesAreaChart } from './MultiSeriesAreaChart';
|
||||||
|
export { PaymentHealthTab } from './PaymentHealthTab';
|
||||||
export { PeriodSelector } from './PeriodSelector';
|
export { PeriodSelector } from './PeriodSelector';
|
||||||
export { RenewalsTab } from './RenewalsTab';
|
export { RenewalsTab } from './RenewalsTab';
|
||||||
export { SalesTab } from './SalesTab';
|
export { SalesTab } from './SalesTab';
|
||||||
|
|||||||
@@ -1296,7 +1296,8 @@
|
|||||||
"sales": "Sales",
|
"sales": "Sales",
|
||||||
"renewals": "Renewals",
|
"renewals": "Renewals",
|
||||||
"addons": "Add-ons",
|
"addons": "Add-ons",
|
||||||
"deposits": "Deposits"
|
"deposits": "Deposits",
|
||||||
|
"payment": "Payments"
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"revenue": "Revenue",
|
"revenue": "Revenue",
|
||||||
@@ -1360,6 +1361,12 @@
|
|||||||
"dailyByMethod": "Daily Deposits by Payment Method",
|
"dailyByMethod": "Daily Deposits by Payment Method",
|
||||||
"revenue": "Revenue"
|
"revenue": "Revenue"
|
||||||
},
|
},
|
||||||
|
"payments": {
|
||||||
|
"successRate": "Payment success rate",
|
||||||
|
"attempts": "Payment attempts",
|
||||||
|
"failedPurchases": "Failed purchases",
|
||||||
|
"byGateway": "Success rate by gateway"
|
||||||
|
},
|
||||||
"loadError": "Failed to load statistics"
|
"loadError": "Failed to load statistics"
|
||||||
},
|
},
|
||||||
"referralNetwork": {
|
"referralNetwork": {
|
||||||
|
|||||||
@@ -1169,7 +1169,8 @@
|
|||||||
"sales": "فروش",
|
"sales": "فروش",
|
||||||
"renewals": "تمدید",
|
"renewals": "تمدید",
|
||||||
"addons": "خدمات اضافی",
|
"addons": "خدمات اضافی",
|
||||||
"deposits": "شارژ"
|
"deposits": "شارژ",
|
||||||
|
"payment": "پرداختها"
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"revenue": "درآمد",
|
"revenue": "درآمد",
|
||||||
@@ -1233,6 +1234,12 @@
|
|||||||
"dailyByMethod": "شارژ روزانه بر اساس روش پرداخت",
|
"dailyByMethod": "شارژ روزانه بر اساس روش پرداخت",
|
||||||
"revenue": "درآمد"
|
"revenue": "درآمد"
|
||||||
},
|
},
|
||||||
|
"payments": {
|
||||||
|
"successRate": "نرخ موفقیت پرداخت",
|
||||||
|
"attempts": "تلاشهای پرداخت",
|
||||||
|
"failedPurchases": "خریدهای ناموفق",
|
||||||
|
"byGateway": "نرخ موفقیت بر اساس درگاه"
|
||||||
|
},
|
||||||
"loadError": "بارگذاری آمار ناموفق بود"
|
"loadError": "بارگذاری آمار ناموفق بود"
|
||||||
},
|
},
|
||||||
"referralNetwork": {
|
"referralNetwork": {
|
||||||
|
|||||||
@@ -1318,7 +1318,8 @@
|
|||||||
"sales": "Продажи",
|
"sales": "Продажи",
|
||||||
"renewals": "Продления",
|
"renewals": "Продления",
|
||||||
"addons": "Доп. услуги",
|
"addons": "Доп. услуги",
|
||||||
"deposits": "Пополнения"
|
"deposits": "Пополнения",
|
||||||
|
"payment": "Оплаты"
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"revenue": "Доход",
|
"revenue": "Доход",
|
||||||
@@ -1382,6 +1383,12 @@
|
|||||||
"dailyByMethod": "Пополнения по дням по платёжкам",
|
"dailyByMethod": "Пополнения по дням по платёжкам",
|
||||||
"revenue": "Доход"
|
"revenue": "Доход"
|
||||||
},
|
},
|
||||||
|
"payments": {
|
||||||
|
"successRate": "Успешность оплат",
|
||||||
|
"attempts": "Попыток оплат",
|
||||||
|
"failedPurchases": "Неудачные покупки",
|
||||||
|
"byGateway": "Успешность по шлюзам"
|
||||||
|
},
|
||||||
"loadError": "Не удалось загрузить статистику"
|
"loadError": "Не удалось загрузить статистику"
|
||||||
},
|
},
|
||||||
"referralNetwork": {
|
"referralNetwork": {
|
||||||
|
|||||||
@@ -1169,7 +1169,8 @@
|
|||||||
"sales": "销售",
|
"sales": "销售",
|
||||||
"renewals": "续订",
|
"renewals": "续订",
|
||||||
"addons": "附加服务",
|
"addons": "附加服务",
|
||||||
"deposits": "充值"
|
"deposits": "充值",
|
||||||
|
"payment": "支付"
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"revenue": "收入",
|
"revenue": "收入",
|
||||||
@@ -1233,6 +1234,12 @@
|
|||||||
"dailyByMethod": "每日按支付方式充值",
|
"dailyByMethod": "每日按支付方式充值",
|
||||||
"revenue": "收入"
|
"revenue": "收入"
|
||||||
},
|
},
|
||||||
|
"payments": {
|
||||||
|
"successRate": "支付成功率",
|
||||||
|
"attempts": "支付尝试",
|
||||||
|
"failedPurchases": "失败购买",
|
||||||
|
"byGateway": "各网关成功率"
|
||||||
|
},
|
||||||
"loadError": "加载统计失败"
|
"loadError": "加载统计失败"
|
||||||
},
|
},
|
||||||
"referralNetwork": {
|
"referralNetwork": {
|
||||||
|
|||||||
@@ -22,13 +22,14 @@ import { StatCard } from '../components/stats';
|
|||||||
import {
|
import {
|
||||||
AddonsTab,
|
AddonsTab,
|
||||||
DepositsTab,
|
DepositsTab,
|
||||||
|
PaymentHealthTab,
|
||||||
PeriodSelector,
|
PeriodSelector,
|
||||||
RenewalsTab,
|
RenewalsTab,
|
||||||
SalesTab,
|
SalesTab,
|
||||||
TrialsTab,
|
TrialsTab,
|
||||||
} from '../components/sales-stats';
|
} 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' };
|
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: 'renewals', label: t('admin.salesStats.tabs.renewals') },
|
||||||
{ id: 'addons', label: t('admin.salesStats.tabs.addons') },
|
{ id: 'addons', label: t('admin.salesStats.tabs.addons') },
|
||||||
{ id: 'deposits', label: t('admin.salesStats.tabs.deposits') },
|
{ id: 'deposits', label: t('admin.salesStats.tabs.deposits') },
|
||||||
|
{ id: 'payment', label: t('admin.salesStats.tabs.payment') },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -258,6 +260,7 @@ export default function AdminSalesStats() {
|
|||||||
{activeTab === 'renewals' && <RenewalsTab params={params} />}
|
{activeTab === 'renewals' && <RenewalsTab params={params} />}
|
||||||
{activeTab === 'addons' && <AddonsTab params={params} />}
|
{activeTab === 'addons' && <AddonsTab params={params} />}
|
||||||
{activeTab === 'deposits' && <DepositsTab params={params} />}
|
{activeTab === 'deposits' && <DepositsTab params={params} />}
|
||||||
|
{activeTab === 'payment' && <PaymentHealthTab params={params} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user