/** * @file OrderPathFunnelBlock.jsx * @description «Путь заказа» — красивая воронка по странице «Аналитика» (mega_admin/admin). * Этапы цепочки: группы в работе → первое SMS → согласована дата → выдано по цепочке. * Плюс честная картина того, что прошло МИМО цепочки (в обход, без даты), источники даты * и разбивка самовывоз/доставка. Данные — блок order_path из admin_comms_funnel. */ import React from 'react'; import { FunnelChart, Funnel, LabelList, Tooltip, ResponsiveContainer } from 'recharts'; import { Panel } from '../../UI/Panel'; import { CONFIRM_CHANNEL_META, CONFIRM_CHANNEL_ORDER } from '../../../services/orderGroupViews'; // Цвета этапов: от «всех» к «прошли всю цепочку» — холодный → зелёный const STAGE_COLORS = { created: '#64748b', sms: '#3b82f6', dated: '#8b5cf6', done: '#22c55e', }; const CHANNEL_COLORS = { push: '#8b5cf6', sms: '#22c55e', call: '#6366f1', link: '#06b6d4', messenger: '#0ea5e9', manual: '#eab308', unknown: '#64748b', }; const SOURCE_LABELS = { manual: 'Менеджер', autocall: 'Робот-звонок', link: 'Ссылка из SMS', automation: 'Автоматически', // дата есть, а источник не записан — старые подтверждения (канал клиента до 29.09.2026) none: 'Источник не записан', // Клиентские каналы из ссылки (/delivery/{hash}?src=...) — до 29.09.2026 не писались, // поэтому в старых строках источник пустой («Источник не записан»). sms: 'SMS-ссылка', client: 'Ссылка (прямой заход)', push: 'Push', messenger: 'Мессенджер', }; const FunnelTooltip = ({ active, payload }) => { if (!active || !payload?.length) return null; const d = payload[0].payload; return (
{d.name}
{d.value} групп · {d.pct}% от всех
{d.convPct != null &&
шаг: {d.convPct}%
}
); }; const Chip = ({ label, value, color, fontSize }) => (
{label} {value}
); export const OrderPathFunnelBlock = ({ funnel, isLoading, mobile = false, periodLabel = '' }) => { 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' }; const path = funnel?.order_path; const stages = path?.stages || []; if (isLoading || !funnel) { return (

Путь заказа

Загружаю…
); } const total = stages[0]?.value || 0; const data = stages.map((s, i) => ({ name: s.label, caption: `${s.value} · ${s.pct}%`, value: Number(s.value) || 0, pct: s.pct, convPct: i === 0 || !stages[i - 1]?.value ? null : Math.round((Number(s.value) / Number(stages[i - 1].value)) * 1000) / 10, fill: STAGE_COLORS[s.key] || '#3b82f6', })); const channels = path?.channels || []; const datedTotal = channels.reduce((sum, c) => sum + (Number(c.count) || 0), 0); const channelRows = [...channels] .filter((c) => c.channel !== 'unknown') .sort((a, b) => (CONFIRM_CHANNEL_ORDER.indexOf(a.channel) - CONFIRM_CHANNEL_ORDER.indexOf(b.channel))); const bypass = path?.bypass || {}; const split = path?.split || {}; const sources = path?.sources || []; return (

Путь заказа

{periodLabel ? `${periodLabel} · ` : ''}сколько заказов проходит каждую ступень
{/* ── Сама воронка ─────────────────────────────────────────────────── */} {total === 0 ? (
За период заказов нет
) : (
} />
)} {/* ── Легенда: те же цифры списком (воронку читать на телефоне тяжело) ─ */} {total > 0 && (
{data.map((d) => (
{d.name} {d.caption}
))}
)} {/* ── Конверсии между ступенями ────────────────────────────────────── */}
{data.slice(1).map((d, i) => ( ))}
{/* ── Мимо цепочки ─────────────────────────────────────────────────── */}
Мимо цепочки (без даты): выдано {bypass.delivered ?? 0} · платное хранение {bypass.paid_storage ?? 0} · {' '}отменено {bypass.cancelled ?? 0} · без согласования {bypass.no_confirmation ?? 0}
Эти заказы довели до выдачи без согласования даты — воронка выше их не считает. «Без согласования» — заказы, закрытые (в т.ч. обменом из 1С) без выбранной даты и без ответа клиента.
{/* ── Как согласовали дату: пуша / SMS-ссылка / звонок / логист ─────── */} {channelRows.length > 0 && (
Как согласовали дату
{channelRows.map((row) => { const meta = CONFIRM_CHANNEL_META[row.channel] || { label: row.channel, icon: '•', tone: 'neutral' }; const share = datedTotal > 0 ? Math.round((row.count / datedTotal) * 100) : 0; return (
{meta.label} {row.count} {share}%
); })}
)} {/* ── Откуда взялась дата + тип заказа ─────────────────────────────── */}
{sources.map((s) => ( ))}
); }; export default OrderPathFunnelBlock;