supersam/src/components/admin/AdminDashboard.jsx

423 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @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 (
<div style={{
background: 'var(--color-surface, #1e293d)',
border: '1px solid var(--color-border, #334155)',
borderRadius: '12px', padding: '8px 12px', fontSize: '0.8rem',
color: 'var(--color-text, #e2e8f0)',
}}>
{tooltipLabel && <div style={{ marginBottom: '4px', fontWeight: 600 }}>{tooltipLabel}</div>}
{payload.map((p, i) => (
<div key={i} style={{ color: p.color }}>{p.name}: <strong>{p.value}</strong></div>
))}
</div>
);
};
// ── KPI Card ────────────────────────────────────────────────────────────────
const KpiCard = ({ label, value, color, mobile }) => (
<Panel style={{
padding: mobile ? '0.5rem 0.6rem' : '0.75rem 1rem',
textAlign: 'center',
display: 'flex', flexDirection: 'column', justifyContent: 'center',
minHeight: mobile ? '60px' : '80px',
}}>
<div style={{ fontSize: mobile ? '0.6rem' : '0.72rem', color: 'var(--color-text-muted)', marginBottom: '0.15rem', textTransform: 'uppercase', letterSpacing: '0.03em' }}>
{label}
</div>
<div style={{ fontSize: mobile ? '1.15rem' : '1.6rem', fontWeight: 800, color: color || 'var(--color-text)', lineHeight: 1.1 }}>
{value ?? '—'}
</div>
</Panel>
);
// ── Section Header ──────────────────────────────────────────────────────────
const SectionHeader = ({ title, subtitle, mobile }) => (
<div style={{ marginBottom: '0.6rem' }}>
<h3 style={{ fontSize: mobile ? '0.9rem' : '1rem', fontWeight: 700, color: 'var(--color-text)', marginBottom: subtitle ? '0.1rem' : 0 }}>
{title}
</h3>
{subtitle && (
<div style={{ fontSize: mobile ? '0.65rem' : '0.72rem', color: 'var(--color-text-muted)' }}>
{subtitle}
</div>
)}
</div>
);
// ── 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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.5rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
<Skeleton variant="heading" className="w-1/4" />
<Skeleton className="w-32 h-8" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : 'repeat(auto-fit, minmax(120px, 1fr))', gap: '0.5rem' }}>
{Array.from({ length: 7 }).map((_, i) => (
<Panel key={i} style={{ padding: '0.75rem', textAlign: 'center' }}>
<Skeleton className="w-12 h-3 mb-1" />
<Skeleton className="w-8 h-6" />
</Panel>
))}
</div>
</div>
);
}
if (error) {
return (
<Panel>
<div style={{ color: 'var(--color-danger)', padding: '1rem' }}>Ошибка: {error}</div>
</Panel>
);
}
// ── 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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.5rem' }}>
{/* ── Header + Period selector ─────────────────────────────────────────── */}
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
<div>
<h2 style={{ fontSize: mobile ? '1.1rem' : '1.4rem', fontWeight: 800, color: 'var(--color-text)', marginBottom: '0.1rem' }}>
Аналитика
</h2>
<p style={{ fontSize: mobile ? '0.72rem' : '0.82rem', color: 'var(--color-text-muted)' }}>
Статистика по доставкам {periodLabel}
</p>
</div>
<SegmentedTabs items={PERIOD_OPTIONS} activeKey={period} onChange={setPeriod} />
</div>
{/* ── KPI Cards ─────────────────────────────────────────────────────────── */}
<div style={{
display: 'grid',
gridTemplateColumns: mobile ? '1fr 1fr' : 'repeat(auto-fit, minmax(130px, 1fr))',
gap: mobile ? '0.4rem' : '0.75rem',
}}>
<KpiCard label="Всего" value={totalGroups} mobile={mobile} />
<KpiCard label="Ожидает" value={sv.pending} color="#94a3b8" mobile={mobile} />
<KpiCard label="В работе" value={sv.in_progress} color="#3b82f6" mobile={mobile} />
<KpiCard label="Доставлено" value={sv.delivered} color="#22c55e" mobile={mobile} />
<KpiCard label="Вывезено" value={sv.picked_up} color="#14b8a6" mobile={mobile} />
<KpiCard label="Самовывоз" value={sv.picked_up_pickup} color="#f59e0b" mobile={mobile} />
<KpiCard label="Проблемы" value={sv.problem} color="#ef4444" mobile={mobile} />
<KpiCard label="% доставки" value={sv.delivery_rate != null ? sv.delivery_rate + '%' : '—'} color="var(--color-text)" mobile={mobile} />
</div>
{/* ── Main Grid: Charts + Tables ───────────────────────────────────────── */}
<div style={{ display: 'grid', gridTemplateColumns: gridCols, gap: mobile ? '0.75rem' : '1.5rem' }}>
{/* Status Pie — 4 cols desktop */}
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(4) }}>
<SectionHeader title="По статусам" subtitle={periodLabel} mobile={mobile} />
{statusPieData.length === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem' }}>Нет данных</div>
) : (
<ResponsiveContainer width="100%" height={chartHeight}>
<PieChart>
<Pie data={statusPieData} cx="50%" cy="50%"
innerRadius={mobile ? 30 : 50}
outerRadius={mobile ? 60 : 90}
dataKey="value" nameKey="name" paddingAngle={2}>
{statusPieData.map(entry => (
<Cell key={entry.status} fill={STATUS_COLORS[entry.status] || '#6b7280'} />
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)' }} />
</PieChart>
</ResponsiveContainer>
)}
</Panel>
{/* Daily Trend — 8 cols desktop */}
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(8) }}>
<SectionHeader title="Тренд по дням" subtitle={periodLabel} mobile={mobile} />
{trendData.length === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem' }}>Нет данных</div>
) : (
<ResponsiveContainer width="100%" height={chartHeight}>
<LineChart data={trendData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border, #334155)" />
<XAxis dataKey="date" tick={{ fontSize: mobile ? 9 : 11, fill: 'var(--color-text-muted)' }} />
<YAxis tick={{ fontSize: mobile ? 9 : 11, fill: 'var(--color-text-muted)' }} width={mobile ? 25 : 40} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: fontSize.xs }} />
<Line type="monotone" dataKey="total" name="Всего" stroke="#94a3b8" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="delivered" name="Доставлено" stroke="#22c55e" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="picked_up" name="Вывезено" stroke="#14b8a6" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="problems" name="Проблемы" stroke="#ef4444" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
)}
</Panel>
{/* Status Table — 4 cols desktop */}
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(4) }}>
<SectionHeader title="Все статусы" subtitle={periodLabel} mobile={mobile} />
{statusPieData.length === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1rem' }}>Нет данных</div>
) : (
<div>
<div style={{
display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 45px 40px' : '12px 1fr 60px 55px',
gap: '0 0.5rem', padding: '0.4rem 0.3rem', alignItems: 'center',
borderBottom: '1px solid var(--color-border)', fontSize: fontSize.xs,
color: 'var(--color-text-muted)', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.03em',
}}>
<div /><div>Статус</div><div style={{ textAlign: 'right' }}>Кол-во</div><div style={{ textAlign: 'right' }}>Доля</div>
</div>
{statusPieData.map(s => {
const pct = totalGroups > 0 ? ((s.value / totalGroups) * 100).toFixed(1) : 0;
return (
<div key={s.status} style={{
display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 45px 40px' : '12px 1fr 60px 55px',
gap: '0 0.5rem', padding: '0.5rem 0.3rem', alignItems: 'center',
borderBottom: '1px solid var(--color-border, rgba(51,65,85,0.4))',
}}>
<div style={{ width: mobile ? '8px' : '12px', height: mobile ? '8px' : '12px', borderRadius: '3px', background: STATUS_COLORS[s.status] || '#6b7280' }} />
<div style={{ fontSize: fontSize.m, color: 'var(--color-text)' }}>{s.name}</div>
<div style={{ textAlign: 'right', fontSize: fontSize.m, fontWeight: 700, color: 'var(--color-text)' }}>{s.value}</div>
<div style={{ textAlign: 'right', fontSize: fontSize.s, color: 'var(--color-text-muted)' }}>{pct}%</div>
</div>
);
})}
</div>
)}
</Panel>
{/* Funnel — 4 cols desktop */}
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(4) }}>
<SectionHeader title="Путь заказа" subtitle={periodLabel} mobile={mobile} />
{totalGroups === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1rem' }}>Нет данных</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0', padding: '0.4rem 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 (
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1px', width: '100%' }}>
<div style={{ fontSize: mobile ? '0.8rem' : '0.9rem', fontWeight: 700, color: 'var(--color-text)', textAlign: 'center' }}>
{step.value}
</div>
<div style={{
width: widthPct + '%', height: mobile ? '28px' : '34px', background: 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,
}}>
<span style={{ fontSize: mobile ? '0.6rem' : '0.72rem', fontWeight: 600, color: step.value > 0 ? '#fff' : 'var(--color-text-muted)', textShadow: step.value > 0 ? '0 1px 2px rgba(0,0,0,0.3)' : 'none' }}>
{pct}%
</span>
</div>
<div style={{ fontSize: mobile ? '0.65rem' : '0.75rem', color: 'var(--color-text-muted)', textAlign: 'center', maxWidth: mobile ? '180px' : '240px' }}>
{step.label}
</div>
{i < funnelSteps.length - 1 && (
<div style={{ width: '2px', height: '4px', background: 'var(--color-border)' }} />
)}
</div>
);
})}
<div style={{
display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : '1fr 1fr 1fr', gap: '0.5rem',
marginTop: '0.75rem', paddingTop: '0.6rem', borderTop: '1px solid var(--color-border)',
}}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: fontSize.xs, color: '#22c55e', marginBottom: '1px' }}>Полная цепочка</div>
<div style={{ fontSize: mobile ? '0.95rem' : '1.1rem', fontWeight: 700, color: '#22c55e' }}>{econ.full_chain_pct ?? 0}%</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: fontSize.xs, color: '#f97316', marginBottom: '1px' }}>В обход</div>
<div style={{ fontSize: mobile ? '0.95rem' : '1.1rem', fontWeight: 700, color: '#f97316' }}>{econ.bypassed_pct ?? 0}%</div>
</div>
<div style={{ textAlign: 'center', display: mobile ? 'none' : 'block' }}>
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '1px' }}>Завершено</div>
<div style={{ fontSize: mobile ? '0.95rem' : '1.1rem', fontWeight: 700, color: 'var(--color-text)' }}>{completedTotal}</div>
</div>
</div>
</div>
)}
</Panel>
{/* SMS + Drivers side by side — 4 cols each on desktop */}
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(4) }}>
<SectionHeader title="SMS" subtitle={periodLabel} mobile={mobile} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.5rem' }}>
{[
{ 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) => (
<div key={i} style={{ textAlign: 'center' }}>
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '2px', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{item.label}</div>
<div style={{ fontSize: mobile ? '1.1rem' : '1.3rem', fontWeight: 800, color: 'var(--color-text)' }}>{item.val}</div>
</div>
))}
</div>
</Panel>
{/* Drivers — 8 cols desktop */}
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem', gridColumn: colSpan(8) }}>
<SectionHeader title="По водителям" subtitle={periodLabel} mobile={mobile} />
{driverData.length === 0 ? (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem' }}>Нет данных</div>
) : (
<ResponsiveContainer width="100%" height={Math.max(180, driverData.length * (mobile ? 35 : 50))}>
<BarChart data={driverData} layout="vertical" margin={{ left: mobile ? 5 : 20, right: mobile ? 5 : 30 }}>
<XAxis type="number" tick={{ fontSize: mobile ? 9 : 11, fill: 'var(--color-text-muted)' }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: mobile ? 9 : 11, fill: 'var(--color-text-muted)' }} width={mobile ? 80 : 130} />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: fontSize.xs }} />
<Bar dataKey="delivered" name="Доставлено" fill="#22c55e" stackId="a" radius={[0, 0, 0, 0]} />
<Bar dataKey="picked_up" name="Вывезено" fill="#14b8a6" stackId="a" radius={[0, 0, 0, 0]} />
<Bar dataKey="problems" name="Проблемы" fill="#ef4444" stackId="a" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</Panel>
{/* Pickup Stats — full width (12 cols) */}
<div style={{ gridColumn: colSpan(12) }}>
<PickupStatsPanel stats={pickupStats} isLoading={pickupLoading} mobile={mobile} fontSize={fontSize} periodLabel={periodLabel} />
</div>
</div>
</div>
);
};