diff --git a/src/components/admin/AdminDashboard.jsx b/src/components/admin/AdminDashboard.jsx index 4cd916e..411249d 100644 --- a/src/components/admin/AdminDashboard.jsx +++ b/src/components/admin/AdminDashboard.jsx @@ -1,8 +1,9 @@ /** * @file AdminDashboard.jsx - * @description Admin analytics dashboard. Displays KPI cards, status pie chart, - * daily trend line, confirmation funnel, SMS stats, and driver performance - * bar chart. Supports period selection (1d/7d/30d/all) and mobile layout. + * @description Admin analytics dashboard. Redesigned with responsive grid layout + * that uses desktop width effectively. KPI cards, status pie, daily trend, + * confirmation funnel, SMS stats, driver performance, pickup stats. + * Period label shown in section subtitles. */ import React, { useState, useEffect } from 'react'; import { @@ -10,7 +11,6 @@ import { PieChart, Pie, Cell, Legend, LineChart, Line, CartesianGrid, } from 'recharts'; import { Panel } from '../UI/Panel'; -import { Badge } from '../UI/Badge'; import { SegmentedTabs } from '../UI/SegmentedTabs'; import { Skeleton } from '../UI/Loading'; import { useAdminStats } from '../../hooks/useAdminStats'; @@ -21,7 +21,7 @@ import { PickupStatsPanel } from './PickupStatsPanel'; const useIsMobile = () => { const [mobile, setMobile] = useState(false); useEffect(() => { - const mq = window.matchMedia('(max-width: 640px)'); + const mq = window.matchMedia('(max-width: 768px)'); setMobile(mq.matches); const handler = (e) => setMobile(e.matches); mq.addEventListener('change', handler); @@ -38,7 +38,7 @@ const STATUS_COLORS = { driver_assigned: '#3b82f6', loaded: '#6366f1', on_route: '#8b5cf6', - delivered: '#10b981', + delivered: '#22c55e', picked_up: '#14b8a6', paid_storage: '#06b6d4', problem: '#ef4444', @@ -66,9 +66,16 @@ const PERIOD_OPTIONS = [ { key: '1d', label: 'Сегодня' }, { key: '7d', label: '7 дней' }, { key: '30d', label: '30 дней' }, - { key: 'all', label: 'Все' }, + { key: 'all', label: 'Всё время' }, ]; +const PERIOD_LABELS = { + '1d': 'за сегодня', + '7d': 'за 7 дней', + '30d': 'за 30 дней', + 'all': 'за всё время', +}; + // ── Custom Recharts Tooltip ───────────────────────────────────────────────── const CustomTooltip = ({ active, payload, label: tooltipLabel }) => { if (!active || !payload?.length) return null; @@ -87,44 +94,65 @@ const CustomTooltip = ({ active, payload, label: tooltipLabel }) => { ); }; +// ── KPI Card ──────────────────────────────────────────────────────────────── +const KpiCard = ({ label, value, color, mobile }) => ( + +
+ {label} +
+
+ {value ?? '—'} +
+
+); + +// ── Section Header ────────────────────────────────────────────────────────── +const SectionHeader = ({ title, subtitle, mobile }) => ( +
+

+ {title} +

+ {subtitle && ( +
+ {subtitle} +
+ )} +
+); + // ── AdminDashboard Component ─────────────────────────────────────────────── export const AdminDashboard = () => { - // ── State & Hooks ───────────────────────────────────────────────────────── const [period, setPeriod] = useState('7d'); const mobile = useIsMobile(); const { stats, statusDist, dailyTrend, driverStats, economics, isLoading, error, refetch } = useAdminStats(period); const { stats: pickupStats, isLoading: pickupLoading } = usePickupStats(period); - // ── Responsive Layout Values (must be before early returns) ──────────────── - const chartHeight = mobile ? 200 : 240; - const kpiMin = mobile ? '80px' : '110px'; - const chartGridCols = mobile ? '1fr' : '1fr 2fr'; - const driverLabelWidth = mobile ? 80 : 120; - const fontSize = mobile ? { xs: '0.6rem', s: '0.7rem', m: '0.78rem', l: '0.85rem', xl: '1rem' } - : { xs: '0.65rem', s: '0.68rem', m: '0.78rem', l: '0.85rem', xl: '1.1rem' }; + const periodLabel = PERIOD_LABELS[period] || ''; + const chartHeight = mobile ? 200 : 280; + const fontSize = mobile ? { xs: '0.6rem', s: '0.7rem', m: '0.78rem', l: '0.85rem' } + : { xs: '0.72rem', s: '0.78rem', m: '0.85rem', l: '0.95rem' }; - // ── Loading / Error States ───────────────────────────────────────────────── + // ── Loading State ───────────────────────────────────────────────────────── if (isLoading) { return ( -
+
-
+
{Array.from({ length: 7 }).map((_, i) => ( - + - + ))}
- - -
- -
-
); } @@ -147,7 +175,6 @@ export const AdminDashboard = () => { status: s.delivery_status, })).filter(d => d.value > 0); - // ── Trend & Driver Data ─────────────────────────────────────────────────── const trendData = (dailyTrend || []).map(d => ({ date: d.date ? new Date(d.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }) : '', delivered: d.delivered || 0, picked_up: d.picked_up || 0, total: d.total || 0, problems: d.problems || 0, @@ -158,7 +185,6 @@ export const AdminDashboard = () => { total: d.total || 0, delivered: d.delivered || 0, picked_up: d.picked_up || 0, problems: d.problems || 0, })); - // Funnel: ALWAYS show all steps, even with 0 values // ── Funnel Data ──────────────────────────────────────────────────────────── const funnelSteps = [ { label: 'Согласовано после 1-й SMS', value: econ.confirmed_after_sms1 || 0, color: '#22c55e' }, @@ -169,49 +195,56 @@ export const AdminDashboard = () => { { label: 'Отмена', value: econ.cancelled_count || 0, color: '#ef4444' }, ]; - // ── Render ───────────────────────────────────────────────────────────────── - return ( -
+ // ── Grid Layout ─────────────────────────────────────────────────────────── + // Desktop: 12-col grid. Mobile: single column. + const gridCols = mobile ? '1fr' : 'repeat(12, 1fr)'; + const colSpan = (n) => mobile ? '1 / -1' : `span ${n}`; - {/* Period selector */} + return ( +
+ + {/* ── Header + Period selector ─────────────────────────────────────────── */}
-

Аналитика

-

Статистика по доставкам

+

+ Аналитика +

+

+ Статистика по доставкам {periodLabel} +

- {/* KPI — centered on mobile */} -
- {[ - { label: 'Всего', val: totalGroups }, - { label: 'Ожидает', val: sv.pending }, - { label: 'В работе', val: sv.in_progress }, - { label: 'Доставлено', val: sv.delivered }, - { label: 'Вывезено', val: sv.picked_up }, - { label: 'Проблемы', val: sv.problem }, - { label: '% доставки', val: sv.delivery_rate != null ? sv.delivery_rate + '%' : '—' }, - ].map((kpi, i) => ( - -
{kpi.label}
-
{kpi.val ?? '—'}
-
- ))} + {/* ── KPI Cards ─────────────────────────────────────────────────────────── */} +
+ + + + + + +
- {/* Pie + Line — stacked on mobile, side-by-side on desktop */} -
- -

По статусам

+ {/* ── Main Grid: Charts + Tables ───────────────────────────────────────── */} +
+ + {/* Status Pie — 4 cols desktop */} + + {statusPieData.length === 0 ? ( -
Нет данных
+
Нет данных
) : ( {statusPieData.map(entry => ( @@ -224,16 +257,17 @@ export const AdminDashboard = () => { )}
- -

Тренд по дням

+ {/* Daily Trend — 8 cols desktop */} + + {trendData.length === 0 ? ( -
Нет данных
+
Нет данных
) : ( - - + + } /> @@ -244,137 +278,139 @@ export const AdminDashboard = () => { )}
-
- {/* Status table */} - -

Все статусы

- {statusPieData.length === 0 ? ( -
Нет данных
- ) : ( -
-
-
Статус
Кол-во
Доля
-
- {statusPieData.map(s => { - const pct = totalGroups > 0 ? ((s.value / totalGroups) * 100).toFixed(1) : 0; - return ( -
-
-
{s.name}
-
{s.value}
-
{pct}%
-
- ); - })} -
- )} - - - {/* Воронка согласования — ALL steps always visible */} - -

Воронка согласования

- {totalGroups === 0 ? ( -
Нет данных
- ) : ( -
- {funnelSteps.map((step, i) => { - const pct = totalGroups > 0 ? Math.round((step.value / totalGroups) * 100) : 0; - const widthPct = step.value > 0 ? Math.max(15, (step.value / totalGroups) * 100) : 15; - return ( -
-
- {step.value} -
-
0 ? step.color : 'var(--color-border, #334155)', - borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center', - transition: 'width 0.4s ease', minWidth: '40px', maxWidth: '100%', - opacity: step.value > 0 ? 1 : 0.5, + {/* Status Table — 4 cols desktop */} + + + {statusPieData.length === 0 ? ( +
Нет данных
+ ) : ( +
+
+
Статус
Кол-во
Доля
+
+ {statusPieData.map(s => { + const pct = totalGroups > 0 ? ((s.value / totalGroups) * 100).toFixed(1) : 0; + return ( +
- 0 ? '#fff' : 'var(--color-text-muted)', textShadow: step.value > 0 ? '0 1px 2px rgba(0,0,0,0.3)' : 'none' }}> - {pct}% - +
+
{s.name}
+
{s.value}
+
{pct}%
-
- {step.label} + ); + })} +
+ )} + + + {/* Funnel — 4 cols desktop */} + + + {totalGroups === 0 ? ( +
Нет данных
+ ) : ( +
+ {funnelSteps.map((step, i) => { + const pct = totalGroups > 0 ? Math.round((step.value / totalGroups) * 100) : 0; + const widthPct = step.value > 0 ? Math.max(15, (step.value / totalGroups) * 100) : 15; + return ( +
+
+ {step.value} +
+
0 ? step.color : 'var(--color-border, #334155)', + borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center', + transition: 'width 0.4s ease', minWidth: '40px', maxWidth: '100%', + opacity: step.value > 0 ? 1 : 0.5, + }}> + 0 ? '#fff' : 'var(--color-text-muted)', textShadow: step.value > 0 ? '0 1px 2px rgba(0,0,0,0.3)' : 'none' }}> + {pct}% + +
+
+ {step.label} +
+ {i < funnelSteps.length - 1 && ( +
+ )}
- {i < funnelSteps.length - 1 && ( -
- )} + ); + })} + +
+
+
Автосогласование
+
{econ.auto_confirm_pct ?? 0}%
+
+
+
Ручное вмешательство
+
{econ.manual_intervention_pct ?? 0}%
+
+
+
Всего согласовано
+
{econ.confirmed_auto_total ?? 0}
- ); - })} - -
-
-
Автосогласование
-
{econ.auto_confirm_pct ?? 0}%
-
-
-
Ручное вмешательство
-
{econ.manual_intervention_pct ?? 0}%
-
-
-
Всего согласовано
-
{econ.confirmed_auto_total ?? 0}
+ )} + + + {/* SMS + Drivers side by side — 4 cols each on desktop */} + + +
+ {[ + { label: 'SMS 1', val: econ.sms1_sent_count ?? 0 }, + { label: 'SMS 2', val: econ.sms2_sent_count ?? 0 }, + { label: 'Всего', val: (econ.sms1_sent_count || 0) + (econ.sms2_sent_count || 0) }, + ].map((item, i) => ( +
+
{item.label}
+
{item.val}
+
+ ))}
- )} -
+ - {/* SMS */} - -

