feat: advanced analytics — revenue trends, status breakdown, manager stats

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 17:27:44 +03:00
parent 3df6029f89
commit 9b04a4527c
3 changed files with 371 additions and 1 deletions
+3 -1
View File
@@ -15,6 +15,7 @@ const ProtectedRoute = React.lazy(() => import('./admin/auth/ProtectedRoute'));
const AdminLayout = React.lazy(() => import('./admin/layout/AdminLayout'));
const KanbanBoard = React.lazy(() => import('./admin/orders/KanbanBoard'));
const AnalyticsDashboard = React.lazy(() => import('./admin/analytics/AnalyticsDashboard'));
const AnalyticsPage = React.lazy(() => import('./admin/analytics/AnalyticsPage'));
const CompletedOrdersTable = React.lazy(() => import('./admin/analytics/CompletedOrdersTable'));
const UserManagementPage = React.lazy(() => import('./admin/users/UserManagementPage'));
const SettingsPage = React.lazy(() => import('./admin/settings/SettingsPage'));
@@ -52,7 +53,8 @@ function App() {
<Route path="/admin/*" element={<ProtectedRoute />}>
<Route element={<AdminLayout />}>
<Route path="kanban" element={<KanbanBoard />} />
<Route path="analytics" element={<AnalyticsDashboard />} />
<Route path="analytics" element={<AnalyticsPage />} />
<Route path="analytics-dashboard" element={<AnalyticsDashboard />} />
<Route path="completed" element={<CompletedOrdersTable />} />
<Route path="users" element={<UserManagementPage />} />
<Route path="settings" element={<SettingsPage />} />
+343
View File
@@ -0,0 +1,343 @@
import { useMemo } from 'react';
import { TrendingUp } from 'lucide-react';
import { useAnalyticsPage } from './useAnalyticsPage';
const MONTHS_RU = ['Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн', 'Июл', 'Авг', 'Сен', 'Окт', 'Ноя', 'Дек'];
const STATUS_COLORS = {
'новая': '#3b82f6',
'в работе': '#f59e0b',
'завершена': '#10b981',
'отменена': '#6b7280',
};
const STATUS_LIST = ['новая', 'в работе', 'завершена', 'отменена'];
function formatRub(value) {
if (!value || value === 0) return '₽ 0';
return '₽ ' + Math.round(value).toLocaleString('ru-RU');
}
function getLast6Months() {
const months = [];
const now = new Date();
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push({ key: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`, label: MONTHS_RU[d.getMonth()] });
}
return months;
}
const SummaryCard = ({ label, value }) => (
<div style={{
background: '#fff',
borderRadius: 12,
padding: 20,
border: '1px solid var(--border-light)',
boxShadow: '0 2px 8px rgba(15,23,42,0.05)',
}}>
<p style={{ fontSize: '0.72rem', fontWeight: 700, textTransform: 'uppercase', color: 'var(--text-muted)', letterSpacing: '0.4px', marginBottom: 8 }}>
{label}
</p>
<p style={{ fontSize: '1.6rem', fontWeight: 800, color: 'var(--text-main)', lineHeight: 1, fontFamily: 'var(--font-heading)' }}>
{value}
</p>
</div>
);
const AnalyticsPage = () => {
const { orders, loading } = useAnalyticsPage();
const metrics = useMemo(() => {
const total = orders.length;
const completed = orders.filter((o) => o.status === 'завершена');
const completedCount = completed.length;
const revenue = completed.reduce((sum, o) => sum + (Number(o.final_cost) || 0), 0);
const avg = completedCount > 0 ? revenue / completedCount : 0;
return { total, completedCount, revenue, avg };
}, [orders]);
const statusCounts = useMemo(() => {
const counts = {};
STATUS_LIST.forEach((s) => { counts[s] = 0; });
orders.forEach((o) => {
if (counts[o.status] !== undefined) counts[o.status]++;
});
return counts;
}, [orders]);
const maxStatusCount = useMemo(() => Math.max(...Object.values(statusCounts), 1), [statusCounts]);
const revenueByMonth = useMemo(() => {
const months = getLast6Months();
const map = {};
months.forEach((m) => { map[m.key] = 0; });
orders
.filter((o) => o.status === 'завершена')
.forEach((o) => {
if (!o.created_at) return;
const d = new Date(o.created_at);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
if (map[key] !== undefined) {
map[key] += Number(o.final_cost) || 0;
}
});
return months.map((m) => ({ ...m, revenue: map[m.key] }));
}, [orders]);
const maxMonthRevenue = useMemo(() => Math.max(...revenueByMonth.map((m) => m.revenue), 1), [revenueByMonth]);
const managerStats = useMemo(() => {
const map = {};
orders.forEach((o) => {
const name = o.assigned_to || 'Не назначен';
if (!map[name]) {
map[name] = { name, total: 0, completed: 0, revenue: 0 };
}
map[name].total++;
if (o.status === 'завершена') {
map[name].completed++;
map[name].revenue += Number(o.final_cost) || 0;
}
});
return Object.values(map).sort((a, b) => b.revenue - a.revenue);
}, [orders]);
if (loading) {
return (
<div style={{ padding: 30, textAlign: 'center', color: 'var(--text-muted)', paddingTop: 80 }}>
Загрузка...
</div>
);
}
const chartPaddingTop = 30;
const chartPaddingBottom = 28;
const chartHeight = 200;
const innerHeight = chartHeight - chartPaddingTop - chartPaddingBottom;
return (
<div style={{ padding: 30, maxWidth: 900 }}>
{/* Page title */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 28 }}>
<TrendingUp size={22} color="var(--accent-blue)" />
<h1 style={{
fontFamily: 'var(--font-heading)',
fontWeight: 800,
fontSize: '1.45rem',
color: 'var(--text-main)',
}}>
Аналитика
</h1>
</div>
{/* Section A — Summary cards */}
<div
className="glass-panel"
style={{ padding: 24, marginBottom: 24 }}
>
<h2 style={{ fontFamily: 'var(--font-heading)', fontWeight: 700, fontSize: '0.9rem', color: 'var(--text-muted)', marginBottom: 16, textTransform: 'uppercase', letterSpacing: '0.4px' }}>
Сводка
</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 16 }}>
<SummaryCard label="Всего заявок" value={metrics.total} />
<SummaryCard label="Завершено" value={metrics.completedCount} />
<SummaryCard label="Выручка" value={formatRub(metrics.revenue)} />
<SummaryCard label="Средний чек" value={metrics.completedCount > 0 ? formatRub(metrics.avg) : '—'} />
</div>
</div>
{/* Section B — Orders by status */}
<div className="glass-panel" style={{ padding: 24, marginBottom: 24 }}>
<h2 style={{ fontFamily: 'var(--font-heading)', fontWeight: 700, fontSize: '0.9rem', color: 'var(--text-muted)', marginBottom: 20, textTransform: 'uppercase', letterSpacing: '0.4px' }}>
Заявки по статусам
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{STATUS_LIST.map((status) => {
const count = statusCounts[status];
const pct = (count / maxStatusCount) * 100;
return (
<div key={status}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 5 }}>
<span style={{ fontFamily: 'var(--font-heading)', fontWeight: 600, fontSize: '0.85rem', color: 'var(--text-main)' }}>
{status}
</span>
<span style={{ fontFamily: 'var(--font-heading)', fontWeight: 700, fontSize: '0.85rem', color: STATUS_COLORS[status] }}>
{count}
</span>
</div>
<div style={{ height: 10, borderRadius: 6, background: 'rgba(15,23,42,0.06)', overflow: 'hidden' }}>
<div style={{
height: '100%',
width: `${pct}%`,
background: STATUS_COLORS[status],
borderRadius: 6,
transition: 'width 0.4s ease',
minWidth: count > 0 ? 4 : 0,
}} />
</div>
</div>
);
})}
</div>
</div>
{/* Section C — Revenue trend (last 6 months) */}
<div className="glass-panel" style={{ padding: 24, marginBottom: 24 }}>
<h2 style={{ fontFamily: 'var(--font-heading)', fontWeight: 700, fontSize: '0.9rem', color: 'var(--text-muted)', marginBottom: 20, textTransform: 'uppercase', letterSpacing: '0.4px' }}>
Выручка за последние 6 месяцев
</h2>
<svg
width="100%"
height={chartHeight}
viewBox={`0 0 600 ${chartHeight}`}
preserveAspectRatio="none"
style={{ display: 'block', overflow: 'visible' }}
>
{revenueByMonth.map((m, i) => {
const barCount = revenueByMonth.length;
const slotWidth = 600 / barCount;
const barWidth = slotWidth * 0.55;
const x = i * slotWidth + slotWidth / 2;
const barH = m.revenue > 0 ? (m.revenue / maxMonthRevenue) * innerHeight : 0;
const barY = chartPaddingTop + innerHeight - barH;
return (
<g key={m.key}>
{/* Bar */}
{barH > 0 && (
<rect
x={x - barWidth / 2}
y={barY}
width={barWidth}
height={barH}
rx={4}
fill="var(--accent-blue)"
opacity={0.85}
/>
)}
{barH === 0 && (
<rect
x={x - barWidth / 2}
y={chartPaddingTop + innerHeight - 3}
width={barWidth}
height={3}
rx={2}
fill="rgba(15,23,42,0.1)"
/>
)}
{/* Revenue label above bar */}
{m.revenue > 0 && (
<text
x={x}
y={barY - 6}
textAnchor="middle"
fontSize={9}
fontFamily="Montserrat, sans-serif"
fontWeight={700}
fill="var(--text-muted)"
>
{m.revenue >= 1000
? `${Math.round(m.revenue / 1000)}к`
: String(Math.round(m.revenue))}
</text>
)}
{/* Month label below */}
<text
x={x}
y={chartPaddingTop + innerHeight + 18}
textAnchor="middle"
fontSize={10}
fontFamily="Montserrat, sans-serif"
fontWeight={600}
fill="var(--text-muted)"
>
{m.label}
</text>
</g>
);
})}
</svg>
</div>
{/* Section D — Manager breakdown */}
<div className="glass-panel" style={{ padding: 24, marginBottom: 24 }}>
<h2 style={{ fontFamily: 'var(--font-heading)', fontWeight: 700, fontSize: '0.9rem', color: 'var(--text-muted)', marginBottom: 20, textTransform: 'uppercase', letterSpacing: '0.4px' }}>
По менеджерам
</h2>
{managerStats.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>Нет данных</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-body)', fontSize: '0.875rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--border-light)' }}>
{['Менеджер', 'Заявок', 'Завершено', 'Выручка', 'Конверсия'].map((col) => (
<th
key={col}
style={{
padding: '8px 12px',
textAlign: 'left',
fontSize: '0.72rem',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.4px',
color: 'var(--text-muted)',
fontFamily: 'var(--font-heading)',
whiteSpace: 'nowrap',
}}
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{managerStats.map((m, idx) => {
const conversion = m.total > 0 ? Math.round((m.completed / m.total) * 100) : 0;
return (
<tr
key={m.name}
style={{
borderBottom: '1px solid var(--border-light)',
background: idx % 2 === 0 ? 'transparent' : 'rgba(15,23,42,0.015)',
}}
>
<td style={{ padding: '10px 12px', fontWeight: 600, color: 'var(--text-main)' }}>
{m.name}
</td>
<td style={{ padding: '10px 12px', color: 'var(--text-main)' }}>
{m.total}
</td>
<td style={{ padding: '10px 12px', color: '#10b981', fontWeight: 600 }}>
{m.completed}
</td>
<td style={{ padding: '10px 12px', fontWeight: 700, color: 'var(--text-main)' }}>
{formatRub(m.revenue)}
</td>
<td style={{ padding: '10px 12px' }}>
<span style={{
display: 'inline-block',
padding: '2px 8px',
borderRadius: 20,
fontSize: '0.78rem',
fontWeight: 700,
background: conversion >= 50 ? 'rgba(16,185,129,0.12)' : 'rgba(107,114,128,0.1)',
color: conversion >= 50 ? '#10b981' : '#6b7280',
}}>
{conversion}%
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
};
export default AnalyticsPage;
+25
View File
@@ -0,0 +1,25 @@
import { useState, useEffect } from 'react';
import { supabase } from '../../lib/supabase';
export function useAnalyticsPage() {
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let isMounted = true;
supabase
.from('orders')
.select('id, status, final_cost, created_at, assigned_to, film_thickness')
.then(({ data }) => {
if (isMounted) {
setOrders(data ?? []);
setLoading(false);
}
});
return () => {
isMounted = false;
};
}, []);
return { orders, loading };
}