redesign(analytics): dashboard desktop layout + pickup period label

- 12-col responsive grid: pie 4/8 trend, table 4/8 drivers
- KPI cards: 7 metrics with uppercase labels, larger values
- Period label in every section subtitle (за 7 дней, etc)
- PickupStatsPanel: period label, status breakdown (завершено/ожидает/ручное)
- PickupStatsPanel: hide empty schedule bars when no planned pickups
- SQL admin_pickup_stats: + picked_up, pending, manual columns
- Colors: delivered #22c55e, picked_up #14b8a6 (distinct)
- Desktop: 3-col grid inside pickup panel
This commit is contained in:
root 2026-07-06 10:09:11 +00:00
parent 1b9fea7f1d
commit eba1769078
2 changed files with 347 additions and 261 deletions

View File

@ -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 }) => (
<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 = () => {
// 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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.25rem' }}>
<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(80px, 160px))`, gap: '0.4rem' }}>
<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: mobile ? '0.4rem 0.6rem' : '0.5rem 0.75rem', textAlign: 'center' }}>
<Panel key={i} style={{ padding: '0.75rem', textAlign: 'center' }}>
<Skeleton className="w-12 h-3 mb-1" />
<Skeleton className="w-8 h-5" />
<Skeleton className="w-8 h-6" />
</Panel>
))}
</div>
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<Skeleton variant="heading" className="w-1/3 mb-3" />
<div style={{ height: chartHeight }} className="flex items-center justify-center">
<Skeleton className="w-3/4 h-40" />
</div>
</Panel>
</div>
);
}
@ -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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: mobile ? '0.75rem' : '1.25rem' }}>
// 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 (
<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 ? '1rem' : '1.1rem', fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.15rem' }}>Аналитика</h2>
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>Статистика по доставкам</p>
<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 — centered on mobile */}
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : `repeat(auto-fit, minmax(${kpiMin}, 160px))`, gap: '0.4rem' }}>
{[
{ 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) => (
<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 ? '1.1rem' : '1.3rem', fontWeight: 700, color: 'var(--color-text)', textAlign: 'center' }}>{kpi.val ?? '—'}</div>
</Panel>
))}
{/* ── 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.problem} color="#ef4444" mobile={mobile} />
<KpiCard label="% доставки" value={sv.delivery_rate != null ? sv.delivery_rate + '%' : '—'} color="var(--color-text)" mobile={mobile} />
</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>
{/* ── 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: '1.5rem' }}>Нет данных</div>
<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 : 40}
outerRadius={mobile ? 60 : 80}
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'} />
@ -224,16 +257,17 @@ export const AdminDashboard = () => {
)}
</Panel>
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>Тренд по дням</h3>
{/* 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: '1.5rem' }}>Нет данных</div>
<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 : 10, fill: 'var(--color-text-muted)' }} />
<YAxis tick={{ fontSize: mobile ? 9 : 10, fill: 'var(--color-text-muted)' }} width={mobile ? 25 : 35} />
<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} />
@ -244,137 +278,139 @@ export const AdminDashboard = () => {
</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,
{/* 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))',
}}>
<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 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 style={{ fontSize: mobile ? '0.65rem' : '0.72rem', color: 'var(--color-text-muted)', textAlign: 'center', maxWidth: mobile ? '180px' : '220px' }}>
{step.label}
);
})}
</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>
{i < funnelSteps.length - 1 && (
<div style={{ width: '2px', height: '4px', background: 'var(--color-border)' }} />
)}
);
})}
<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.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.1rem', 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.1rem', fontWeight: 700, color: 'var(--color-text)' }}>{econ.confirmed_auto_total ?? 0}</div>
</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>
)}
</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>
</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>
))}
{/* 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>
</Panel>
{/* Pickup Stats */}
<PickupStatsPanel stats={pickupStats} isLoading={pickupLoading} mobile={mobile} fontSize={fontSize} />
{/* 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="picked_up" name="Вывезено" fill="#14b8a6" stackId="a" />
<Bar dataKey="problems" name="Проблемы" fill="#ef4444" stackId="a" />
</BarChart>
</ResponsiveContainer>
)}
</Panel>
</div>
</div>
);
};

View File

@ -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 (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<div style={{ flex: '0 0 auto', width: mobile ? '70px' : '100px', fontSize: fontSize.xs, color: 'var(--color-text-muted)', textAlign: 'right' }}>{label}</div>
<div style={{ flex: '1 1 auto', height: mobile ? '16px' : '20px', background: 'var(--color-border, rgba(51,65,85,0.4))', borderRadius: '4px', overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{ flex: '0 0 auto', width: mobile ? '70px' : '110px', fontSize: fontSize.xs, color: 'var(--color-text-muted)', textAlign: 'right' }}>{label}</div>
<div style={{ flex: '1 1 auto', height: mobile ? '16px' : '22px', background: 'var(--color-border, rgba(51,65,85,0.4))', borderRadius: '4px', overflow: 'hidden' }}>
<div style={{ width: pct + '%', height: '100%', background: color, borderRadius: '4px', transition: 'width 0.4s ease', minWidth: pct > 0 ? '4px' : '0' }} />
</div>
<div style={{ flex: '0 0 auto', width: mobile ? '36px' : '45px', fontSize: fontSize.s, fontWeight: 600, color: 'var(--color-text)', textAlign: 'left' }}>{value}</div>
<div style={{ flex: '0 0 auto', width: mobile ? '36px' : '50px', fontSize: fontSize.s, fontWeight: 700, color: 'var(--color-text)', textAlign: 'left' }}>{value}</div>
</div>
);
};
export const PickupStatsPanel = ({ stats, isLoading, mobile, fontSize }) => {
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>
);
export const PickupStatsPanel = ({ stats, isLoading, mobile, fontSize, periodLabel = '' }) => {
if (isLoading) {
return (
<Panel>
@ -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 (
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
<h3 style={{ fontSize: fs.l, fontWeight: 600, marginBottom: '0.5rem', color: 'var(--color-text)' }}>
📦 Самовывоз
</h3>
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem' }}>
<SectionHeader title="📦 Самовывоз" subtitle={periodLabel} mobile={mobile} />
{/* KPI row */}
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr 1fr' : '1fr 1fr 1fr', gap: '0.5rem', marginBottom: '0.75rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr 1fr 1fr' : 'repeat(auto-fit, minmax(120px, 1fr))', gap: '0.5rem', marginBottom: '1rem' }}>
{[
{ 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) => (
<div key={i} style={{ textAlign: 'center' }}>
<div style={{ fontSize: fs.xs, color: 'var(--color-text-muted)', marginBottom: '1px' }}>{kpi.label}</div>
<div style={{ fontSize: mobile ? '1rem' : '1.2rem', fontWeight: 700, color: kpi.color }}>{kpi.val}</div>
<div key={i} style={{ textAlign: 'center', padding: '0.4rem 0.2rem' }}>
<div style={{ fontSize: fs.xs, color: 'var(--color-text-muted)', marginBottom: '2px', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{kpi.label}</div>
<div style={{ fontSize: mobile ? '1.05rem' : '1.3rem', fontWeight: 800, color: kpi.color }}>{kpi.val}</div>
</div>
))}
</div>
{/* Distribution by day */}
<div style={{ marginBottom: '0.6rem' }}>
<div style={{ fontSize: fs.m, fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.3rem' }}>По дням</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
<ProgressBar label="Сегодня" value={Number(stats.pickup_today) || 0} max={maxDay} color={PICKUP_COLORS.today} fontSize={fs} mobile={mobile} />
<ProgressBar label="Завтра" value={Number(stats.pickup_tomorrow) || 0} max={maxDay} color={PICKUP_COLORS.tomorrow} fontSize={fs} mobile={mobile} />
<ProgressBar label="Послезавтра" value={Number(stats.pickup_day_after) || 0} max={maxDay} color={PICKUP_COLORS.dayAfter} fontSize={fs} mobile={mobile} />
</div>
</div>
{/* Content grid: status breakdown | schedule | donut */}
<div style={{ display: 'grid', gridTemplateColumns: innerCols, gap: mobile ? '0.75rem' : '1.5rem' }}>
{/* Half-day split */}
<div style={{ marginBottom: '0.6rem' }}>
<div style={{ fontSize: fs.m, fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.3rem' }}>По времени</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
<ProgressBar label="До обеда" value={Number(stats.pickup_first_half) || 0} max={maxHalf} color={PICKUP_COLORS.firstHalf} fontSize={fs} mobile={mobile} />
<ProgressBar label="После обеда" value={Number(stats.pickup_second_half) || 0} max={maxHalf} color={PICKUP_COLORS.secondHalf} fontSize={fs} mobile={mobile} />
</div>
</div>
{/* Saturday */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.3rem 0', borderTop: '1px solid var(--color-border)', marginBottom: '0.6rem' }}>
<span style={{ fontSize: fs.s, color: 'var(--color-text-muted)' }}>Самовывоз в субботу</span>
<span style={{ fontSize: fs.l, fontWeight: 700, color: PICKUP_COLORS.saturday }}>{Number(stats.pickup_on_saturday) || 0}</span>
</div>
{/* Delivery vs Pickup donut */}
{pieData.length > 0 && (
{/* Status breakdown */}
<div>
<div style={{ fontSize: fs.m, fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.3rem' }}>Доставка vs Самовывоз</div>
<ResponsiveContainer width="100%" height={mobile ? 140 : 170}>
<PieChart>
<Pie data={pieData} cx="50%" cy="50%"
innerRadius={mobile ? 30 : 40}
outerRadius={mobile ? 55 : 70}
dataKey="value" nameKey="name" paddingAngle={2}
>
{pieData.map((entry, i) => (
<Cell key={i} fill={entry.fill} />
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
</PieChart>
</ResponsiveContainer>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', marginTop: '0.2rem' }}>
{pieData.map((d, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: fs.xs }}>
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: d.fill }} />
<span style={{ color: 'var(--color-text-muted)' }}>{d.name}: <strong style={{ color: 'var(--color-text)' }}>{d.value}</strong></span>
</div>
))}
<div style={{ fontSize: fs.m, fontWeight: 700, color: 'var(--color-text)', marginBottom: '0.4rem' }}>По статусам</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
<ProgressBar label="Завершено" value={pickedUp} max={Math.max(totalPickups, 1)} color={PICKUP_COLORS.picked_up} fontSize={fs} mobile={mobile} />
<ProgressBar label="Ожидает" value={pending} max={Math.max(totalPickups, 1)} color={PICKUP_COLORS.pending} fontSize={fs} mobile={mobile} />
<ProgressBar label="Ручное" value={manual} max={Math.max(totalPickups, 1)} color={PICKUP_COLORS.manual} fontSize={fs} mobile={mobile} />
</div>
{avgDays !== null && avgDays > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.4rem 0', marginTop: '0.3rem', borderTop: '1px solid var(--color-border)' }}>
<span style={{ fontSize: fs.s, color: 'var(--color-text-muted)' }}>Ср. дней до выдачи</span>
<span style={{ fontSize: fs.l, fontWeight: 700, color: '#3b82f6' }}>{avgDays}</span>
</div>
)}
</div>
{/* Schedule — only show if there are scheduled pickups */}
<div>
<div style={{ fontSize: fs.m, fontWeight: 700, color: 'var(--color-text)', marginBottom: '0.4rem' }}>Расписание</div>
{hasScheduledPickups ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem', marginBottom: '0.5rem' }}>
<ProgressBar label="Сегодня" value={pickupToday} max={maxDay} color={PICKUP_COLORS.today} fontSize={fs} mobile={mobile} />
<ProgressBar label="Завтра" value={pickupTomorrow} max={maxDay} color={PICKUP_COLORS.tomorrow} fontSize={fs} mobile={mobile} />
<ProgressBar label="Послезавтра" value={pickupDayAfter} max={maxDay} color={PICKUP_COLORS.dayAfter} fontSize={fs} mobile={mobile} />
</div>
) : (
<div style={{ fontSize: fs.s, color: 'var(--color-text-muted)', padding: '0.5rem 0', marginBottom: '0.5rem' }}>
Нет запланированных самовывозов
</div>
)}
{hasTimeSlots && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
<ProgressBar label="До обеда" value={pickupFirstHalf} max={maxHalf} color={PICKUP_COLORS.firstHalf} fontSize={fs} mobile={mobile} />
<ProgressBar label="После обеда" value={pickupSecondHalf} max={maxHalf} color={PICKUP_COLORS.secondHalf} fontSize={fs} mobile={mobile} />
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.4rem 0', marginTop: '0.3rem', borderTop: '1px solid var(--color-border)' }}>
<span style={{ fontSize: fs.s, color: 'var(--color-text-muted)' }}>Самовывоз в субботу</span>
<span style={{ fontSize: fs.l, fontWeight: 700, color: PICKUP_COLORS.saturday }}>{pickupOnSaturday}</span>
</div>
</div>
)}
{/* Delivery vs Pickup donut */}
<div>
<div style={{ fontSize: fs.m, fontWeight: 700, color: 'var(--color-text)', marginBottom: '0.4rem' }}>Доставка vs Самовывоз</div>
{pieData.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={mobile ? 140 : 180}>
<PieChart>
<Pie data={pieData} cx="50%" cy="50%"
innerRadius={mobile ? 30 : 45}
outerRadius={mobile ? 55 : 75}
dataKey="value" nameKey="name" paddingAngle={2}
>
{pieData.map((entry, i) => (
<Cell key={i} fill={entry.fill} />
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
</PieChart>
</ResponsiveContainer>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', marginTop: '0.3rem' }}>
{pieData.map((d, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '0.3rem', fontSize: fs.xs }}>
<div style={{ width: '10px', height: '10px', borderRadius: '2px', background: d.fill }} />
<span style={{ color: 'var(--color-text-muted)' }}>{d.name}: <strong style={{ color: 'var(--color-text)' }}>{d.value}</strong></span>
</div>
))}
</div>
</>
) : (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem 0', fontSize: fs.s }}>
Нет данных
</div>
)}
</div>
</div>
</Panel>
);
};