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[];
|
||||
}
|
||||
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
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 { DualAreaChart } from './DualAreaChart';
|
||||
export { MultiSeriesAreaChart } from './MultiSeriesAreaChart';
|
||||
export { PaymentHealthTab } from './PaymentHealthTab';
|
||||
export { PeriodSelector } from './PeriodSelector';
|
||||
export { RenewalsTab } from './RenewalsTab';
|
||||
export { SalesTab } from './SalesTab';
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user