mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat(sales-stats): читаемые разбивки и stacked-бары, иконки платёжек, выручка
Полировка статистики продаж (читаемее/информативнее): - BreakdownList — категорийные разбивки списком (иконка/цвет + значение + доля % + пропорциональный мини-бар) вместо bar-графика. В «Депозиты → по методам» теперь с иконками платёжек (PaymentMethodIcon), в «Продажи → по тарифам» — с долями. - StackedBarChart — «по дням разбивкой» теперь stacked-бар вместо наслаивающихся полупрозрачных area (читалось плохо); тултип по дню показывает все серии. - Продажи: добавлена карточка «Выручка» (total_revenue бэк отдавал, но не показывался).
This commit is contained in:
80
src/components/sales-stats/BreakdownList.tsx
Normal file
80
src/components/sales-stats/BreakdownList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,9 +9,11 @@ import { SALES_STATS } from '../../constants/salesStats';
|
|||||||
import { useCurrency } from '../../hooks/useCurrency';
|
import { useCurrency } from '../../hooks/useCurrency';
|
||||||
import { StatCard } from '../stats';
|
import { StatCard } from '../stats';
|
||||||
|
|
||||||
import { MultiSeriesAreaChart } from './MultiSeriesAreaChart';
|
import PaymentMethodIcon from '../PaymentMethodIcon';
|
||||||
|
|
||||||
|
import { BreakdownList } from './BreakdownList';
|
||||||
import { SimpleAreaChart } from './SimpleAreaChart';
|
import { SimpleAreaChart } from './SimpleAreaChart';
|
||||||
import { SimpleBarChart } from './SimpleBarChart';
|
import { StackedBarChart } from './StackedBarChart';
|
||||||
|
|
||||||
interface DepositsTabProps {
|
interface DepositsTabProps {
|
||||||
params: SalesStatsParams;
|
params: SalesStatsParams;
|
||||||
@@ -29,11 +31,13 @@ export function DepositsTab({ params }: DepositsTabProps) {
|
|||||||
|
|
||||||
const formatValue = useCallback((v: number) => formatWithCurrency(v), [formatWithCurrency]);
|
const formatValue = useCallback((v: number) => formatWithCurrency(v), [formatWithCurrency]);
|
||||||
|
|
||||||
const methodBarData = useMemo(
|
const methodBreakdown = useMemo(
|
||||||
() =>
|
() =>
|
||||||
data?.by_method.map((item) => ({
|
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,
|
value: item.amount_kopeks / SALES_STATS.KOPEKS_DIVISOR,
|
||||||
|
icon: <PaymentMethodIcon method={item.method} className="h-5 w-5 shrink-0" />,
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
[data?.by_method],
|
[data?.by_method],
|
||||||
);
|
);
|
||||||
@@ -89,9 +93,9 @@ export function DepositsTab({ params }: DepositsTabProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SimpleBarChart
|
<BreakdownList
|
||||||
data={methodBarData}
|
|
||||||
title={t('admin.salesStats.deposits.byMethod')}
|
title={t('admin.salesStats.deposits.byMethod')}
|
||||||
|
items={methodBreakdown}
|
||||||
valueFormatter={formatValue}
|
valueFormatter={formatValue}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -102,11 +106,9 @@ export function DepositsTab({ params }: DepositsTabProps) {
|
|||||||
valueLabel={t('admin.salesStats.deposits.revenue')}
|
valueLabel={t('admin.salesStats.deposits.revenue')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MultiSeriesAreaChart
|
<StackedBarChart
|
||||||
data={dailyByMethodData}
|
data={dailyByMethodData}
|
||||||
title={t('admin.salesStats.deposits.dailyByMethod')}
|
title={t('admin.salesStats.deposits.dailyByMethod')}
|
||||||
chartId="deposits-daily-by-method"
|
|
||||||
valueLabel={t('admin.salesStats.deposits.revenue')}
|
|
||||||
valueFormatter={formatValue}
|
valueFormatter={formatValue}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import { SALES_STATS } from '../../constants/salesStats';
|
|||||||
import { useCurrency } from '../../hooks/useCurrency';
|
import { useCurrency } from '../../hooks/useCurrency';
|
||||||
import { StatCard } from '../stats';
|
import { StatCard } from '../stats';
|
||||||
|
|
||||||
|
import { BreakdownList } from './BreakdownList';
|
||||||
import { DonutChart } from './DonutChart';
|
import { DonutChart } from './DonutChart';
|
||||||
import { MultiSeriesAreaChart } from './MultiSeriesAreaChart';
|
|
||||||
import { SimpleAreaChart } from './SimpleAreaChart';
|
import { SimpleAreaChart } from './SimpleAreaChart';
|
||||||
import { SimpleBarChart } from './SimpleBarChart';
|
import { StackedBarChart } from './StackedBarChart';
|
||||||
|
|
||||||
interface SalesTabProps {
|
interface SalesTabProps {
|
||||||
params: SalesStatsParams;
|
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>;
|
return <div className="py-8 text-center text-error-400">{t('admin.salesStats.loadError')}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tariffBarData = data.by_tariff.map((item) => ({
|
const tariffBreakdown = data.by_tariff.map((item) => ({
|
||||||
name: item.tariff_name,
|
key: String(item.tariff_id),
|
||||||
|
label: item.tariff_name,
|
||||||
value: item.count,
|
value: item.count,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -65,8 +66,13 @@ export function SalesTab({ params }: SalesTabProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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.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
|
<StatCard
|
||||||
label={t('admin.salesStats.sales.avgOrder')}
|
label={t('admin.salesStats.sales.avgOrder')}
|
||||||
value={formatWithCurrency(data.avg_order_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
|
value={formatWithCurrency(data.avg_order_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
|
||||||
@@ -76,7 +82,7 @@ export function SalesTab({ params }: SalesTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<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')} />
|
<DonutChart data={periodPieData} title={t('admin.salesStats.sales.byPeriod')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -87,11 +93,10 @@ export function SalesTab({ params }: SalesTabProps) {
|
|||||||
valueLabel={t('admin.salesStats.summary.revenue')}
|
valueLabel={t('admin.salesStats.summary.revenue')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MultiSeriesAreaChart
|
<StackedBarChart
|
||||||
data={dailyByTariffData}
|
data={dailyByTariffData}
|
||||||
title={t('admin.salesStats.sales.dailyByTariff')}
|
title={t('admin.salesStats.sales.dailyByTariff')}
|
||||||
chartId="sales-daily-by-tariff"
|
valueFormatter={(v) => String(v)}
|
||||||
valueLabel={t('admin.salesStats.sales.subscriptions')}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
135
src/components/sales-stats/StackedBarChart.tsx
Normal file
135
src/components/sales-stats/StackedBarChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1317,6 +1317,7 @@
|
|||||||
},
|
},
|
||||||
"sales": {
|
"sales": {
|
||||||
"totalSales": "Total Sales",
|
"totalSales": "Total Sales",
|
||||||
|
"totalRevenue": "Revenue",
|
||||||
"avgOrder": "Avg Order",
|
"avgOrder": "Avg Order",
|
||||||
"topTariff": "Top Tariff",
|
"topTariff": "Top Tariff",
|
||||||
"byTariff": "Sales by Tariff",
|
"byTariff": "Sales by Tariff",
|
||||||
|
|||||||
@@ -1190,6 +1190,7 @@
|
|||||||
},
|
},
|
||||||
"sales": {
|
"sales": {
|
||||||
"totalSales": "کل فروش",
|
"totalSales": "کل فروش",
|
||||||
|
"totalRevenue": "درآمد",
|
||||||
"avgOrder": "میانگین سفارش",
|
"avgOrder": "میانگین سفارش",
|
||||||
"topTariff": "تعرفه برتر",
|
"topTariff": "تعرفه برتر",
|
||||||
"byTariff": "بر اساس تعرفه",
|
"byTariff": "بر اساس تعرفه",
|
||||||
|
|||||||
@@ -1339,6 +1339,7 @@
|
|||||||
},
|
},
|
||||||
"sales": {
|
"sales": {
|
||||||
"totalSales": "Всего продаж",
|
"totalSales": "Всего продаж",
|
||||||
|
"totalRevenue": "Выручка",
|
||||||
"avgOrder": "Средний чек",
|
"avgOrder": "Средний чек",
|
||||||
"topTariff": "Топ тариф",
|
"topTariff": "Топ тариф",
|
||||||
"byTariff": "Продажи по тарифам",
|
"byTariff": "Продажи по тарифам",
|
||||||
|
|||||||
@@ -1190,6 +1190,7 @@
|
|||||||
},
|
},
|
||||||
"sales": {
|
"sales": {
|
||||||
"totalSales": "总销售",
|
"totalSales": "总销售",
|
||||||
|
"totalRevenue": "营收",
|
||||||
"avgOrder": "平均订单",
|
"avgOrder": "平均订单",
|
||||||
"topTariff": "热门套餐",
|
"topTariff": "热门套餐",
|
||||||
"byTariff": "按套餐",
|
"byTariff": "按套餐",
|
||||||
|
|||||||
Reference in New Issue
Block a user