diff --git a/src/api/adminSalesStats.ts b/src/api/adminSalesStats.ts index 74343c2..8985faf 100644 --- a/src/api/adminSalesStats.ts +++ b/src/api/adminSalesStats.ts @@ -29,11 +29,13 @@ export interface ProviderBreakdownItem { export interface DailyTrialItem { date: string; - count: number; + registrations: number; + trials: number; } export interface TrialsStats { total_trials: number; + total_registrations: number; conversion_rate: number; avg_trial_duration_days: number; by_provider: ProviderBreakdownItem[]; @@ -59,6 +61,12 @@ export interface DailySalesItem { revenue_kopeks: number; } +export interface DailyTariffSalesItem { + date: string; + tariff_name: string; + count: number; +} + export interface SalesStats { total_sales: number; total_revenue_kopeks: number; @@ -67,6 +75,7 @@ export interface SalesStats { by_tariff: SalesByTariffItem[]; by_period: SalesByPeriodItem[]; daily: DailySalesItem[]; + daily_by_tariff: DailyTariffSalesItem[]; } // ============ Renewals ============ @@ -114,6 +123,8 @@ export interface AddonsStats { total_purchases: number; total_gb_purchased: number; addon_revenue_kopeks: number; + device_purchases: number; + device_revenue_kopeks: number; by_package: AddonByPackageItem[]; daily: DailyAddonItem[]; } diff --git a/src/components/sales-stats/AddonsTab.tsx b/src/components/sales-stats/AddonsTab.tsx index 67de83d..73a1d1a 100644 --- a/src/components/sales-stats/AddonsTab.tsx +++ b/src/components/sales-stats/AddonsTab.tsx @@ -50,7 +50,7 @@ export function AddonsTab({ params }: AddonsTabProps) { return (
-
+
+ +
diff --git a/src/components/sales-stats/DualAreaChart.tsx b/src/components/sales-stats/DualAreaChart.tsx new file mode 100644 index 0000000..8ccf100 --- /dev/null +++ b/src/components/sales-stats/DualAreaChart.tsx @@ -0,0 +1,151 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Area, + AreaChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import { SALES_STATS } from '../../constants/salesStats'; +import { useChartColors } from '../../hooks/useChartColors'; + +interface DualAreaChartProps { + data: { date: string; series1: number; series2: number }[]; + title: string; + chartId: string; + series1Label: string; + series2Label: string; + series1Color?: string; + series2Color?: string; + height?: number; +} + +export function DualAreaChart({ + data, + title, + chartId, + series1Label, + series2Label, + series1Color, + series2Color, + height = SALES_STATS.CHART.HEIGHT, +}: DualAreaChartProps) { + const { t, i18n } = useTranslation(); + const colors = useChartColors(); + const color1 = series1Color || colors.referrals; + const color2 = series2Color || 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}

+ + + + + + + + + + + + + + + + { + const displayValue = value ?? 0; + if (name === 'series1') return [displayValue, series1Label]; + return [displayValue, series2Label]; + }} + /> + { + if (value === 'series1') return series1Label; + return series2Label; + }} + /> + + + + +
+ ); +} diff --git a/src/components/sales-stats/MultiSeriesAreaChart.tsx b/src/components/sales-stats/MultiSeriesAreaChart.tsx new file mode 100644 index 0000000..8adc060 --- /dev/null +++ b/src/components/sales-stats/MultiSeriesAreaChart.tsx @@ -0,0 +1,155 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Area, + AreaChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import { SALES_STATS } from '../../constants/salesStats'; +import { useChartColors } from '../../hooks/useChartColors'; + +interface MultiSeriesAreaChartProps { + data: { date: string; key: string; value: number }[]; + title: string; + chartId: string; + valueLabel?: string; + height?: number; +} + +export function MultiSeriesAreaChart({ + data, + title, + chartId, + valueLabel, + height = SALES_STATS.CHART.HEIGHT, +}: MultiSeriesAreaChartProps) { + const { t, i18n } = useTranslation(); + const colors = useChartColors(); + + // Pivot flat data into { label, [key1]: value, [key2]: value, ... } + const { chartData, seriesKeys } = useMemo(() => { + const dateMap = new Map>(); + const keySet = new Set(); + + for (const item of data) { + keySet.add(item.key); + const existing = dateMap.get(item.date) || {}; + existing[item.key] = (existing[item.key] || 0) + item.value; + dateMap.set(item.date, existing); + } + + const keys = Array.from(keySet).sort(); + const sortedDates = Array.from(dateMap.keys()).sort(); + const pivoted = sortedDates.map((date) => { + const row: Record = { + date, + label: new Date(date + 'T00:00:00').toLocaleDateString(i18n.language, { + month: 'short', + day: 'numeric', + }), + }; + const values = dateMap.get(date) || {}; + for (const key of keys) { + row[key] = values[key] || 0; + } + return row; + }); + + return { chartData: pivoted, seriesKeys: keys }; + }, [data, i18n.language]); + + if (data.length === 0) { + return ( +
+

{title}

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

{title}

+ + + + {seriesKeys.map((key, i) => { + const color = SALES_STATS.BAR_COLORS[i % SALES_STATS.BAR_COLORS.length]; + return ( + + + + + ); + })} + + + + + [ + value ?? 0, + name || valueLabel || '', + ]} + /> + + {seriesKeys.map((key, i) => { + const color = SALES_STATS.BAR_COLORS[i % SALES_STATS.BAR_COLORS.length]; + return ( + + ); + })} + + +
+ ); +} diff --git a/src/components/sales-stats/SalesTab.tsx b/src/components/sales-stats/SalesTab.tsx index 9199c89..a56c4e7 100644 --- a/src/components/sales-stats/SalesTab.tsx +++ b/src/components/sales-stats/SalesTab.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -8,6 +9,7 @@ import { useCurrency } from '../../hooks/useCurrency'; import { StatCard } from '../stats'; import { DonutChart } from './DonutChart'; +import { MultiSeriesAreaChart } from './MultiSeriesAreaChart'; import { SimpleAreaChart } from './SimpleAreaChart'; import { SimpleBarChart } from './SimpleBarChart'; @@ -25,6 +27,13 @@ export function SalesTab({ params }: SalesTabProps) { staleTime: SALES_STATS.STALE_TIME, }); + const dailyByTariffData = useMemo( + () => + data?.daily_by_tariff.map((i) => ({ date: i.date, key: i.tariff_name, value: i.count })) ?? + [], + [data?.daily_by_tariff], + ); + if (isLoading) { return (
@@ -77,6 +86,13 @@ export function SalesTab({ params }: SalesTabProps) { chartId="sales-daily" valueLabel={t('admin.salesStats.summary.revenue')} /> + +
); } diff --git a/src/components/sales-stats/TrialsTab.tsx b/src/components/sales-stats/TrialsTab.tsx index f81066b..dcc0197 100644 --- a/src/components/sales-stats/TrialsTab.tsx +++ b/src/components/sales-stats/TrialsTab.tsx @@ -7,7 +7,7 @@ import { SALES_STATS } from '../../constants/salesStats'; import { StatCard } from '../stats'; import { DonutChart } from './DonutChart'; -import { SimpleAreaChart } from './SimpleAreaChart'; +import { DualAreaChart } from './DualAreaChart'; interface TrialsTabProps { params: SalesStatsParams; @@ -51,14 +51,19 @@ export function TrialsTab({ params }: TrialsTabProps) { color: SALES_STATS.PROVIDER_COLORS[item.provider as keyof typeof SALES_STATS.PROVIDER_COLORS], })); - const areaData = data.daily.map((item) => ({ + const dualData = data.daily.map((item) => ({ date: item.date, - value: item.count, + series1: item.registrations, + series2: item.trials, })); return (
-
+
+ -
diff --git a/src/components/sales-stats/index.ts b/src/components/sales-stats/index.ts index ddb40ad..f9eba7d 100644 --- a/src/components/sales-stats/index.ts +++ b/src/components/sales-stats/index.ts @@ -1,6 +1,8 @@ export { AddonsTab } from './AddonsTab'; export { DepositsTab } from './DepositsTab'; export { DonutChart } from './DonutChart'; +export { DualAreaChart } from './DualAreaChart'; +export { MultiSeriesAreaChart } from './MultiSeriesAreaChart'; export { PeriodSelector } from './PeriodSelector'; export { RenewalsTab } from './RenewalsTab'; export { SalesTab } from './SalesTab'; diff --git a/src/locales/en.json b/src/locales/en.json index f31fffc..ded563e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1106,10 +1106,12 @@ }, "trials": { "total": "Total Trials", + "totalRegistrations": "Registrations", + "trialsIssued": "Trials Issued", "conversion": "Conversion Rate", "avgDuration": "Avg Duration", "byProvider": "By Registration Source", - "dailyChart": "Daily Registrations", + "dailyChart": "Daily Registrations & Trials", "registrations": "Registrations" }, "sales": { @@ -1118,7 +1120,9 @@ "topTariff": "Top Tariff", "byTariff": "Sales by Tariff", "byPeriod": "By Subscription Period", - "dailyChart": "Daily Sales" + "dailyChart": "Daily Sales", + "dailyByTariff": "Sales by Tariff per Day", + "subscriptions": "Subscriptions" }, "renewals": { "total": "Total Renewals", @@ -1134,6 +1138,8 @@ "totalPurchases": "Total Purchases", "totalGb": "Total Traffic", "revenue": "Revenue", + "devicePurchases": "Device Purchases", + "deviceRevenue": "Device Revenue", "byPackage": "By Package Size", "dailyChart": "Daily Purchases", "purchases": "Purchases" diff --git a/src/locales/fa.json b/src/locales/fa.json index 362ca7a..8788a67 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -929,10 +929,12 @@ }, "trials": { "total": "کل آزمایشی", + "totalRegistrations": "ثبت‌نام‌ها", + "trialsIssued": "آزمایشی صادرشده", "conversion": "نرخ تبدیل", "avgDuration": "میانگین مدت", "byProvider": "بر اساس منبع", - "dailyChart": "ثبت‌نام روزانه", + "dailyChart": "ثبت‌نام و آزمایشی روزانه", "registrations": "ثبت‌نام" }, "sales": { @@ -941,7 +943,9 @@ "topTariff": "تعرفه برتر", "byTariff": "بر اساس تعرفه", "byPeriod": "بر اساس دوره", - "dailyChart": "فروش روزانه" + "dailyChart": "فروش روزانه", + "dailyByTariff": "فروش روزانه بر اساس تعرفه", + "subscriptions": "اشتراک‌ها" }, "renewals": { "total": "کل تمدیدها", @@ -957,6 +961,8 @@ "totalPurchases": "کل خرید", "totalGb": "کل ترافیک", "revenue": "درآمد", + "devicePurchases": "خرید دستگاه", + "deviceRevenue": "درآمد دستگاه", "byPackage": "بر اساس بسته", "dailyChart": "خرید روزانه", "purchases": "خرید" diff --git a/src/locales/ru.json b/src/locales/ru.json index cb5cd27..c34a181 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1127,10 +1127,12 @@ }, "trials": { "total": "Всего триалов", + "totalRegistrations": "Регистрации", + "trialsIssued": "Выданные триалы", "conversion": "Конверсия", "avgDuration": "Средняя длит.", "byProvider": "По источнику регистрации", - "dailyChart": "Регистрации по дням", + "dailyChart": "Регистрации и триалы по дням", "registrations": "Регистрации" }, "sales": { @@ -1139,7 +1141,9 @@ "topTariff": "Топ тариф", "byTariff": "Продажи по тарифам", "byPeriod": "По периоду подписки", - "dailyChart": "Продажи по дням" + "dailyChart": "Продажи по дням", + "dailyByTariff": "Продажи по тарифам по дням", + "subscriptions": "Подписки" }, "renewals": { "total": "Всего продлений", @@ -1155,6 +1159,8 @@ "totalPurchases": "Всего покупок", "totalGb": "Всего трафика", "revenue": "Доход", + "devicePurchases": "Покупки устройств", + "deviceRevenue": "Доход от устройств", "byPackage": "По размеру пакета", "dailyChart": "Покупки по дням", "purchases": "Покупки" diff --git a/src/locales/zh.json b/src/locales/zh.json index aebde75..6b3b19d 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -929,10 +929,12 @@ }, "trials": { "total": "试用总数", + "totalRegistrations": "注册数", + "trialsIssued": "已发放试用", "conversion": "转化率", "avgDuration": "平均时长", "byProvider": "按注册来源", - "dailyChart": "每日注册", + "dailyChart": "每日注册与试用", "registrations": "注册" }, "sales": { @@ -941,7 +943,9 @@ "topTariff": "热门套餐", "byTariff": "按套餐", "byPeriod": "按订阅周期", - "dailyChart": "每日销售" + "dailyChart": "每日销售", + "dailyByTariff": "每日按套餐销售", + "subscriptions": "订阅" }, "renewals": { "total": "续订总数", @@ -957,6 +961,8 @@ "totalPurchases": "购买总数", "totalGb": "总流量", "revenue": "收入", + "devicePurchases": "设备购买", + "deviceRevenue": "设备收入", "byPackage": "按套餐大小", "dailyChart": "每日购买", "purchases": "购买"