krona-front/src/features/analytics/AnalyticsModule.tsx

301 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState } from 'react';
import { StatCard, Badge, MaturityBadge } from '../../shared/ui';
import { formatCurrency, formatNumber } from '../../shared/utils/formatters';
import {
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
AreaChart,
Area,
} from 'recharts';
import { BarChart3, TrendingUp, DollarSign, Users } from 'lucide-react';
const PERIOD_METRICS = {
today: {
revenue: 184500,
revChange: '+12.4%',
orders: 58,
ordersChange: '+6',
avgCheck: 3180,
avgCheckChange: '+180 ₽',
retention: '68.4%',
retentionChange: '+2.1%',
salesTrend: [
{ label: '10:00', revenue: 14200, orders: 4 },
{ label: '12:00', revenue: 38400, orders: 12 },
{ label: '14:00', revenue: 76000, orders: 24 },
{ label: '16:00', revenue: 104500, orders: 32 },
{ label: '18:00', revenue: 149000, orders: 45 },
{ label: '20:00', revenue: 184500, orders: 58 },
],
},
week: {
revenue: 1428000,
revChange: '+15.8%',
orders: 432,
ordersChange: '+42',
avgCheck: 3305,
avgCheckChange: '+210 ₽',
retention: '70.2%',
retentionChange: '+3.5%',
salesTrend: [
{ label: 'Пн', revenue: 142000, orders: 48 },
{ label: 'Вт', revenue: 156000, orders: 52 },
{ label: 'Ср', revenue: 168000, orders: 56 },
{ label: 'Чт', revenue: 174000, orders: 59 },
{ label: 'Пт', revenue: 245000, orders: 84 },
{ label: 'Сб', revenue: 290000, orders: 98 },
{ label: 'Вс', revenue: 260000, orders: 89 },
],
},
month: {
revenue: 4850000,
revChange: '+18.5%',
orders: 1490,
ordersChange: '+180',
avgCheck: 3255,
avgCheckChange: '+315 ₽',
retention: '72.4%',
retentionChange: '+4.0%',
salesTrend: [
{ label: '1-я нед', revenue: 1120000, orders: 340 },
{ label: '2-я нед', revenue: 1240000, orders: 380 },
{ label: '3-я нед', revenue: 1390000, orders: 420 },
{ label: '4-я нед', revenue: 1428000, orders: 432 },
],
},
year: {
revenue: 52400000,
revChange: '+34.2%',
orders: 16800,
ordersChange: '+2400',
avgCheck: 3120,
avgCheckChange: '+420 ₽',
retention: '75.8%',
retentionChange: '+6.2%',
salesTrend: [
{ label: 'Q1', revenue: 11200000, orders: 3600 },
{ label: 'Q2', revenue: 12800000, orders: 4100 },
{ label: 'Q3', revenue: 14100000, orders: 4600 },
{ label: 'Q4 (прогноз)', revenue: 14300000, orders: 4500 },
],
},
};
const SALES_CHANNEL_DATA = [
{ name: 'Зал ресторана', value: 48, color: '#2F684C' },
{ name: 'Собственная доставка', value: 26, color: '#B78335' },
{ name: 'Telegram-бот', value: 14, color: '#527286' },
{ name: 'Яндекс Еда / Агрегаторы', value: 12, color: '#B16D50' },
];
const ABC_ITEMS_DATA = [
{ name: 'Филадельфия Премиум', revenue: 420000, qty: 442, margin: '71.2%', category: 'A' },
{ name: 'Том Ям с морепродуктами', revenue: 310000, qty: 455, margin: '68.5%', category: 'A' },
{ name: 'Калифорния краб', revenue: 265000, qty: 335, margin: '69.0%', category: 'A' },
{ name: 'Рамен Тонкоцу', revenue: 195000, qty: 330, margin: '64.5%', category: 'B' },
{ name: 'Гёдза со свининой', revenue: 160000, qty: 380, margin: '74.0%', category: 'B' },
{ name: 'Эби Темпура', revenue: 84000, qty: 155, margin: '58.0%', category: 'C' },
];
export const AnalyticsModule: React.FC = () => {
const [selectedPeriod, setSelectedPeriod] = useState<'today' | 'week' | 'month' | 'year'>('month');
const currentData = PERIOD_METRICS[selectedPeriod];
return (
<div className="space-y-4 animate-fade-in">
{/* Header with MaturityBadge and Period Switcher */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-krona-surface p-3.5 rounded-xl border border-krona-border shadow-krona-soft">
<div>
<div className="flex items-center gap-2">
<h2 className="text-sm font-bold text-krona-primary">
Сквозная бизнес-аналитика, ABC-матрица & BI
</h2>
<MaturityBadge level="target" size="sm" />
</div>
<p className="text-xs text-krona-secondary mt-0.5">
Анализ маржинальности, выручки по каналам, структуры чеков, LTV и Food Cost
</p>
</div>
{/* Date range selector */}
<div className="flex items-center gap-1 bg-krona-workspace p-1 rounded-lg border border-krona-border text-xs">
{[
{ id: 'today', label: 'Сегодня' },
{ id: 'week', label: '7 дней' },
{ id: 'month', label: '30 дней' },
{ id: 'year', label: 'Год' },
].map((p) => (
<button
key={p.id}
onClick={() => setSelectedPeriod(p.id as any)}
className={`px-3 py-1 rounded font-medium transition-colors ${
selectedPeriod === p.id
? 'bg-[#2F684C] text-white shadow-sm'
: 'text-krona-secondary hover:text-krona-primary'
}`}
>
{p.label}
</button>
))}
</div>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3.5">
<StatCard
title="Выручка за период"
value={formatCurrency(currentData.revenue)}
change={{ value: currentData.revChange, trend: 'up' }}
subtitle={`Заказов: ${formatNumber(currentData.orders)}`}
icon={<DollarSign className="w-4 h-4 text-krona-green" />}
/>
<StatCard
title="Средний чек"
value={formatCurrency(currentData.avgCheck)}
change={{ value: currentData.avgCheckChange, trend: 'up' }}
subtitle="По всем каналам"
icon={<BarChart3 className="w-4 h-4 text-krona-gold" />}
/>
<StatCard
title="Удержание гостей (CRR)"
value={currentData.retention}
change={{ value: currentData.retentionChange, trend: 'up' }}
subtitle="Повторные покупки"
icon={<Users className="w-4 h-4 text-sky-600" />}
/>
<StatCard
title="Маржинальность меню"
value="70.6%"
change={{ value: '+1.2%', trend: 'up' }}
subtitle="Food cost: 29.4%"
icon={<TrendingUp className="w-4 h-4 text-krona-green" />}
/>
</div>
{/* Main Charts Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Revenue Trend Area Chart */}
<div className="lg:col-span-2 bg-krona-surface border border-krona-border rounded-xl p-4 shadow-krona-soft space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-xs font-bold text-krona-primary">
Динамика выручки за выбранный период
</h4>
<Badge variant="green" size="sm">План выполнен на 106%</Badge>
</div>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={currentData.salesTrend} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="colorRev" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#2F684C" stopOpacity={0.4} />
<stop offset="95%" stopColor="#2F684C" stopOpacity={0.0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#DDDAD1" opacity={0.5} />
<XAxis dataKey="label" stroke="#71766F" fontSize={11} />
<YAxis stroke="#71766F" fontSize={11} tickFormatter={(val) => `${val / 1000}k`} />
<Tooltip
formatter={(val: any) => [formatCurrency(val), 'Выручка']}
contentStyle={{ backgroundColor: '#FCFAF5', borderColor: '#DDDAD1', borderRadius: '8px', fontSize: '11px' }}
/>
<Area type="monotone" dataKey="revenue" stroke="#2F684C" strokeWidth={2} fillOpacity={1} fill="url(#colorRev)" />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
{/* Sales by channel pie chart */}
<div className="bg-krona-surface border border-krona-border rounded-xl p-4 shadow-krona-soft space-y-3 flex flex-col justify-between">
<div>
<h4 className="text-xs font-bold text-krona-primary">Структура выручки по каналам продаж</h4>
<p className="text-[11px] text-krona-secondary">Доля в общем обороте заведений</p>
</div>
<div className="h-44 w-full flex items-center justify-center">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={SALES_CHANNEL_DATA}
innerRadius={45}
outerRadius={70}
paddingAngle={4}
dataKey="value"
>
{SALES_CHANNEL_DATA.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip formatter={(val: any) => [`${val}%`, 'Доля']} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="space-y-1 text-xs border-t border-krona-border/60 pt-2">
{SALES_CHANNEL_DATA.map((ch) => (
<div key={ch.name} className="flex items-center justify-between text-[11px]">
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: ch.color }}></span>
<span className="text-krona-secondary">{ch.name}</span>
</div>
<span className="font-bold font-mono text-krona-primary">{ch.value}%</span>
</div>
))}
</div>
</div>
</div>
{/* ABC Menu Matrix Table */}
<div className="bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft overflow-hidden">
<div className="p-3.5 border-b border-krona-border flex items-center justify-between">
<div>
<h4 className="text-xs font-bold text-krona-primary">ABC-анализ меню и блюд</h4>
<p className="text-[11px] text-krona-secondary">
Классификация позиций по выручке и маржинальности (Группа A формирует 80% выручки)
</p>
</div>
<Badge variant="gold" size="sm">ABC Matrix v2.1</Badge>
</div>
<table className="w-full text-left text-xs border-collapse">
<thead className="bg-krona-workspace/80 text-krona-secondary border-b border-krona-border">
<tr>
<th className="p-3 font-semibold">Блюдо / Позиция</th>
<th className="p-3 font-semibold text-center">Класс</th>
<th className="p-3 font-semibold text-right">Продано порций</th>
<th className="p-3 font-semibold text-right">Выручка</th>
<th className="p-3 font-semibold text-right">Маржинальность</th>
</tr>
</thead>
<tbody className="divide-y divide-krona-border/50">
{ABC_ITEMS_DATA.map((it) => (
<tr key={it.name} className="hover:bg-krona-workspace/40 transition-colors">
<td className="p-3 font-medium text-krona-primary">{it.name}</td>
<td className="p-3 text-center">
<Badge
variant={it.category === 'A' ? 'green' : it.category === 'B' ? 'gold' : 'neutral'}
size="sm"
>
Группа {it.category}
</Badge>
</td>
<td className="p-3 text-right font-mono text-krona-secondary">{it.qty} шт</td>
<td className="p-3 text-right font-mono font-bold text-krona-primary">
{formatCurrency(it.revenue)}
</td>
<td className="p-3 text-right font-mono text-emerald-700 font-semibold">{it.margin}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};