feat: advanced analytics — revenue trends, status breakdown, manager stats
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -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 />} />
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user