mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +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