From a47c222310aea2f55bfa6b4df179aa8e27a5293d Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 2 Mar 2026 20:34:39 +0300 Subject: [PATCH] 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) --- src/api/adminSalesStats.ts | 175 ++++++++++++++++++ src/components/sales-stats/AddonsTab.tsx | 81 ++++++++ src/components/sales-stats/DepositsTab.tsx | 98 ++++++++++ src/components/sales-stats/DonutChart.tsx | 83 +++++++++ src/components/sales-stats/PeriodSelector.tsx | 89 +++++++++ src/components/sales-stats/RenewalsTab.tsx | 102 ++++++++++ src/components/sales-stats/SalesTab.tsx | 82 ++++++++ .../sales-stats/SimpleAreaChart.tsx | 114 ++++++++++++ src/components/sales-stats/SimpleBarChart.tsx | 97 ++++++++++ src/components/sales-stats/TrialsTab.tsx | 86 +++++++++ src/components/sales-stats/index.ts | 9 + src/constants/salesStats.ts | 39 ++++ src/pages/AdminSalesStats.tsx | 171 +++++++++++++++++ 13 files changed, 1226 insertions(+) create mode 100644 src/api/adminSalesStats.ts create mode 100644 src/components/sales-stats/AddonsTab.tsx create mode 100644 src/components/sales-stats/DepositsTab.tsx create mode 100644 src/components/sales-stats/DonutChart.tsx create mode 100644 src/components/sales-stats/PeriodSelector.tsx create mode 100644 src/components/sales-stats/RenewalsTab.tsx create mode 100644 src/components/sales-stats/SalesTab.tsx create mode 100644 src/components/sales-stats/SimpleAreaChart.tsx create mode 100644 src/components/sales-stats/SimpleBarChart.tsx create mode 100644 src/components/sales-stats/TrialsTab.tsx create mode 100644 src/components/sales-stats/index.ts create mode 100644 src/constants/salesStats.ts create mode 100644 src/pages/AdminSalesStats.tsx diff --git a/src/api/adminSalesStats.ts b/src/api/adminSalesStats.ts new file mode 100644 index 0000000..74343c2 --- /dev/null +++ b/src/api/adminSalesStats.ts @@ -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 => { + const response = await apiClient.get('/cabinet/admin/stats/sales/summary', { params }); + return response.data; + }, + + getTrials: async (params: SalesStatsParams = {}): Promise => { + const response = await apiClient.get('/cabinet/admin/stats/sales/trials', { params }); + return response.data; + }, + + getSales: async (params: SalesStatsParams = {}): Promise => { + const response = await apiClient.get('/cabinet/admin/stats/sales/subscriptions', { params }); + return response.data; + }, + + getRenewals: async (params: SalesStatsParams = {}): Promise => { + const response = await apiClient.get('/cabinet/admin/stats/sales/renewals', { params }); + return response.data; + }, + + getAddons: async (params: SalesStatsParams = {}): Promise => { + const response = await apiClient.get('/cabinet/admin/stats/sales/addons', { params }); + return response.data; + }, + + getDeposits: async (params: SalesStatsParams = {}): Promise => { + const response = await apiClient.get('/cabinet/admin/stats/sales/deposits', { params }); + return response.data; + }, +}; diff --git a/src/components/sales-stats/AddonsTab.tsx b/src/components/sales-stats/AddonsTab.tsx new file mode 100644 index 0000000..67de83d --- /dev/null +++ b/src/components/sales-stats/AddonsTab.tsx @@ -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 ( +
+ {Array.from({ length: 3 }, (_, i) => ( +
+ ))} +
+ ); + } + + if (isError || !data) { + return
{t('admin.salesStats.loadError')}
; + } + + 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 ( +
+
+ + + +
+ +
+ + +
+
+ ); +} diff --git a/src/components/sales-stats/DepositsTab.tsx b/src/components/sales-stats/DepositsTab.tsx new file mode 100644 index 0000000..c913565 --- /dev/null +++ b/src/components/sales-stats/DepositsTab.tsx @@ -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 = { + 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 ( +
+ {Array.from({ length: 3 }, (_, i) => ( +
+ ))} +
+ ); + } + + if (isError || !data) { + return
{t('admin.salesStats.loadError')}
; + } + + 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 ( +
+
+ + + +
+ + formatWithCurrency(v)} + /> + + +
+ ); +} diff --git a/src/components/sales-stats/DonutChart.tsx b/src/components/sales-stats/DonutChart.tsx new file mode 100644 index 0000000..10114dc --- /dev/null +++ b/src/components/sales-stats/DonutChart.tsx @@ -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 ( +
+

{title}

+
+ {t('common.noData')} +
+
+ ); + } + + return ( +
+

{title}

+ + + + {data.map((entry, index) => ( + + ))} + + + + +
+ {data.map((entry, index) => ( +
+
+ + {entry.name}: {entry.value} + +
+ ))} +
+
+ ); +} diff --git a/src/components/sales-stats/PeriodSelector.tsx b/src/components/sales-stats/PeriodSelector.tsx new file mode 100644 index 0000000..35eff18 --- /dev/null +++ b/src/components/sales-stats/PeriodSelector.tsx @@ -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 = { + 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 ( +
+ {SALES_STATS.PERIOD_PRESETS.map((days) => ( + + ))} + + + + {showCustom && ( +
+ handleDateChange('startDate', e.target.value)} + className="rounded-lg border border-dark-600 bg-dark-800 px-2 py-1 text-sm text-dark-200" + /> + {'\u2014'} + handleDateChange('endDate', e.target.value)} + className="rounded-lg border border-dark-600 bg-dark-800 px-2 py-1 text-sm text-dark-200" + /> +
+ )} +
+ ); +} diff --git a/src/components/sales-stats/RenewalsTab.tsx b/src/components/sales-stats/RenewalsTab.tsx new file mode 100644 index 0000000..84b18ad --- /dev/null +++ b/src/components/sales-stats/RenewalsTab.tsx @@ -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 ( +
+ {Array.from({ length: 3 }, (_, i) => ( +
+ ))} +
+ ); + } + + if (isError || !data) { + return
{t('admin.salesStats.loadError')}
; + } + + const dailyData = data.daily.map((item) => ({ + date: item.date, + value: item.count, + })); + + const trendStyle = TREND_STYLES[data.change.trend] ?? TREND_STYLES.stable; + + return ( +
+
+ + + +
+ +
+

+ {t('admin.salesStats.renewals.comparison')} +

+
+
+
+ {t('admin.salesStats.renewals.currentPeriod')} +
+
+ + {data.current_period.count} + + + {trendStyle.arrow} {Math.abs(data.change.percent)}% + +
+
+
+
+ {t('admin.salesStats.renewals.previousPeriod')} +
+
+ {data.previous_period.count} +
+
+
+
+ + +
+ ); +} diff --git a/src/components/sales-stats/SalesTab.tsx b/src/components/sales-stats/SalesTab.tsx new file mode 100644 index 0000000..9199c89 --- /dev/null +++ b/src/components/sales-stats/SalesTab.tsx @@ -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 ( +
+ {Array.from({ length: 3 }, (_, i) => ( +
+ ))} +
+ ); + } + + if (isError || !data) { + return
{t('admin.salesStats.loadError')}
; + } + + 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 ( +
+
+ + + +
+ +
+ + +
+ + +
+ ); +} diff --git a/src/components/sales-stats/SimpleAreaChart.tsx b/src/components/sales-stats/SimpleAreaChart.tsx new file mode 100644 index 0000000..3064617 --- /dev/null +++ b/src/components/sales-stats/SimpleAreaChart.tsx @@ -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 ( +
+

{title}

+
+ {t('common.noData')} +
+
+ ); + } + + return ( +
+

{title}

+ + + + + + + + + + + + [value ?? 0, valueLabel || '']} + /> + + + +
+ ); +} diff --git a/src/components/sales-stats/SimpleBarChart.tsx b/src/components/sales-stats/SimpleBarChart.tsx new file mode 100644 index 0000000..1366944 --- /dev/null +++ b/src/components/sales-stats/SimpleBarChart.tsx @@ -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 ( +
+

{title}

+
+ {t('common.noData')} +
+
+ ); + } + + return ( +
+

{title}

+ + + + + + [ + valueFormatter ? valueFormatter(value ?? 0) : (value ?? 0), + ]} + /> + + {data.map((entry, index) => ( + + ))} + + + +
+ ); +} diff --git a/src/components/sales-stats/TrialsTab.tsx b/src/components/sales-stats/TrialsTab.tsx new file mode 100644 index 0000000..f81066b --- /dev/null +++ b/src/components/sales-stats/TrialsTab.tsx @@ -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 = { + 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 ( +
+ {Array.from({ length: 3 }, (_, i) => ( +
+ ))} +
+ ); + } + + if (isError || !data) { + return
{t('admin.salesStats.loadError')}
; + } + + 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 ( +
+
+ + + +
+ +
+ + +
+
+ ); +} diff --git a/src/components/sales-stats/index.ts b/src/components/sales-stats/index.ts new file mode 100644 index 0000000..ddb40ad --- /dev/null +++ b/src/components/sales-stats/index.ts @@ -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'; diff --git a/src/constants/salesStats.ts b/src/constants/salesStats.ts new file mode 100644 index 0000000..72ab5d0 --- /dev/null +++ b/src/constants/salesStats.ts @@ -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; diff --git a/src/pages/AdminSalesStats.tsx b/src/pages/AdminSalesStats.tsx new file mode 100644 index 0000000..bf71998 --- /dev/null +++ b/src/pages/AdminSalesStats.tsx @@ -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('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 ( +
+ {/* Header */} +
+ +
+

+ {t('admin.salesStats.title')} +

+

{t('admin.salesStats.subtitle')}

+
+
+ + {/* Period selector */} + + + {/* Summary cards */} + {summaryError && ( +
+ {t('admin.salesStats.loadError')} +
+ )} +
+ + + + + + + +
+ + {/* Tabs */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Tab content */} + {isValidPeriod && ( +
+ {activeTab === 'trials' && } + {activeTab === 'sales' && } + {activeTab === 'renewals' && } + {activeTab === 'addons' && } + {activeTab === 'deposits' && } +
+ )} +
+ ); +}