From a75f42d056afe7183cf070cf0d9e9cb88630f94a Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 2 Jun 2026 15:10:06 +0300 Subject: [PATCH] =?UTF-8?q?feat(sales-stats):=20=D1=87=D0=B8=D1=82=D0=B0?= =?UTF-8?q?=D0=B5=D0=BC=D1=8B=D0=B5=20=D1=80=D0=B0=D0=B7=D0=B1=D0=B8=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B8=20stacked-=D0=B1=D0=B0=D1=80=D1=8B,=20?= =?UTF-8?q?=D0=B8=D0=BA=D0=BE=D0=BD=D0=BA=D0=B8=20=D0=BF=D0=BB=D0=B0=D1=82?= =?UTF-8?q?=D1=91=D0=B6=D0=B5=D0=BA,=20=D0=B2=D1=8B=D1=80=D1=83=D1=87?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Полировка статистики продаж (читаемее/информативнее): - BreakdownList — категорийные разбивки списком (иконка/цвет + значение + доля % + пропорциональный мини-бар) вместо bar-графика. В «Депозиты → по методам» теперь с иконками платёжек (PaymentMethodIcon), в «Продажи → по тарифам» — с долями. - StackedBarChart — «по дням разбивкой» теперь stacked-бар вместо наслаивающихся полупрозрачных area (читалось плохо); тултип по дню показывает все серии. - Продажи: добавлена карточка «Выручка» (total_revenue бэк отдавал, но не показывался). --- src/components/sales-stats/BreakdownList.tsx | 80 +++++++++++ src/components/sales-stats/DepositsTab.tsx | 20 +-- src/components/sales-stats/SalesTab.tsx | 23 +-- .../sales-stats/StackedBarChart.tsx | 135 ++++++++++++++++++ src/locales/en.json | 1 + src/locales/fa.json | 1 + src/locales/ru.json | 1 + src/locales/zh.json | 1 + 8 files changed, 244 insertions(+), 18 deletions(-) create mode 100644 src/components/sales-stats/BreakdownList.tsx create mode 100644 src/components/sales-stats/StackedBarChart.tsx diff --git a/src/components/sales-stats/BreakdownList.tsx b/src/components/sales-stats/BreakdownList.tsx new file mode 100644 index 0000000..a3c7065 --- /dev/null +++ b/src/components/sales-stats/BreakdownList.tsx @@ -0,0 +1,80 @@ +import { type ReactNode, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { SALES_STATS } from '../../constants/salesStats'; + +export interface BreakdownItem { + key: string; + label: string; + value: number; + color?: string; + icon?: ReactNode; +} + +interface BreakdownListProps { + title: string; + items: BreakdownItem[]; + /** Formats the value shown on the right (e.g. money). Defaults to the raw number. */ + valueFormatter?: (value: number) => string; +} + +/** + * Categorical breakdown rendered as a ranked list instead of a bar chart: + * icon/colour dot + label + value + share-% + a proportional mini bar. Far more + * readable than a recharts bar for "by method / by tariff / by package" data. + */ +export function BreakdownList({ title, items, valueFormatter }: BreakdownListProps) { + const { t } = useTranslation(); + + const { sorted, total, max } = useMemo(() => { + const sortedItems = [...items].sort((a, b) => b.value - a.value); + const totalValue = sortedItems.reduce((sum, item) => sum + item.value, 0); + const maxValue = sortedItems.length > 0 ? sortedItems[0].value : 0; + return { sorted: sortedItems, total: totalValue, max: maxValue }; + }, [items]); + + return ( +
+

{title}

+ {sorted.length === 0 ? ( +
{t('common.noData')}
+ ) : ( +
+ {sorted.map((item, index) => { + const color = + item.color || SALES_STATS.BAR_COLORS[index % SALES_STATS.BAR_COLORS.length]; + const share = total > 0 ? (item.value / total) * 100 : 0; + const barWidth = max > 0 ? (item.value / max) * 100 : 0; + return ( +
+
+ {item.icon ?? ( + + )} + + {item.label} + + + {valueFormatter ? valueFormatter(item.value) : item.value} + + + {share.toFixed(1)}% + +
+
+
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/components/sales-stats/DepositsTab.tsx b/src/components/sales-stats/DepositsTab.tsx index e62599c..8aa3003 100644 --- a/src/components/sales-stats/DepositsTab.tsx +++ b/src/components/sales-stats/DepositsTab.tsx @@ -9,9 +9,11 @@ import { SALES_STATS } from '../../constants/salesStats'; import { useCurrency } from '../../hooks/useCurrency'; import { StatCard } from '../stats'; -import { MultiSeriesAreaChart } from './MultiSeriesAreaChart'; +import PaymentMethodIcon from '../PaymentMethodIcon'; + +import { BreakdownList } from './BreakdownList'; import { SimpleAreaChart } from './SimpleAreaChart'; -import { SimpleBarChart } from './SimpleBarChart'; +import { StackedBarChart } from './StackedBarChart'; interface DepositsTabProps { params: SalesStatsParams; @@ -29,11 +31,13 @@ export function DepositsTab({ params }: DepositsTabProps) { const formatValue = useCallback((v: number) => formatWithCurrency(v), [formatWithCurrency]); - const methodBarData = useMemo( + const methodBreakdown = useMemo( () => data?.by_method.map((item) => ({ - name: METHOD_LABELS[item.method] || item.method, + key: item.method, + label: METHOD_LABELS[item.method] || item.method, value: item.amount_kopeks / SALES_STATS.KOPEKS_DIVISOR, + icon: , })) ?? [], [data?.by_method], ); @@ -89,9 +93,9 @@ export function DepositsTab({ params }: DepositsTabProps) { />
- @@ -102,11 +106,9 @@ export function DepositsTab({ params }: DepositsTabProps) { valueLabel={t('admin.salesStats.deposits.revenue')} /> - diff --git a/src/components/sales-stats/SalesTab.tsx b/src/components/sales-stats/SalesTab.tsx index 5b7bc44..58dc043 100644 --- a/src/components/sales-stats/SalesTab.tsx +++ b/src/components/sales-stats/SalesTab.tsx @@ -8,10 +8,10 @@ import { SALES_STATS } from '../../constants/salesStats'; import { useCurrency } from '../../hooks/useCurrency'; import { StatCard } from '../stats'; +import { BreakdownList } from './BreakdownList'; import { DonutChart } from './DonutChart'; -import { MultiSeriesAreaChart } from './MultiSeriesAreaChart'; import { SimpleAreaChart } from './SimpleAreaChart'; -import { SimpleBarChart } from './SimpleBarChart'; +import { StackedBarChart } from './StackedBarChart'; interface SalesTabProps { params: SalesStatsParams; @@ -48,8 +48,9 @@ export function SalesTab({ params }: SalesTabProps) { return
{t('admin.salesStats.loadError')}
; } - const tariffBarData = data.by_tariff.map((item) => ({ - name: item.tariff_name, + const tariffBreakdown = data.by_tariff.map((item) => ({ + key: String(item.tariff_id), + label: item.tariff_name, value: item.count, })); @@ -65,8 +66,13 @@ export function SalesTab({ params }: SalesTabProps) { return (
-
+
+
- +
@@ -87,11 +93,10 @@ export function SalesTab({ params }: SalesTabProps) { valueLabel={t('admin.salesStats.summary.revenue')} /> - String(v)} />
); diff --git a/src/components/sales-stats/StackedBarChart.tsx b/src/components/sales-stats/StackedBarChart.tsx new file mode 100644 index 0000000..8a13edb --- /dev/null +++ b/src/components/sales-stats/StackedBarChart.tsx @@ -0,0 +1,135 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import { SALES_STATS } from '../../constants/salesStats'; +import { useChartColors } from '../../hooks/useChartColors'; + +interface StackedBarChartProps { + data: { date: string; key: string; value: number }[]; + title: string; + valueFormatter?: (value: number) => string; + height?: number; +} + +/** + * Composition-over-time as a STACKED bar chart. Replaces the overlapping + * multi-series area chart for "daily by method / by tariff" — stacked bars read + * far cleaner (no layered translucent areas hiding each other) and the tooltip + * shows every series for the hovered day plus the day total. + */ +export function StackedBarChart({ + data, + title, + valueFormatter, + height = SALES_STATS.CHART.HEIGHT, +}: StackedBarChartProps) { + const { t, i18n } = useTranslation(); + const colors = useChartColors(); + + 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}

+ + + + + + [ + valueFormatter ? valueFormatter(value ?? 0) : (value ?? 0), + name || '', + ]} + /> + + {seriesKeys.map((key, index) => ( + + ))} + + +
+ ); +} diff --git a/src/locales/en.json b/src/locales/en.json index 0976dba..d29d7fa 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1317,6 +1317,7 @@ }, "sales": { "totalSales": "Total Sales", + "totalRevenue": "Revenue", "avgOrder": "Avg Order", "topTariff": "Top Tariff", "byTariff": "Sales by Tariff", diff --git a/src/locales/fa.json b/src/locales/fa.json index 7c4e281..ac32a24 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1190,6 +1190,7 @@ }, "sales": { "totalSales": "کل فروش", + "totalRevenue": "درآمد", "avgOrder": "میانگین سفارش", "topTariff": "تعرفه برتر", "byTariff": "بر اساس تعرفه", diff --git a/src/locales/ru.json b/src/locales/ru.json index bf8081d..5e95ba5 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1339,6 +1339,7 @@ }, "sales": { "totalSales": "Всего продаж", + "totalRevenue": "Выручка", "avgOrder": "Средний чек", "topTariff": "Топ тариф", "byTariff": "Продажи по тарифам", diff --git a/src/locales/zh.json b/src/locales/zh.json index bcc6376..69ffe95 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1190,6 +1190,7 @@ }, "sales": { "totalSales": "总销售", + "totalRevenue": "营收", "avgOrder": "平均订单", "topTariff": "热门套餐", "byTariff": "按套餐",