mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat: add admin sales statistics dashboard with 5 analytics tabs
- Add sales stats API client with typed interfaces for all endpoints - Implement AdminSalesStats page with summary cards and period selector - Add TrialsTab with provider breakdown donut chart and daily area chart - Add SalesTab with tariff/period breakdown and daily revenue chart - Add RenewalsTab with period comparison and trend indicators - Add AddonsTab with package breakdown bar chart - Add DepositsTab with payment method breakdown bar chart - Add SimpleBarChart, SimpleAreaChart, DonutChart components - Add PeriodSelector with preset periods and custom date range - Full responsive design (mobile/tablet/desktop/TG Mini App) - ARIA accessibility (tablist, tab, tabpanel, aria-selected) - Error handling with isError on all queries - Empty data guards on all chart components - i18n support across all 4 locales (en, ru, zh, fa)
This commit is contained in:
175
src/api/adminSalesStats.ts
Normal file
175
src/api/adminSalesStats.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import apiClient from './client';
|
||||
|
||||
// ============ Period Params ============
|
||||
|
||||
export interface SalesStatsParams {
|
||||
days?: number;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
|
||||
// ============ Summary ============
|
||||
|
||||
export interface SalesSummary {
|
||||
total_revenue_kopeks: number;
|
||||
active_subscriptions: number;
|
||||
active_trials: number;
|
||||
new_trials: number;
|
||||
trial_to_paid_conversion: number;
|
||||
renewals_count: number;
|
||||
addon_revenue_kopeks: number;
|
||||
}
|
||||
|
||||
// ============ Trials ============
|
||||
|
||||
export interface ProviderBreakdownItem {
|
||||
provider: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface DailyTrialItem {
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface TrialsStats {
|
||||
total_trials: number;
|
||||
conversion_rate: number;
|
||||
avg_trial_duration_days: number;
|
||||
by_provider: ProviderBreakdownItem[];
|
||||
daily: DailyTrialItem[];
|
||||
}
|
||||
|
||||
// ============ Sales ============
|
||||
|
||||
export interface SalesByTariffItem {
|
||||
tariff_id: number;
|
||||
tariff_name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SalesByPeriodItem {
|
||||
period_days: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface DailySalesItem {
|
||||
date: string;
|
||||
count: number;
|
||||
revenue_kopeks: number;
|
||||
}
|
||||
|
||||
export interface SalesStats {
|
||||
total_sales: number;
|
||||
total_revenue_kopeks: number;
|
||||
avg_order_kopeks: number;
|
||||
top_tariff_name: string;
|
||||
by_tariff: SalesByTariffItem[];
|
||||
by_period: SalesByPeriodItem[];
|
||||
daily: DailySalesItem[];
|
||||
}
|
||||
|
||||
// ============ Renewals ============
|
||||
|
||||
export interface RenewalPeriodStats {
|
||||
count: number;
|
||||
revenue_kopeks: number;
|
||||
}
|
||||
|
||||
export interface RenewalChange {
|
||||
absolute: number;
|
||||
percent: number;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
}
|
||||
|
||||
export interface DailyRenewalItem {
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface RenewalsStats {
|
||||
total_renewals: number;
|
||||
total_revenue_kopeks: number;
|
||||
renewal_rate: number;
|
||||
current_period: RenewalPeriodStats;
|
||||
previous_period: RenewalPeriodStats;
|
||||
change: RenewalChange;
|
||||
daily: DailyRenewalItem[];
|
||||
}
|
||||
|
||||
// ============ Add-ons ============
|
||||
|
||||
export interface AddonByPackageItem {
|
||||
traffic_gb: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface DailyAddonItem {
|
||||
date: string;
|
||||
count: number;
|
||||
total_gb: number;
|
||||
}
|
||||
|
||||
export interface AddonsStats {
|
||||
total_purchases: number;
|
||||
total_gb_purchased: number;
|
||||
addon_revenue_kopeks: number;
|
||||
by_package: AddonByPackageItem[];
|
||||
daily: DailyAddonItem[];
|
||||
}
|
||||
|
||||
// ============ Deposits ============
|
||||
|
||||
export interface DepositByMethodItem {
|
||||
method: string;
|
||||
count: number;
|
||||
amount_kopeks: number;
|
||||
}
|
||||
|
||||
export interface DailyDepositItem {
|
||||
date: string;
|
||||
count: number;
|
||||
amount_kopeks: number;
|
||||
}
|
||||
|
||||
export interface DepositsStats {
|
||||
total_deposits: number;
|
||||
total_amount_kopeks: number;
|
||||
avg_deposit_kopeks: number;
|
||||
by_method: DepositByMethodItem[];
|
||||
daily: DailyDepositItem[];
|
||||
}
|
||||
|
||||
// ============ API ============
|
||||
|
||||
export const salesStatsApi = {
|
||||
getSummary: async (params: SalesStatsParams = {}): Promise<SalesSummary> => {
|
||||
const response = await apiClient.get('/cabinet/admin/stats/sales/summary', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTrials: async (params: SalesStatsParams = {}): Promise<TrialsStats> => {
|
||||
const response = await apiClient.get('/cabinet/admin/stats/sales/trials', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getSales: async (params: SalesStatsParams = {}): Promise<SalesStats> => {
|
||||
const response = await apiClient.get('/cabinet/admin/stats/sales/subscriptions', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getRenewals: async (params: SalesStatsParams = {}): Promise<RenewalsStats> => {
|
||||
const response = await apiClient.get('/cabinet/admin/stats/sales/renewals', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getAddons: async (params: SalesStatsParams = {}): Promise<AddonsStats> => {
|
||||
const response = await apiClient.get('/cabinet/admin/stats/sales/addons', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getDeposits: async (params: SalesStatsParams = {}): Promise<DepositsStats> => {
|
||||
const response = await apiClient.get('/cabinet/admin/stats/sales/deposits', { params });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
81
src/components/sales-stats/AddonsTab.tsx
Normal file
81
src/components/sales-stats/AddonsTab.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
||||
import { salesStatsApi } from '../../api/adminSalesStats';
|
||||
import { SALES_STATS } from '../../constants/salesStats';
|
||||
import { useCurrency } from '../../hooks/useCurrency';
|
||||
import { StatCard } from '../stats';
|
||||
|
||||
import { SimpleAreaChart } from './SimpleAreaChart';
|
||||
import { SimpleBarChart } from './SimpleBarChart';
|
||||
|
||||
interface AddonsTabProps {
|
||||
params: SalesStatsParams;
|
||||
}
|
||||
|
||||
export function AddonsTab({ params }: AddonsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['sales-stats', 'addons', params],
|
||||
queryFn: () => salesStatsApi.getAddons(params),
|
||||
staleTime: SALES_STATS.STALE_TIME,
|
||||
});
|
||||
|
||||
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-red-400">{t('admin.salesStats.loadError')}</div>;
|
||||
}
|
||||
|
||||
const packageBarData = data.by_package.map((item) => ({
|
||||
name: `${item.traffic_gb} GB`,
|
||||
value: item.count,
|
||||
}));
|
||||
|
||||
const dailyData = data.daily.map((item) => ({
|
||||
date: item.date,
|
||||
value: item.count,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<StatCard
|
||||
label={t('admin.salesStats.addons.totalPurchases')}
|
||||
value={data.total_purchases}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.addons.totalGb')}
|
||||
value={`${data.total_gb_purchased} GB`}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.addons.revenue')}
|
||||
value={formatWithCurrency(data.addon_revenue_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<SimpleBarChart data={packageBarData} title={t('admin.salesStats.addons.byPackage')} />
|
||||
<SimpleAreaChart
|
||||
data={dailyData}
|
||||
title={t('admin.salesStats.addons.dailyChart')}
|
||||
chartId="addons-daily"
|
||||
valueLabel={t('admin.salesStats.addons.purchases')}
|
||||
color={SALES_STATS.BAR_COLORS[5]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
src/components/sales-stats/DepositsTab.tsx
Normal file
98
src/components/sales-stats/DepositsTab.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
||||
import { salesStatsApi } from '../../api/adminSalesStats';
|
||||
import { SALES_STATS } from '../../constants/salesStats';
|
||||
import { useCurrency } from '../../hooks/useCurrency';
|
||||
import { StatCard } from '../stats';
|
||||
|
||||
import { SimpleAreaChart } from './SimpleAreaChart';
|
||||
import { SimpleBarChart } from './SimpleBarChart';
|
||||
|
||||
interface DepositsTabProps {
|
||||
params: SalesStatsParams;
|
||||
}
|
||||
|
||||
const METHOD_LABELS: Record<string, string> = {
|
||||
telegram_stars: 'Telegram Stars',
|
||||
tribute: 'Tribute',
|
||||
yookassa: 'YooKassa',
|
||||
cryptobot: 'CryptoBot',
|
||||
heleket: 'Heleket',
|
||||
mulenpay: 'Mulenpay',
|
||||
pal24: 'Pal24',
|
||||
wata: 'Wata',
|
||||
platega: 'Platega',
|
||||
cloudpayments: 'CloudPayments',
|
||||
freekassa: 'FreeKassa',
|
||||
kassa_ai: 'Kassa AI',
|
||||
};
|
||||
|
||||
export function DepositsTab({ params }: DepositsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['sales-stats', 'deposits', params],
|
||||
queryFn: () => salesStatsApi.getDeposits(params),
|
||||
staleTime: SALES_STATS.STALE_TIME,
|
||||
});
|
||||
|
||||
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-red-400">{t('admin.salesStats.loadError')}</div>;
|
||||
}
|
||||
|
||||
const methodBarData = data.by_method.map((item) => ({
|
||||
name: METHOD_LABELS[item.method] || item.method,
|
||||
value: item.amount_kopeks / SALES_STATS.KOPEKS_DIVISOR,
|
||||
}));
|
||||
|
||||
const dailyData = data.daily.map((item) => ({
|
||||
date: item.date,
|
||||
value: item.amount_kopeks / SALES_STATS.KOPEKS_DIVISOR,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<StatCard
|
||||
label={t('admin.salesStats.deposits.totalDeposits')}
|
||||
value={data.total_deposits}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.deposits.totalAmount')}
|
||||
value={formatWithCurrency(data.total_amount_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.deposits.avgDeposit')}
|
||||
value={formatWithCurrency(data.avg_deposit_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SimpleBarChart
|
||||
data={methodBarData}
|
||||
title={t('admin.salesStats.deposits.byMethod')}
|
||||
valueFormatter={(v) => formatWithCurrency(v)}
|
||||
/>
|
||||
|
||||
<SimpleAreaChart
|
||||
data={dailyData}
|
||||
title={t('admin.salesStats.deposits.dailyChart')}
|
||||
chartId="deposits-daily"
|
||||
valueLabel={t('admin.salesStats.deposits.revenue')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
src/components/sales-stats/DonutChart.tsx
Normal file
83
src/components/sales-stats/DonutChart.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
|
||||
import { SALES_STATS } from '../../constants/salesStats';
|
||||
import { useChartColors } from '../../hooks/useChartColors';
|
||||
|
||||
interface DonutChartProps {
|
||||
data: { name: string; value: number; color?: string }[];
|
||||
title: string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function DonutChart({
|
||||
data,
|
||||
title,
|
||||
height = SALES_STATS.CHART.PIE_HEIGHT,
|
||||
}: DonutChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const colors = useChartColors();
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||
<div className="flex items-center justify-center text-sm text-dark-400" style={{ height }}>
|
||||
{t('common.noData')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={55}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.name}
|
||||
fill={entry.color || SALES_STATS.BAR_COLORS[index % SALES_STATS.BAR_COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: colors.tooltipBg,
|
||||
border: `1px solid ${colors.tooltipBorder}`,
|
||||
borderRadius: SALES_STATS.TOOLTIP.BORDER_RADIUS,
|
||||
fontSize: SALES_STATS.TOOLTIP.FONT_SIZE,
|
||||
}}
|
||||
labelStyle={{ color: colors.label }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="mt-2 flex flex-wrap justify-center gap-3">
|
||||
{data.map((entry, index) => (
|
||||
<div key={entry.name} className="flex items-center gap-1.5 text-xs text-dark-400">
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
entry.color || SALES_STATS.BAR_COLORS[index % SALES_STATS.BAR_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{entry.name}: {entry.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
src/components/sales-stats/PeriodSelector.tsx
Normal file
89
src/components/sales-stats/PeriodSelector.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { SALES_STATS } from '../../constants/salesStats';
|
||||
|
||||
interface PeriodSelectorProps {
|
||||
value: { days?: number; startDate?: string; endDate?: string };
|
||||
onChange: (period: { days?: number; startDate?: string; endDate?: string }) => void;
|
||||
}
|
||||
|
||||
export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
|
||||
const presetLabels: Record<number, string> = {
|
||||
7: t('admin.salesStats.period.week'),
|
||||
30: t('admin.salesStats.period.month'),
|
||||
90: t('admin.salesStats.period.quarter'),
|
||||
0: t('admin.salesStats.period.all'),
|
||||
};
|
||||
|
||||
const handlePreset = (days: number) => {
|
||||
setShowCustom(false);
|
||||
onChange({ days });
|
||||
};
|
||||
|
||||
const handleCustomToggle = () => {
|
||||
setShowCustom((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleDateChange = (field: 'startDate' | 'endDate', dateStr: string) => {
|
||||
onChange({
|
||||
...value,
|
||||
days: undefined,
|
||||
[field]: dateStr,
|
||||
});
|
||||
};
|
||||
|
||||
const isPresetActive = (days: number) => !showCustom && value.days === days;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{SALES_STATS.PERIOD_PRESETS.map((days) => (
|
||||
<button
|
||||
key={days}
|
||||
type="button"
|
||||
onClick={() => handlePreset(days)}
|
||||
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
isPresetActive(days)
|
||||
? 'bg-accent-500/20 text-accent-400'
|
||||
: 'bg-dark-800/50 text-dark-400 hover:bg-dark-700/50 hover:text-dark-300'
|
||||
}`}
|
||||
>
|
||||
{presetLabels[days]}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCustomToggle}
|
||||
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
showCustom
|
||||
? 'bg-accent-500/20 text-accent-400'
|
||||
: 'bg-dark-800/50 text-dark-400 hover:bg-dark-700/50 hover:text-dark-300'
|
||||
}`}
|
||||
>
|
||||
{t('admin.salesStats.period.custom')}
|
||||
</button>
|
||||
|
||||
{showCustom && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={value.startDate || ''}
|
||||
onChange={(e) => handleDateChange('startDate', e.target.value)}
|
||||
className="rounded-lg border border-dark-600 bg-dark-800 px-2 py-1 text-sm text-dark-200"
|
||||
/>
|
||||
<span className="text-dark-500">{'\u2014'}</span>
|
||||
<input
|
||||
type="date"
|
||||
value={value.endDate || ''}
|
||||
onChange={(e) => handleDateChange('endDate', e.target.value)}
|
||||
className="rounded-lg border border-dark-600 bg-dark-800 px-2 py-1 text-sm text-dark-200"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
src/components/sales-stats/RenewalsTab.tsx
Normal file
102
src/components/sales-stats/RenewalsTab.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
||||
import { salesStatsApi } from '../../api/adminSalesStats';
|
||||
import { SALES_STATS } from '../../constants/salesStats';
|
||||
import { useCurrency } from '../../hooks/useCurrency';
|
||||
import { StatCard } from '../stats';
|
||||
import { TREND_STYLES } from '../stats/constants';
|
||||
|
||||
import { SimpleAreaChart } from './SimpleAreaChart';
|
||||
|
||||
interface RenewalsTabProps {
|
||||
params: SalesStatsParams;
|
||||
}
|
||||
|
||||
export function RenewalsTab({ params }: RenewalsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['sales-stats', 'renewals', params],
|
||||
queryFn: () => salesStatsApi.getRenewals(params),
|
||||
staleTime: SALES_STATS.STALE_TIME,
|
||||
});
|
||||
|
||||
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-red-400">{t('admin.salesStats.loadError')}</div>;
|
||||
}
|
||||
|
||||
const dailyData = data.daily.map((item) => ({
|
||||
date: item.date,
|
||||
value: item.count,
|
||||
}));
|
||||
|
||||
const trendStyle = TREND_STYLES[data.change.trend] ?? TREND_STYLES.stable;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<StatCard label={t('admin.salesStats.renewals.total')} value={data.total_renewals} />
|
||||
<StatCard
|
||||
label={t('admin.salesStats.renewals.rate')}
|
||||
value={`${data.renewal_rate}%`}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.renewals.revenue')}
|
||||
value={formatWithCurrency(data.total_revenue_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">
|
||||
{t('admin.salesStats.renewals.comparison')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-xl bg-dark-800/30 p-3">
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.salesStats.renewals.currentPeriod')}
|
||||
</div>
|
||||
<div className="mt-1 flex items-baseline gap-2">
|
||||
<span className="text-base font-semibold text-dark-100 sm:text-lg">
|
||||
{data.current_period.count}
|
||||
</span>
|
||||
<span className={`text-sm font-medium ${trendStyle.className}`}>
|
||||
{trendStyle.arrow} {Math.abs(data.change.percent)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-800/30 p-3">
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.salesStats.renewals.previousPeriod')}
|
||||
</div>
|
||||
<div className="mt-1 text-base font-semibold text-dark-400 sm:text-lg">
|
||||
{data.previous_period.count}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SimpleAreaChart
|
||||
data={dailyData}
|
||||
title={t('admin.salesStats.renewals.dailyChart')}
|
||||
chartId="renewals-daily"
|
||||
valueLabel={t('admin.salesStats.renewals.renewals')}
|
||||
color={SALES_STATS.BAR_COLORS[2]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/components/sales-stats/SalesTab.tsx
Normal file
82
src/components/sales-stats/SalesTab.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
||||
import { salesStatsApi } from '../../api/adminSalesStats';
|
||||
import { SALES_STATS } from '../../constants/salesStats';
|
||||
import { useCurrency } from '../../hooks/useCurrency';
|
||||
import { StatCard } from '../stats';
|
||||
|
||||
import { DonutChart } from './DonutChart';
|
||||
import { SimpleAreaChart } from './SimpleAreaChart';
|
||||
import { SimpleBarChart } from './SimpleBarChart';
|
||||
|
||||
interface SalesTabProps {
|
||||
params: SalesStatsParams;
|
||||
}
|
||||
|
||||
export function SalesTab({ params }: SalesTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['sales-stats', 'sales', params],
|
||||
queryFn: () => salesStatsApi.getSales(params),
|
||||
staleTime: SALES_STATS.STALE_TIME,
|
||||
});
|
||||
|
||||
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-red-400">{t('admin.salesStats.loadError')}</div>;
|
||||
}
|
||||
|
||||
const tariffBarData = data.by_tariff.map((item) => ({
|
||||
name: item.tariff_name,
|
||||
value: item.count,
|
||||
}));
|
||||
|
||||
const periodPieData = data.by_period.map((item) => ({
|
||||
name: `${item.period_days} ${t('admin.trafficUsage.days')}`,
|
||||
value: item.count,
|
||||
}));
|
||||
|
||||
const dailyData = data.daily.map((item) => ({
|
||||
date: item.date,
|
||||
value: item.revenue_kopeks / SALES_STATS.KOPEKS_DIVISOR,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<StatCard label={t('admin.salesStats.sales.totalSales')} value={data.total_sales} />
|
||||
<StatCard
|
||||
label={t('admin.salesStats.sales.avgOrder')}
|
||||
value={formatWithCurrency(data.avg_order_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
<StatCard label={t('admin.salesStats.sales.topTariff')} value={data.top_tariff_name} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<SimpleBarChart data={tariffBarData} title={t('admin.salesStats.sales.byTariff')} />
|
||||
<DonutChart data={periodPieData} title={t('admin.salesStats.sales.byPeriod')} />
|
||||
</div>
|
||||
|
||||
<SimpleAreaChart
|
||||
data={dailyData}
|
||||
title={t('admin.salesStats.sales.dailyChart')}
|
||||
chartId="sales-daily"
|
||||
valueLabel={t('admin.salesStats.summary.revenue')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
src/components/sales-stats/SimpleAreaChart.tsx
Normal file
114
src/components/sales-stats/SimpleAreaChart.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { SALES_STATS } from '../../constants/salesStats';
|
||||
import { useChartColors } from '../../hooks/useChartColors';
|
||||
|
||||
interface SimpleAreaChartProps {
|
||||
data: { date: string; value: number }[];
|
||||
title: string;
|
||||
color?: string;
|
||||
chartId: string;
|
||||
valueLabel?: string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function SimpleAreaChart({
|
||||
data,
|
||||
title,
|
||||
color,
|
||||
chartId,
|
||||
valueLabel,
|
||||
height = SALES_STATS.CHART.HEIGHT,
|
||||
}: SimpleAreaChartProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const colors = useChartColors();
|
||||
const strokeColor = color || colors.earnings;
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
data.map((item) => ({
|
||||
...item,
|
||||
label: new Date(item.date + 'T00:00:00').toLocaleDateString(i18n.language, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
})),
|
||||
[data, i18n.language],
|
||||
);
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||
<div className="flex items-center justify-center text-sm text-dark-400" style={{ height }}>
|
||||
{t('common.noData')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<AreaChart data={chartData} margin={SALES_STATS.CHART.MARGIN}>
|
||||
<defs>
|
||||
<linearGradient id={`gradient-${chartId}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset={SALES_STATS.GRADIENT.START_OFFSET}
|
||||
stopColor={strokeColor}
|
||||
stopOpacity={SALES_STATS.GRADIENT.START_OPACITY}
|
||||
/>
|
||||
<stop
|
||||
offset={SALES_STATS.GRADIENT.END_OFFSET}
|
||||
stopColor={strokeColor}
|
||||
stopOpacity={SALES_STATS.GRADIENT.END_OPACITY}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray={SALES_STATS.GRID_DASH} stroke={colors.grid} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
|
||||
tickLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={SALES_STATS.AXIS.WIDTH}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: colors.tooltipBg,
|
||||
border: `1px solid ${colors.tooltipBorder}`,
|
||||
borderRadius: SALES_STATS.TOOLTIP.BORDER_RADIUS,
|
||||
fontSize: SALES_STATS.TOOLTIP.FONT_SIZE,
|
||||
}}
|
||||
labelStyle={{ color: colors.label }}
|
||||
formatter={(value: number | undefined) => [value ?? 0, valueLabel || '']}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={strokeColor}
|
||||
fill={`url(#gradient-${chartId})`}
|
||||
strokeWidth={SALES_STATS.STROKE_WIDTH}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
src/components/sales-stats/SimpleBarChart.tsx
Normal file
97
src/components/sales-stats/SimpleBarChart.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { SALES_STATS } from '../../constants/salesStats';
|
||||
import { useChartColors } from '../../hooks/useChartColors';
|
||||
|
||||
interface SimpleBarChartProps {
|
||||
data: { name: string; value: number; color?: string }[];
|
||||
title: string;
|
||||
height?: number;
|
||||
valueFormatter?: (value: number) => string;
|
||||
}
|
||||
|
||||
const BAR_CHART_MARGIN = {
|
||||
...SALES_STATS.CHART.MARGIN,
|
||||
bottom: 40,
|
||||
};
|
||||
|
||||
export function SimpleBarChart({
|
||||
data,
|
||||
title,
|
||||
height = SALES_STATS.CHART.HEIGHT,
|
||||
valueFormatter,
|
||||
}: SimpleBarChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const colors = useChartColors();
|
||||
|
||||
const adjustedHeight = useMemo(() => height + 35, [height]);
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||
<div className="flex items-center justify-center text-sm text-dark-400" style={{ height }}>
|
||||
{t('common.noData')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||
<ResponsiveContainer width="100%" height={adjustedHeight}>
|
||||
<BarChart data={data} margin={BAR_CHART_MARGIN}>
|
||||
<CartesianGrid strokeDasharray={SALES_STATS.GRID_DASH} stroke={colors.grid} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
|
||||
tickLine={false}
|
||||
angle={-30}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={SALES_STATS.AXIS.WIDTH}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: colors.tooltipBg,
|
||||
border: `1px solid ${colors.tooltipBorder}`,
|
||||
borderRadius: SALES_STATS.TOOLTIP.BORDER_RADIUS,
|
||||
fontSize: SALES_STATS.TOOLTIP.FONT_SIZE,
|
||||
}}
|
||||
labelStyle={{ color: colors.label }}
|
||||
formatter={(value: number | undefined) => [
|
||||
valueFormatter ? valueFormatter(value ?? 0) : (value ?? 0),
|
||||
]}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
|
||||
{data.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.name}
|
||||
fill={entry.color || SALES_STATS.BAR_COLORS[index % SALES_STATS.BAR_COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
src/components/sales-stats/TrialsTab.tsx
Normal file
86
src/components/sales-stats/TrialsTab.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
||||
import { salesStatsApi } from '../../api/adminSalesStats';
|
||||
import { SALES_STATS } from '../../constants/salesStats';
|
||||
import { StatCard } from '../stats';
|
||||
|
||||
import { DonutChart } from './DonutChart';
|
||||
import { SimpleAreaChart } from './SimpleAreaChart';
|
||||
|
||||
interface TrialsTabProps {
|
||||
params: SalesStatsParams;
|
||||
}
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
telegram: 'Telegram',
|
||||
email: 'Email',
|
||||
vk: 'VK',
|
||||
yandex: 'Yandex',
|
||||
google: 'Google',
|
||||
discord: 'Discord',
|
||||
};
|
||||
|
||||
export function TrialsTab({ params }: TrialsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['sales-stats', 'trials', params],
|
||||
queryFn: () => salesStatsApi.getTrials(params),
|
||||
staleTime: SALES_STATS.STALE_TIME,
|
||||
});
|
||||
|
||||
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-red-400">{t('admin.salesStats.loadError')}</div>;
|
||||
}
|
||||
|
||||
const pieData = data.by_provider.map((item) => ({
|
||||
name: PROVIDER_LABELS[item.provider] || item.provider,
|
||||
value: item.count,
|
||||
color: SALES_STATS.PROVIDER_COLORS[item.provider as keyof typeof SALES_STATS.PROVIDER_COLORS],
|
||||
}));
|
||||
|
||||
const areaData = data.daily.map((item) => ({
|
||||
date: item.date,
|
||||
value: item.count,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<StatCard label={t('admin.salesStats.trials.total')} value={data.total_trials} />
|
||||
<StatCard
|
||||
label={t('admin.salesStats.trials.conversion')}
|
||||
value={`${data.conversion_rate}%`}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.trials.avgDuration')}
|
||||
value={`${data.avg_trial_duration_days} ${t('admin.trafficUsage.days')}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<DonutChart data={pieData} title={t('admin.salesStats.trials.byProvider')} />
|
||||
<SimpleAreaChart
|
||||
data={areaData}
|
||||
title={t('admin.salesStats.trials.dailyChart')}
|
||||
chartId="trials-daily"
|
||||
valueLabel={t('admin.salesStats.trials.registrations')}
|
||||
color={SALES_STATS.PROVIDER_COLORS.telegram}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
src/components/sales-stats/index.ts
Normal file
9
src/components/sales-stats/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { AddonsTab } from './AddonsTab';
|
||||
export { DepositsTab } from './DepositsTab';
|
||||
export { DonutChart } from './DonutChart';
|
||||
export { PeriodSelector } from './PeriodSelector';
|
||||
export { RenewalsTab } from './RenewalsTab';
|
||||
export { SalesTab } from './SalesTab';
|
||||
export { SimpleAreaChart } from './SimpleAreaChart';
|
||||
export { SimpleBarChart } from './SimpleBarChart';
|
||||
export { TrialsTab } from './TrialsTab';
|
||||
39
src/constants/salesStats.ts
Normal file
39
src/constants/salesStats.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { CHART_COMMON } from './charts';
|
||||
|
||||
export const SALES_STATS = {
|
||||
CHART: {
|
||||
...CHART_COMMON.CHART,
|
||||
HEIGHT: 220,
|
||||
PIE_HEIGHT: 250,
|
||||
},
|
||||
KOPEKS_DIVISOR: CHART_COMMON.KOPEKS_DIVISOR,
|
||||
GRADIENT: CHART_COMMON.GRADIENT,
|
||||
AXIS: {
|
||||
TICK_FONT_SIZE: CHART_COMMON.AXIS.TICK_FONT_SIZE,
|
||||
WIDTH: 40,
|
||||
},
|
||||
TOOLTIP: CHART_COMMON.TOOLTIP,
|
||||
STROKE_WIDTH: CHART_COMMON.STROKE_WIDTH,
|
||||
GRID_DASH: CHART_COMMON.GRID_DASH,
|
||||
STALE_TIME: CHART_COMMON.STALE_TIME,
|
||||
DEFAULT_PERIOD: 30,
|
||||
PERIOD_PRESETS: [7, 30, 90, 0] as const,
|
||||
PROVIDER_COLORS: {
|
||||
telegram: '#229ED9',
|
||||
email: '#34d399',
|
||||
vk: '#4C75A3',
|
||||
yandex: '#FC3F1D',
|
||||
google: '#4285F4',
|
||||
discord: '#5865F2',
|
||||
} as const,
|
||||
BAR_COLORS: [
|
||||
'#34d399',
|
||||
'#818cf8',
|
||||
'#f59e0b',
|
||||
'#ec4899',
|
||||
'#06b6d4',
|
||||
'#8b5cf6',
|
||||
'#f97316',
|
||||
'#14b8a6',
|
||||
] as const,
|
||||
} as const;
|
||||
171
src/pages/AdminSalesStats.tsx
Normal file
171
src/pages/AdminSalesStats.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { SalesStatsParams } from '../api/adminSalesStats';
|
||||
import { salesStatsApi } from '../api/adminSalesStats';
|
||||
import { SALES_STATS } from '../constants/salesStats';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import { AdminBackButton } from '../components/admin/AdminBackButton';
|
||||
import { StatCard } from '../components/stats';
|
||||
import {
|
||||
AddonsTab,
|
||||
DepositsTab,
|
||||
PeriodSelector,
|
||||
RenewalsTab,
|
||||
SalesTab,
|
||||
TrialsTab,
|
||||
} from '../components/sales-stats';
|
||||
|
||||
type TabId = 'trials' | 'sales' | 'renewals' | 'addons' | 'deposits';
|
||||
|
||||
export default function AdminSalesStats() {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabId>('trials');
|
||||
const [period, setPeriod] = useState<{
|
||||
days?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}>({ days: SALES_STATS.DEFAULT_PERIOD });
|
||||
|
||||
const params: SalesStatsParams = useMemo(
|
||||
() => ({
|
||||
days: period.days,
|
||||
start_date: period.startDate,
|
||||
end_date: period.endDate,
|
||||
}),
|
||||
[period.days, period.startDate, period.endDate],
|
||||
);
|
||||
|
||||
const isValidPeriod = period.days !== undefined || (!!period.startDate && !!period.endDate);
|
||||
|
||||
const {
|
||||
data: summary,
|
||||
isLoading: summaryLoading,
|
||||
isError: summaryError,
|
||||
} = useQuery({
|
||||
queryKey: ['sales-stats', 'summary', params],
|
||||
queryFn: () => salesStatsApi.getSummary(params),
|
||||
staleTime: SALES_STATS.STALE_TIME,
|
||||
enabled: isValidPeriod,
|
||||
});
|
||||
|
||||
const tabs: { id: TabId; label: string }[] = [
|
||||
{ id: 'trials', label: t('admin.salesStats.tabs.trials') },
|
||||
{ id: 'sales', label: t('admin.salesStats.tabs.sales') },
|
||||
{ id: 'renewals', label: t('admin.salesStats.tabs.renewals') },
|
||||
{ id: 'addons', label: t('admin.salesStats.tabs.addons') },
|
||||
{ id: 'deposits', label: t('admin.salesStats.tabs.deposits') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-4 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<AdminBackButton />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-dark-100 sm:text-2xl">
|
||||
{t('admin.salesStats.title')}
|
||||
</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.salesStats.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Period selector */}
|
||||
<PeriodSelector value={period} onChange={setPeriod} />
|
||||
|
||||
{/* Summary cards */}
|
||||
{summaryError && (
|
||||
<div className="rounded-xl bg-error-500/10 px-4 py-3 text-sm text-error-400">
|
||||
{t('admin.salesStats.loadError')}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-7">
|
||||
<StatCard
|
||||
label={t('admin.salesStats.summary.revenue')}
|
||||
value={
|
||||
summaryLoading
|
||||
? '...'
|
||||
: formatWithCurrency(
|
||||
(summary?.total_revenue_kopeks ?? 0) / SALES_STATS.KOPEKS_DIVISOR,
|
||||
)
|
||||
}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.summary.activeSubs')}
|
||||
value={summaryLoading ? '...' : (summary?.active_subscriptions ?? 0)}
|
||||
valueClassName="text-accent-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.summary.activeTrials')}
|
||||
value={summaryLoading ? '...' : (summary?.active_trials ?? 0)}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.summary.newTrials')}
|
||||
value={summaryLoading ? '...' : (summary?.new_trials ?? 0)}
|
||||
valueClassName="text-blue-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.summary.conversion')}
|
||||
value={summaryLoading ? '...' : `${summary?.trial_to_paid_conversion ?? 0}%`}
|
||||
valueClassName="text-warning-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.summary.renewals')}
|
||||
value={summaryLoading ? '...' : (summary?.renewals_count ?? 0)}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.summary.addonRevenue')}
|
||||
value={
|
||||
summaryLoading
|
||||
? '...'
|
||||
: formatWithCurrency(
|
||||
(summary?.addon_revenue_kopeks ?? 0) / SALES_STATS.KOPEKS_DIVISOR,
|
||||
)
|
||||
}
|
||||
valueClassName="text-accent-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div
|
||||
className="scrollbar-hide flex gap-1 overflow-x-auto rounded-xl bg-dark-800/30 p-1"
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
id={`tab-${tab.id}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.id}
|
||||
aria-controls={`panel-${tab.id}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`shrink-0 whitespace-nowrap rounded-lg px-3 py-2.5 text-xs font-medium transition-colors sm:text-sm ${
|
||||
activeTab === tab.id
|
||||
? 'bg-dark-700/60 text-dark-100'
|
||||
: 'text-dark-400 hover:text-dark-300'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{isValidPeriod && (
|
||||
<div role="tabpanel" id={`panel-${activeTab}`} aria-labelledby={`tab-${activeTab}`}>
|
||||
{activeTab === 'trials' && <TrialsTab params={params} />}
|
||||
{activeTab === 'sales' && <SalesTab params={params} />}
|
||||
{activeTab === 'renewals' && <RenewalsTab params={params} />}
|
||||
{activeTab === 'addons' && <AddonsTab params={params} />}
|
||||
{activeTab === 'deposits' && <DepositsTab params={params} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user