feat(sales-stats): читаемые разбивки и stacked-бары, иконки платёжек, выручка

Полировка статистики продаж (читаемее/информативнее):
- BreakdownList — категорийные разбивки списком (иконка/цвет + значение + доля % +
  пропорциональный мини-бар) вместо bar-графика. В «Депозиты → по методам» теперь
  с иконками платёжек (PaymentMethodIcon), в «Продажи → по тарифам» — с долями.
- StackedBarChart — «по дням разбивкой» теперь stacked-бар вместо наслаивающихся
  полупрозрачных area (читалось плохо); тултип по дню показывает все серии.
- Продажи: добавлена карточка «Выручка» (total_revenue бэк отдавал, но не показывался).
This commit is contained in:
c0mrade
2026-06-02 15:10:06 +03:00
parent 5e837621ad
commit a75f42d056
8 changed files with 244 additions and 18 deletions

View File

@@ -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 (
<div className="bento-card">
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
{sorted.length === 0 ? (
<div className="py-6 text-center text-sm text-dark-400">{t('common.noData')}</div>
) : (
<div className="space-y-2.5">
{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 (
<div key={item.key}>
<div className="flex items-center gap-2">
{item.icon ?? (
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: color }}
/>
)}
<span className="min-w-0 flex-1 truncate text-sm text-dark-200">
{item.label}
</span>
<span className="shrink-0 text-sm font-semibold text-dark-100">
{valueFormatter ? valueFormatter(item.value) : item.value}
</span>
<span className="w-12 shrink-0 text-right text-xs tabular-nums text-dark-500">
{share.toFixed(1)}%
</span>
</div>
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-dark-800/60">
<div
className="h-full rounded-full transition-all"
style={{ width: `${barWidth}%`, backgroundColor: color }}
/>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -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: <PaymentMethodIcon method={item.method} className="h-5 w-5 shrink-0" />,
})) ?? [],
[data?.by_method],
);
@@ -89,9 +93,9 @@ export function DepositsTab({ params }: DepositsTabProps) {
/>
</div>
<SimpleBarChart
data={methodBarData}
<BreakdownList
title={t('admin.salesStats.deposits.byMethod')}
items={methodBreakdown}
valueFormatter={formatValue}
/>
@@ -102,11 +106,9 @@ export function DepositsTab({ params }: DepositsTabProps) {
valueLabel={t('admin.salesStats.deposits.revenue')}
/>
<MultiSeriesAreaChart
<StackedBarChart
data={dailyByMethodData}
title={t('admin.salesStats.deposits.dailyByMethod')}
chartId="deposits-daily-by-method"
valueLabel={t('admin.salesStats.deposits.revenue')}
valueFormatter={formatValue}
/>
</div>

View File

@@ -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 <div className="py-8 text-center text-error-400">{t('admin.salesStats.loadError')}</div>;
}
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 (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label={t('admin.salesStats.sales.totalSales')} value={data.total_sales} />
<StatCard
label={t('admin.salesStats.sales.totalRevenue')}
value={formatWithCurrency(data.total_revenue_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
valueClassName="text-success-400"
/>
<StatCard
label={t('admin.salesStats.sales.avgOrder')}
value={formatWithCurrency(data.avg_order_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
@@ -76,7 +82,7 @@ export function SalesTab({ params }: SalesTabProps) {
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<SimpleBarChart data={tariffBarData} title={t('admin.salesStats.sales.byTariff')} />
<BreakdownList title={t('admin.salesStats.sales.byTariff')} items={tariffBreakdown} />
<DonutChart data={periodPieData} title={t('admin.salesStats.sales.byPeriod')} />
</div>
@@ -87,11 +93,10 @@ export function SalesTab({ params }: SalesTabProps) {
valueLabel={t('admin.salesStats.summary.revenue')}
/>
<MultiSeriesAreaChart
<StackedBarChart
data={dailyByTariffData}
title={t('admin.salesStats.sales.dailyByTariff')}
chartId="sales-daily-by-tariff"
valueLabel={t('admin.salesStats.sales.subscriptions')}
valueFormatter={(v) => String(v)}
/>
</div>
);

View File

@@ -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<string, Record<string, number>>();
const keySet = new Set<string>();
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<string, string | number> = {
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 (
<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}>
<BarChart data={chartData} margin={SALES_STATS.CHART.MARGIN}>
<CartesianGrid
strokeDasharray={SALES_STATS.GRID_DASH}
stroke={colors.grid}
vertical={false}
/>
<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
cursor={{ fill: colors.grid, fillOpacity: 0.2 }}
contentStyle={{
backgroundColor: colors.tooltipBg,
border: `1px solid ${colors.tooltipBorder}`,
borderRadius: SALES_STATS.TOOLTIP.BORDER_RADIUS,
fontSize: SALES_STATS.TOOLTIP.FONT_SIZE,
color: colors.label,
}}
labelStyle={{ color: colors.label }}
itemStyle={{ color: colors.label }}
formatter={(value: number | undefined, name: string | undefined) => [
valueFormatter ? valueFormatter(value ?? 0) : (value ?? 0),
name || '',
]}
/>
<Legend />
{seriesKeys.map((key, index) => (
<Bar
key={key}
dataKey={key}
name={key}
stackId="a"
fill={SALES_STATS.BAR_COLORS[index % SALES_STATS.BAR_COLORS.length]}
radius={index === seriesKeys.length - 1 ? [4, 4, 0, 0] : undefined}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -1317,6 +1317,7 @@
},
"sales": {
"totalSales": "Total Sales",
"totalRevenue": "Revenue",
"avgOrder": "Avg Order",
"topTariff": "Top Tariff",
"byTariff": "Sales by Tariff",

View File

@@ -1190,6 +1190,7 @@
},
"sales": {
"totalSales": "کل فروش",
"totalRevenue": "درآمد",
"avgOrder": "میانگین سفارش",
"topTariff": "تعرفه برتر",
"byTariff": "بر اساس تعرفه",

View File

@@ -1339,6 +1339,7 @@
},
"sales": {
"totalSales": "Всего продаж",
"totalRevenue": "Выручка",
"avgOrder": "Средний чек",
"topTariff": "Топ тариф",
"byTariff": "Продажи по тарифам",

View File

@@ -1190,6 +1190,7 @@
},
"sales": {
"totalSales": "总销售",
"totalRevenue": "营收",
"avgOrder": "平均订单",
"topTariff": "热门套餐",
"byTariff": "按套餐",