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