332 lines
17 KiB
JavaScript
332 lines
17 KiB
JavaScript
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 { Badge } from '../UI/Badge';
|
||
import { SegmentedTabs } from '../UI/SegmentedTabs';
|
||
import { useAdminStats } from '../../hooks/useAdminStats';
|
||
|
||
const useIsMobile = () => {
|
||
const [mobile, setMobile] = useState(false);
|
||
useEffect(() => {
|
||
const mq = window.matchMedia('(max-width: 640px)');
|
||
setMobile(mq.matches);
|
||
const handler = (e) => setMobile(e.matches);
|
||
mq.addEventListener('change', handler);
|
||
return () => mq.removeEventListener('change', handler);
|
||
}, []);
|
||
return mobile;
|
||
};
|
||
|
||
const STATUS_COLORS = {
|
||
pending_confirmation: '#94a3b8',
|
||
manual_confirmation_required: '#eab308',
|
||
agreed: '#22c55e',
|
||
driver_assigned: '#3b82f6',
|
||
loaded: '#6366f1',
|
||
on_route: '#8b5cf6',
|
||
delivered: '#10b981',
|
||
paid_storage: '#06b6d4',
|
||
problem: '#ef4444',
|
||
cancelled: '#64748b',
|
||
};
|
||
|
||
const STATUS_LABELS = {
|
||
pending_confirmation: 'Ожидает подтверждения',
|
||
manual_confirmation_required: 'Ручное подтверждение',
|
||
agreed: 'Согласовано',
|
||
driver_assigned: 'Водитель назначен',
|
||
loaded: 'Загружено',
|
||
on_route: 'В пути',
|
||
delivered: 'Доставлено',
|
||
paid_storage: 'Оплаченное хранение',
|
||
problem: 'Проблема',
|
||
cancelled: 'Отменено',
|
||
};
|
||
|
||
const PERIOD_OPTIONS = [
|
||
{ key: '1d', label: 'Сегодня' },
|
||
{ key: '7d', label: '7 дней' },
|
||
{ key: '30d', label: '30 дней' },
|
||
{ key: 'all', label: 'Все' },
|
||
];
|
||
|
||
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>
|
||
);
|
||
};
|
||
|
||
export const AdminDashboard = () => {
|
||
const [period, setPeriod] = useState('7d');
|
||
const mobile = useIsMobile();
|
||
const { stats, statusDist, dailyTrend, driverStats, economics, isLoading, error, refetch } = useAdminStats(period);
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<Panel>
|
||
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--color-text-muted)' }}>Загрузка...</div>
|
||
</Panel>
|
||
);
|
||
}
|
||
if (error) {
|
||
return (
|
||
<Panel>
|
||
<div style={{ color: 'var(--color-danger)', padding: '1rem' }}>Ошибка: {error}</div>
|
||
</Panel>
|
||
);
|
||
}
|
||
|
||
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,
|
||
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, 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, problems: d.problems || 0,
|
||
}));
|
||
|
||
// Funnel: ALWAYS show all steps, even with 0 values
|
||
const funnelSteps = [
|
||
{ label: 'Согласовано после 1-й SMS', value: econ.confirmed_after_sms1 || 0, color: '#22c55e' },
|
||
{ label: 'Согласовано после 2-й SMS', value: econ.confirmed_after_sms2 || 0, color: '#14b8a6' },
|
||
{ label: 'Согласовано вручную', value: econ.confirmed_via_manual || 0, color: '#eab308' },
|
||
{ label: 'Ручное назначение даты', value: econ.manual_date_set_count || 0, color: '#f97316' },
|
||
{ label: 'Платное хранение', value: econ.paid_storage_count || 0, color: '#06b6d4' },
|
||
{ label: 'Отмена', value: econ.cancelled_count || 0, color: '#ef4444' },
|
||
];
|
||
|
||
// Responsive values
|
||
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' };
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.25rem' }}>
|
||
|
||
{/* Period selector */}
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
|
||
<div>
|
||
<h2 style={{ fontSize: mobile ? '1rem' : '1.1rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.15rem' }}>Аналитика</h2>
|
||
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>Статистика по доставкам</p>
|
||
</div>
|
||
<SegmentedTabs items={PERIOD_OPTIONS} activeKey={period} onChange={setPeriod} />
|
||
</div>
|
||
|
||
{/* KPI — centered on mobile */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : `repeat(auto-fit, minmax(${kpiMin}, 1fr))`, gap: '0.4rem' }}>
|
||
{[
|
||
{ label: 'Всего', val: totalGroups },
|
||
{ label: 'Ожидает', val: sv.pending },
|
||
{ label: 'В работе', val: sv.in_progress },
|
||
{ label: 'Доставлено', val: sv.delivered },
|
||
{ label: 'Проблемы', val: sv.problem },
|
||
{ label: '% доставки', val: sv.delivery_rate != null ? sv.delivery_rate + '%' : '—' },
|
||
].map((kpi, i) => (
|
||
<Panel key={i} style={{ padding: mobile ? '0.4rem 0.6rem' : '0.5rem 0.75rem', textAlign: 'center' }}>
|
||
<div style={{ fontSize: fontSize.xs, color: 'var(--color-text-muted)', marginBottom: '0.05rem' }}>{kpi.label}</div>
|
||
<div style={{ fontSize: mobile ? '1rem' : '1.2rem', fontWeight: 700, color: 'var(--color-text)' }}>{kpi.val ?? '—'}</div>
|
||
</Panel>
|
||
))}
|
||
</div>
|
||
|
||
{/* Pie + Line — stacked on mobile, side-by-side on desktop */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: chartGridCols, gap: mobile ? '0.5rem' : '1rem' }}>
|
||
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
|
||
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>По статусам</h3>
|
||
{statusPieData.length === 0 ? (
|
||
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1.5rem' }}>Нет данных</div>
|
||
) : (
|
||
<ResponsiveContainer width="100%" height={chartHeight}>
|
||
<PieChart>
|
||
<Pie data={statusPieData} cx="50%" cy="50%"
|
||
innerRadius={mobile ? 30 : 40}
|
||
outerRadius={mobile ? 60 : 80}
|
||
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>
|
||
|
||
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
|
||
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>Тренд по дням</h3>
|
||
{trendData.length === 0 ? (
|
||
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1.5rem' }}>Нет данных</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 : 10, fill: 'var(--color-text-muted)' }} />
|
||
<YAxis tick={{ fontSize: mobile ? 9 : 10, fill: 'var(--color-text-muted)' }} width={mobile ? 25 : 35} />
|
||
<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="problems" name="Проблемы" stroke="#ef4444" strokeWidth={2} dot={false} />
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
)}
|
||
</Panel>
|
||
</div>
|
||
|
||
{/* Status table */}
|
||
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
|
||
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>Все статусы</h3>
|
||
{statusPieData.length === 0 ? (
|
||
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1rem' }}>Нет данных</div>
|
||
) : (
|
||
<div>
|
||
<div style={{
|
||
display: 'grid', gridTemplateColumns: mobile ? '8px 1fr 50px 40px' : '10px 1fr 70px 55px',
|
||
gap: '0 0.4rem', padding: '0.3rem 0.3rem', alignItems: 'center',
|
||
borderBottom: '1px solid var(--color-border)', fontSize: fontSize.xs,
|
||
color: 'var(--color-text-muted)', fontWeight: 600,
|
||
}}>
|
||
<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 50px 40px' : '10px 1fr 70px 55px',
|
||
gap: '0 0.4rem', padding: '0.4rem 0.3rem', alignItems: 'center',
|
||
borderBottom: '1px solid var(--color-border, rgba(51,65,85,0.4))',
|
||
}}>
|
||
<div style={{ width: mobile ? '8px' : '10px', height: mobile ? '8px' : '10px', 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: 600, color: 'var(--color-text)' }}>{s.value}</div>
|
||
<div style={{ textAlign: 'right', fontSize: fontSize.s, color: 'var(--color-text-muted)' }}>{pct}%</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</Panel>
|
||
|
||
{/* Воронка согласования — ALL steps always visible */}
|
||
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
|
||
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.5rem', color: 'var(--color-text)' }}>Воронка согласования</h3>
|
||
{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.85rem', fontWeight: 700, color: 'var(--color-text)', textAlign: 'center' }}>
|
||
{step.value}
|
||
</div>
|
||
<div style={{
|
||
width: widthPct + '%', height: mobile ? '28px' : '32px', 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.7rem', 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.72rem', color: 'var(--color-text-muted)', textAlign: 'center', maxWidth: mobile ? '180px' : '220px' }}>
|
||
{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.05rem', fontWeight: 700, color: '#22c55e' }}>{econ.auto_confirm_pct ?? 0}%</div>
|
||
</div>
|
||
<div style={{ textAlign: 'center' }}>
|
||
<div style={{ fontSize: fontSize.xs, color: '#ef4444', marginBottom: '1px' }}>Ручное вмешательство</div>
|
||
<div style={{ fontSize: mobile ? '0.95rem' : '1.05rem', fontWeight: 700, color: '#ef4444' }}>{econ.manual_intervention_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.05rem', fontWeight: 700, color: 'var(--color-text)' }}>{econ.confirmed_auto_total ?? 0}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Panel>
|
||
|
||
{/* SMS */}
|
||
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
|
||
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>SMS</h3>
|
||
<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: '1px' }}>{item.label}</div>
|
||
<div style={{ fontSize: mobile ? '1rem' : '1.1rem', fontWeight: 700, color: 'var(--color-text)' }}>{item.val}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Panel>
|
||
|
||
{/* Drivers */}
|
||
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
|
||
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>По водителям</h3>
|
||
{driverData.length === 0 ? (
|
||
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '1.5rem' }}>Нет данных</div>
|
||
) : (
|
||
<ResponsiveContainer width="100%" height={Math.max(150, driverData.length * (mobile ? 35 : 45))}>
|
||
<BarChart data={driverData} layout="vertical" margin={{ left: mobile ? 5 : 20, right: mobile ? 5 : 20 }}>
|
||
<XAxis type="number" tick={{ fontSize: mobile ? 9 : 10, fill: 'var(--color-text-muted)' }} />
|
||
<YAxis type="category" dataKey="name" tick={{ fontSize: mobile ? 9 : 10, fill: 'var(--color-text-muted)' }} width={driverLabelWidth} />
|
||
<Tooltip content={<CustomTooltip />} />
|
||
<Legend wrapperStyle={{ fontSize: fontSize.xs }} />
|
||
<Bar dataKey="delivered" name="Доставлено" fill="#22c55e" stackId="a" />
|
||
<Bar dataKey="problems" name="Проблемы" fill="#ef4444" stackId="a" />
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
)}
|
||
</Panel>
|
||
</div>
|
||
);
|
||
}; |