feat: add pickup analytics — RPC, hook, PickupStatsPanel, dashboard integration
This commit is contained in:
parent
14fe89f899
commit
c774c6a362
|
|
@ -7,6 +7,8 @@ import { Panel } from '../UI/Panel';
|
||||||
import { Badge } from '../UI/Badge';
|
import { Badge } from '../UI/Badge';
|
||||||
import { SegmentedTabs } from '../UI/SegmentedTabs';
|
import { SegmentedTabs } from '../UI/SegmentedTabs';
|
||||||
import { useAdminStats } from '../../hooks/useAdminStats';
|
import { useAdminStats } from '../../hooks/useAdminStats';
|
||||||
|
import { usePickupStats } from '../../hooks/usePickupStats';
|
||||||
|
import { PickupStatsPanel } from './PickupStatsPanel';
|
||||||
|
|
||||||
const useIsMobile = () => {
|
const useIsMobile = () => {
|
||||||
const [mobile, setMobile] = useState(false);
|
const [mobile, setMobile] = useState(false);
|
||||||
|
|
@ -31,6 +33,7 @@ const STATUS_COLORS = {
|
||||||
paid_storage: '#06b6d4',
|
paid_storage: '#06b6d4',
|
||||||
problem: '#ef4444',
|
problem: '#ef4444',
|
||||||
cancelled: '#64748b',
|
cancelled: '#64748b',
|
||||||
|
pickup: '#f59e0b',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABELS = {
|
const STATUS_LABELS = {
|
||||||
|
|
@ -44,6 +47,7 @@ const STATUS_LABELS = {
|
||||||
paid_storage: 'Оплаченное хранение',
|
paid_storage: 'Оплаченное хранение',
|
||||||
problem: 'Проблема',
|
problem: 'Проблема',
|
||||||
cancelled: 'Отменено',
|
cancelled: 'Отменено',
|
||||||
|
pickup: 'Самовывоз',
|
||||||
};
|
};
|
||||||
|
|
||||||
const PERIOD_OPTIONS = [
|
const PERIOD_OPTIONS = [
|
||||||
|
|
@ -74,6 +78,7 @@ export const AdminDashboard = () => {
|
||||||
const [period, setPeriod] = useState('7d');
|
const [period, setPeriod] = useState('7d');
|
||||||
const mobile = useIsMobile();
|
const mobile = useIsMobile();
|
||||||
const { stats, statusDist, dailyTrend, driverStats, economics, isLoading, error, refetch } = useAdminStats(period);
|
const { stats, statusDist, dailyTrend, driverStats, economics, isLoading, error, refetch } = useAdminStats(period);
|
||||||
|
const { stats: pickupStats, isLoading: pickupLoading } = usePickupStats(period);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -309,6 +314,9 @@ export const AdminDashboard = () => {
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
{/* Pickup Stats */}
|
||||||
|
<PickupStatsPanel stats={pickupStats} isLoading={pickupLoading} mobile={mobile} fontSize={fontSize} />
|
||||||
|
|
||||||
{/* Drivers */}
|
{/* Drivers */}
|
||||||
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
|
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
|
||||||
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>По водителям</h3>
|
<h3 style={{ fontSize: fontSize.l, fontWeight: 600, marginBottom: '0.4rem', color: 'var(--color-text)' }}>По водителям</h3>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
|
import { Panel } from '../UI/Panel';
|
||||||
|
|
||||||
|
const PICKUP_COLORS = {
|
||||||
|
today: '#22c55e',
|
||||||
|
tomorrow: '#3b82f6',
|
||||||
|
dayAfter: '#8b5cf6',
|
||||||
|
firstHalf: '#f59e0b',
|
||||||
|
secondHalf: '#ef4444',
|
||||||
|
saturday: '#06b6d4',
|
||||||
|
pickup: '#f59e0b',
|
||||||
|
delivery: '#6366f1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload }) => {
|
||||||
|
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)',
|
||||||
|
}}>
|
||||||
|
{payload.map((p, i) => (
|
||||||
|
<div key={i} style={{ color: p.payload?.fill || p.color }}>
|
||||||
|
{p.name}: <strong>{p.value}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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={{ 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PickupStatsPanel = ({ stats, isLoading, mobile, fontSize }) => {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Panel>
|
||||||
|
<div style={{ textAlign: 'center', padding: '1.5rem', color: 'var(--color-text-muted)', fontSize: fontSize?.m }}>
|
||||||
|
Загрузка статистики самовывоза...
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats) {
|
||||||
|
return (
|
||||||
|
<Panel>
|
||||||
|
<div style={{ textAlign: 'center', padding: '1.5rem', color: 'var(--color-text-muted)', fontSize: fontSize?.m }}>
|
||||||
|
Нет данных по самовывозу
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fs = fontSize || { xs: '0.65rem', s: '0.68rem', m: '0.78rem', l: '0.85rem', xl: '1.1rem' };
|
||||||
|
const totalPickups = Number(stats.total_pickups) || 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 maxHalf = Math.max(
|
||||||
|
Number(stats.pickup_first_half) || 0,
|
||||||
|
Number(stats.pickup_second_half) || 0,
|
||||||
|
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);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel style={{ padding: mobile ? '0.75rem' : '1rem' }}>
|
||||||
|
<h3 style={{ fontSize: fs.l, fontWeight: 600, marginBottom: '0.5rem', color: 'var(--color-text)' }}>
|
||||||
|
📦 Самовывоз
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* KPI row */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr 1fr' : '1fr 1fr 1fr', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
||||||
|
{[
|
||||||
|
{ label: 'Всего самовывоз', val: totalPickups, color: '#f59e0b' },
|
||||||
|
{ label: 'Доля самовывоза', val: pickupRate + '%', color: '#f59e0b' },
|
||||||
|
{ label: 'Ср. дней до выдачи', val: avgDays !== null ? avgDays : '—', color: '#3b82f6' },
|
||||||
|
].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>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* 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 && (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { supabase, hasSupabaseConfig } from "../supabaseClient";
|
||||||
|
|
||||||
|
const PERIOD_DAYS = { today: 1, "7d": 7, "30d": 30, all: 0 };
|
||||||
|
|
||||||
|
export const usePickupStats = (period = "7d") => {
|
||||||
|
const [stats, setStats] = useState(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const days = PERIOD_DAYS[period] ?? 7;
|
||||||
|
|
||||||
|
const fetchStats = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (!hasSupabaseConfig || !supabase) throw new Error("Supabase не сконфигурирован");
|
||||||
|
const { data, error: rpcError } = await supabase.rpc("admin_pickup_stats", { p_days: days, p_date_from: null, p_date_to: null });
|
||||||
|
if (rpcError) throw rpcError;
|
||||||
|
setStats(data?.[0] || null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || "Ошибка загрузки статистики самовывоза");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [days]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchStats(); }, [fetchStats]);
|
||||||
|
|
||||||
|
return { stats, isLoading, error, refetch: fetchStats };
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue