/**
* @file AdminDashboard.jsx
* @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 {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
PieChart, Pie, Cell, Legend, LineChart, Line, CartesianGrid,
} from 'recharts';
import { Panel } from '../UI/Panel';
import { SegmentedTabs } from '../UI/SegmentedTabs';
import { Skeleton } from '../UI/Loading';
import { useAdminStats } from '../../hooks/useAdminStats';
import { usePickupStats } from '../../hooks/usePickupStats';
import { PickupStatsPanel } from './PickupStatsPanel';
// ── Mobile Detection Hook ───────────────────────────────────────────────────
const useIsMobile = () => {
const [mobile, setMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(max-width: 768px)');
setMobile(mq.matches);
const handler = (e) => setMobile(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
return mobile;
};
// ── Status Colour & Label Maps ─────────────────────────────────────────────
const STATUS_COLORS = {
pending_confirmation: '#94a3b8',
manual_confirmation_required: '#eab308',
agreed: '#22c55e',
driver_assigned: '#3b82f6',
loaded: '#6366f1',
on_route: '#8b5cf6',
delivered: '#22c55e',
picked_up: '#14b8a6',
paid_storage: '#06b6d4',
problem: '#ef4444',
cancelled: '#64748b',
pickup: '#f59e0b',
};
const STATUS_LABELS = {
pending_confirmation: 'Ожидает подтверждения',
manual_confirmation_required: 'Ручное подтверждение',
agreed: 'Согласовано',
driver_assigned: 'Водитель назначен',
loaded: 'Загружено',
on_route: 'В пути',
delivered: 'Доставлено',
picked_up: 'Вывезено',
paid_storage: 'Оплаченное хранение',
problem: 'Проблема',
cancelled: 'Отменено',
pickup: 'Самовывоз',
};
// ── Period Selector Options ────────────────────────────────────────────────
const PERIOD_OPTIONS = [
{ key: '1d', label: 'Сегодня' },
{ key: '7d', label: '7 дней' },
{ key: '30d', label: '30 дней' },
{ 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;
return (
{tooltipLabel &&
{tooltipLabel}
}
{payload.map((p, i) => (
{p.name}: {p.value}
))}
);
};
// ── 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 = () => {
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);
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 State ─────────────────────────────────────────────────────────
if (isLoading) {
return (
{Array.from({ length: 7 }).map((_, i) => (
))}
);
}
if (error) {
return (
Ошибка: {error}
);
}
// ── Data Preparation ──────────────────────────────────────────────────────
const sv = stats || {};
const totalGroups = sv.total || 0;
const econ = economics || {};
const statusPieData = (statusDist || []).map(s => ({
name: STATUS_LABELS[s.delivery_status] || (s.delivery_status ? `Неизвестно (${s.delivery_status})` : 'Неизвестно'),
value: s.count,
status: s.delivery_status,
})).filter(d => d.value > 0);
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,
}));
const driverData = (driverStats || []).map(d => ({
name: d.driver_name || 'Неизвестный',
total: d.total || 0, delivered: d.delivered || 0, picked_up: d.picked_up || 0, problems: d.problems || 0,
}));
// ── Funnel: Real order path ───────────────────────────────────────────────
const completedTotal = (econ.full_chain_client || 0) + (econ.client_date_no_driver || 0)
+ (econ.manager_date_completed || 0) + (econ.bypassed_completed || 0);
const funnelSteps = [
{ label: 'Всего заказов', value: econ.total_groups || 0, color: '#94a3b8' },
{ label: 'SMS отправлено', value: econ.sms_sent || 0, color: '#3b82f6' },
{ label: 'Полная цепочка', value: econ.full_chain_client || 0, color: '#22c55e' },
{ label: 'Клиент выбрал дату', value: econ.client_date_no_driver || 0, color: '#14b8a6' },
{ label: 'Менеджер назначил дату', value: econ.manager_date_completed || 0, color: '#8b5cf6' },
{ label: 'В обход (без даты)', value: econ.bypassed_completed || 0, color: '#f97316' },
{ label: 'Застряло в ручном', value: econ.stuck_in_manual || 0, color: '#eab308' },
{ label: 'В работе', value: econ.in_progress || 0, color: '#3b82f6' },
{ label: 'Отменено', value: econ.cancelled_count || 0, color: '#64748b' },
];
// ── Grid Layout ───────────────────────────────────────────────────────────
// Desktop: 12-col grid. Mobile: single column.
const gridCols = mobile ? '1fr' : 'repeat(12, 1fr)';
const colSpan = (n) => mobile ? '1 / -1' : `span ${n}`;
return (
{/* ── Header + Period selector ─────────────────────────────────────────── */}
Аналитика
Статистика по доставкам {periodLabel}
{/* ── KPI Cards ─────────────────────────────────────────────────────────── */}
{/* ── Main Grid: Charts + Tables ───────────────────────────────────────── */}
{/* Status Pie — 4 cols desktop */}
{statusPieData.length === 0 ? (
Нет данных
) : (
{statusPieData.map(entry => (
|
))}
} />
)}
{/* Daily Trend — 8 cols desktop */}
{trendData.length === 0 ? (
Нет данных
) : (
} />
)}
{/* Status Table — 4 cols desktop */}
{statusPieData.length === 0 ? (
Нет данных
) : (
{statusPieData.map(s => {
const pct = totalGroups > 0 ? ((s.value / totalGroups) * 100).toFixed(1) : 0;
return (
{s.name}
{s.value}
{pct}%
);
})}
)}
{/* 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 && (
)}
);
})}
Полная цепочка
{econ.full_chain_pct ?? 0}%
В обход
{econ.bypassed_pct ?? 0}%
Завершено
{completedTotal}
)}
{/* 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) => (
))}
{/* Drivers — 8 cols desktop */}
{driverData.length === 0 ? (
Нет данных
) : (
} />
)}
{/* Pickup Stats — full width (12 cols) */}
);
};