feat: add pickup analytics — RPC, hook, PickupStatsPanel, dashboard integration

This commit is contained in:
root 2026-06-10 12:32:51 +00:00
parent 14fe89f899
commit c774c6a362
3 changed files with 206 additions and 0 deletions

View File

@ -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>

View File

@ -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>
);
};

View File

@ -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 };
};