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 { 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
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": {
|
||||
"totalSales": "Total Sales",
|
||||
"totalRevenue": "Revenue",
|
||||
"avgOrder": "Avg Order",
|
||||
"topTariff": "Top Tariff",
|
||||
"byTariff": "Sales by Tariff",
|
||||
|
||||
@@ -1190,6 +1190,7 @@
|
||||
},
|
||||
"sales": {
|
||||
"totalSales": "کل فروش",
|
||||
"totalRevenue": "درآمد",
|
||||
"avgOrder": "میانگین سفارش",
|
||||
"topTariff": "تعرفه برتر",
|
||||
"byTariff": "بر اساس تعرفه",
|
||||
|
||||
@@ -1339,6 +1339,7 @@
|
||||
},
|
||||
"sales": {
|
||||
"totalSales": "Всего продаж",
|
||||
"totalRevenue": "Выручка",
|
||||
"avgOrder": "Средний чек",
|
||||
"topTariff": "Топ тариф",
|
||||
"byTariff": "Продажи по тарифам",
|
||||
|
||||
@@ -1190,6 +1190,7 @@
|
||||
},
|
||||
"sales": {
|
||||
"totalSales": "总销售",
|
||||
"totalRevenue": "营收",
|
||||
"avgOrder": "平均订单",
|
||||
"topTariff": "热门套餐",
|
||||
"byTariff": "按套餐",
|
||||
|
||||
Reference in New Issue
Block a user