/**
* @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.icon} {meta.label}
{row.count}
{share}%
);
})}
)}
{/* ── Откуда взялась дата + тип заказа ─────────────────────────────── */}
{sources.map((s) => (
))}
);
};
export default OrderPathFunnelBlock;