From 9b04a4527cd369d2198b08a03deb6814bef8f2b6 Mon Sep 17 00:00:00 2001 From: houseassassin Date: Sun, 10 May 2026 17:27:44 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20advanced=20analytics=20=E2=80=94=20reve?= =?UTF-8?q?nue=20trends,=20status=20breakdown,=20manager=20stats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/App.jsx | 4 +- src/admin/analytics/AnalyticsPage.jsx | 343 ++++++++++++++++++++++++ src/admin/analytics/useAnalyticsPage.js | 25 ++ 3 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 src/admin/analytics/AnalyticsPage.jsx create mode 100644 src/admin/analytics/useAnalyticsPage.js diff --git a/src/App.jsx b/src/App.jsx index 40ba2d4..3923a47 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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() { }> }> } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/src/admin/analytics/AnalyticsPage.jsx b/src/admin/analytics/AnalyticsPage.jsx new file mode 100644 index 0000000..996cbd2 --- /dev/null +++ b/src/admin/analytics/AnalyticsPage.jsx @@ -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 }) => ( +
+

+ {label} +

+

+ {value} +

+
+); + +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 ( +
+ Загрузка... +
+ ); + } + + const chartPaddingTop = 30; + const chartPaddingBottom = 28; + const chartHeight = 200; + const innerHeight = chartHeight - chartPaddingTop - chartPaddingBottom; + + return ( +
+ {/* Page title */} +
+ +

+ Аналитика +

+
+ + {/* Section A — Summary cards */} +
+

+ Сводка +

+
+ + + + 0 ? formatRub(metrics.avg) : '—'} /> +
+
+ + {/* Section B — Orders by status */} +
+

+ Заявки по статусам +

+
+ {STATUS_LIST.map((status) => { + const count = statusCounts[status]; + const pct = (count / maxStatusCount) * 100; + return ( +
+
+ + {status} + + + {count} + +
+
+
0 ? 4 : 0, + }} /> +
+
+ ); + })} +
+
+ + {/* Section C — Revenue trend (last 6 months) */} +
+

+ Выручка за последние 6 месяцев +

+ + {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 ( + + {/* Bar */} + {barH > 0 && ( + + )} + {barH === 0 && ( + + )} + {/* Revenue label above bar */} + {m.revenue > 0 && ( + + {m.revenue >= 1000 + ? `${Math.round(m.revenue / 1000)}к` + : String(Math.round(m.revenue))} + + )} + {/* Month label below */} + + {m.label} + + + ); + })} + +
+ + {/* Section D — Manager breakdown */} +
+

+ По менеджерам +

+ {managerStats.length === 0 ? ( +

Нет данных

+ ) : ( +
+ + + + {['Менеджер', 'Заявок', 'Завершено', 'Выручка', 'Конверсия'].map((col) => ( + + ))} + + + + {managerStats.map((m, idx) => { + const conversion = m.total > 0 ? Math.round((m.completed / m.total) * 100) : 0; + return ( + + + + + + + + ); + })} + +
+ {col} +
+ {m.name} + + {m.total} + + {m.completed} + + {formatRub(m.revenue)} + + = 50 ? 'rgba(16,185,129,0.12)' : 'rgba(107,114,128,0.1)', + color: conversion >= 50 ? '#10b981' : '#6b7280', + }}> + {conversion}% + +
+
+ )} +
+
+ ); +}; + +export default AnalyticsPage; diff --git a/src/admin/analytics/useAnalyticsPage.js b/src/admin/analytics/useAnalyticsPage.js new file mode 100644 index 0000000..83c7602 --- /dev/null +++ b/src/admin/analytics/useAnalyticsPage.js @@ -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 }; +}