SMS

-
- {[ - { label: 'SMS 1', val: econ.sms1_sent_count ?? 0 }, - { label: 'SMS 2', val: econ.sms2_sent_count ?? 0 }, - { label: 'Всего', val: (econ.sms1_sent_count || 0) + (econ.sms2_sent_count || 0) }, - ].map((item, i) => ( -
-
{item.label}
-
{item.val}
-
- ))} + {/* Drivers — 8 cols desktop */} + + + {driverData.length === 0 ? ( +
Нет данных
+ ) : ( + + + + + } /> + + + + + + + )} +
+ + {/* Pickup Stats — full width (12 cols) */} +
+
- - - {/* Pickup Stats */} - - - {/* Drivers */} - -

По водителям

- {driverData.length === 0 ? ( -
Нет данных
- ) : ( - - - - - } /> - - - - - - - )} -
+
); }; \ No newline at end of file diff --git a/src/components/admin/PickupStatsPanel.jsx b/src/components/admin/PickupStatsPanel.jsx index a25ff2a..e8074e4 100644 --- a/src/components/admin/PickupStatsPanel.jsx +++ b/src/components/admin/PickupStatsPanel.jsx @@ -11,6 +11,9 @@ const PICKUP_COLORS = { saturday: '#06b6d4', pickup: '#f59e0b', delivery: '#6366f1', + picked_up: '#14b8a6', + pending: '#94a3b8', + manual: '#eab308', }; const CustomTooltip = ({ active, payload }) => { @@ -34,17 +37,30 @@ const CustomTooltip = ({ active, payload }) => { const ProgressBar = ({ label, value, max, color, fontSize, mobile }) => { const pct = max > 0 ? Math.max(0, (value / max) * 100) : 0; return ( -
-
{label}
-
+
+
{label}
+
0 ? '4px' : '0' }} />
-
{value}
+
{value}
); }; -export const PickupStatsPanel = ({ stats, isLoading, mobile, fontSize }) => { +const SectionHeader = ({ title, subtitle, mobile }) => ( +
+

+ {title} +

+ {subtitle && ( +
+ {subtitle} +
+ )} +
+); + +export const PickupStatsPanel = ({ stats, isLoading, mobile, fontSize, periodLabel = '' }) => { if (isLoading) { return ( @@ -65,103 +81,137 @@ export const PickupStatsPanel = ({ stats, isLoading, mobile, fontSize }) => { ); } - const fs = fontSize || { xs: '0.65rem', s: '0.68rem', m: '0.78rem', l: '0.85rem', xl: '1.1rem' }; + const fs = fontSize || { xs: '0.72rem', s: '0.78rem', m: '0.85rem', l: '0.95rem' }; const totalPickups = Number(stats.total_pickups) || 0; + const pickedUp = Number(stats.picked_up) || 0; + const pending = Number(stats.pending) || 0; + const manual = Number(stats.manual) || 0; const pickupRate = Number(stats.pickup_rate) || 0; const avgDays = stats.avg_days_until_pickup != null ? Number(stats.avg_days_until_pickup) : null; const dist = stats.delivery_type_dist || {}; - const maxDay = Math.max( - Number(stats.pickup_today) || 0, - Number(stats.pickup_tomorrow) || 0, - Number(stats.pickup_day_after) || 0, - 1 - ); + const pickupToday = Number(stats.pickup_today) || 0; + const pickupTomorrow = Number(stats.pickup_tomorrow) || 0; + const pickupDayAfter = Number(stats.pickup_day_after) || 0; + const pickupFirstHalf = Number(stats.pickup_first_half) || 0; + const pickupSecondHalf = Number(stats.pickup_second_half) || 0; + const pickupOnSaturday = Number(stats.pickup_on_saturday) || 0; - const maxHalf = Math.max( - Number(stats.pickup_first_half) || 0, - Number(stats.pickup_second_half) || 0, - 1 - ); + const hasScheduledPickups = pickupToday + pickupTomorrow + pickupDayAfter > 0; + const hasTimeSlots = pickupFirstHalf + pickupSecondHalf > 0; + + const maxDay = Math.max(pickupToday, pickupTomorrow, pickupDayAfter, 1); + const maxHalf = Math.max(pickupFirstHalf, pickupSecondHalf, 1); const pieData = [ { name: 'Самовывоз', value: Number(dist.pickup) || 0, fill: PICKUP_COLORS.pickup }, { name: 'Доставка', value: Number(dist.delivery) || 0, fill: PICKUP_COLORS.delivery }, ].filter(d => d.value > 0); + // Desktop: 3-col grid inside panel. Mobile: stacked. + const innerCols = mobile ? '1fr' : '1fr 1fr 1fr'; + return ( - -

- 📦 Самовывоз -

+ + {/* KPI row */} -
+
{[ - { label: 'Всего самовывоз', val: totalPickups, color: '#f59e0b' }, - { label: 'Доля самовывоза', val: pickupRate + '%', color: '#f59e0b' }, - { label: 'Ср. дней до выдачи', val: avgDays !== null ? avgDays : '—', color: '#3b82f6' }, + { label: 'Всего', val: totalPickups, color: '#f59e0b' }, + { label: 'Завершено', val: pickedUp, color: '#14b8a6' }, + { label: 'Ожидает', val: pending, color: '#94a3b8' }, + { label: 'Доля', val: pickupRate + '%', color: '#f59e0b' }, ].map((kpi, i) => ( -
-
{kpi.label}
-
{kpi.val}
+
+
{kpi.label}
+
{kpi.val}
))}
- {/* Distribution by day */} -
-
По дням
-
- - - -
-
+ {/* Content grid: status breakdown | schedule | donut */} +
- {/* Half-day split */} -
-
По времени
-
- - -
-
- - {/* Saturday */} -
- Самовывоз в субботу - {Number(stats.pickup_on_saturday) || 0} -
- - {/* Delivery vs Pickup donut */} - {pieData.length > 0 && ( + {/* Status breakdown */}
-
Доставка vs Самовывоз
- - - - {pieData.map((entry, i) => ( - - ))} - - } /> - - -
- {pieData.map((d, i) => ( -
-
- {d.name}: {d.value} -
- ))} +
По статусам
+
+ + + +
+ {avgDays !== null && avgDays > 0 && ( +
+ Ср. дней до выдачи + {avgDays} +
+ )} +
+ + {/* Schedule — only show if there are scheduled pickups */} +
+
Расписание
+ {hasScheduledPickups ? ( +
+ + + +
+ ) : ( +
+ Нет запланированных самовывозов +
+ )} + + {hasTimeSlots && ( +
+ + +
+ )} + +
+ Самовывоз в субботу + {pickupOnSaturday}
- )} + + {/* Delivery vs Pickup donut */} +
+
Доставка vs Самовывоз
+ {pieData.length > 0 ? ( + <> + + + + {pieData.map((entry, i) => ( + + ))} + + } /> + + +
+ {pieData.map((d, i) => ( +
+
+ {d.name}: {d.value} +
+ ))} +
+ + ) : ( +
+ Нет данных +
+ )} +
+
); -}; +}; \ No newline at end of file