244 lines
12 KiB
JavaScript
244 lines
12 KiB
JavaScript
/**
|
||
* @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 (
|
||
<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)',
|
||
}}>
|
||
<div style={{ fontWeight: 600 }}>{d.name}</div>
|
||
<div>{d.value} групп · {d.pct}% от всех</div>
|
||
{d.convPct != null && <div style={{ color: '#94a3b8' }}>шаг: {d.convPct}%</div>}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const Chip = ({ label, value, color, fontSize }) => (
|
||
<div style={{
|
||
display: 'flex', alignItems: 'baseline', gap: '0.35rem', padding: '0.3rem 0.55rem',
|
||
border: '1px solid var(--color-border, #334155)', borderRadius: '10px',
|
||
fontSize: fontSize.xs, color: 'var(--color-text-muted)', whiteSpace: 'nowrap',
|
||
}}>
|
||
<span style={{ width: '8px', height: '8px', borderRadius: '50%', background: color, display: 'inline-block' }} />
|
||
<span>{label}</span>
|
||
<strong style={{ color: 'var(--color-text)', fontSize: fontSize.s }}>{value}</strong>
|
||
</div>
|
||
);
|
||
|
||
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 (
|
||
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem' }}>
|
||
<h3 style={{ fontSize: mobile ? '0.9rem' : '1rem', fontWeight: 700, color: 'var(--color-text)' }}>Путь заказа</h3>
|
||
<div style={{ color: 'var(--color-text-muted)', fontSize: fontSize.s, padding: '1rem 0' }}>Загружаю…</div>
|
||
</Panel>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem' }}>
|
||
<div style={{ marginBottom: '0.6rem' }}>
|
||
<h3 style={{ fontSize: mobile ? '0.9rem' : '1rem', fontWeight: 700, color: 'var(--color-text)', marginBottom: '0.1rem' }}>
|
||
Путь заказа
|
||
</h3>
|
||
<div style={{ fontSize: mobile ? '0.65rem' : '0.72rem', color: 'var(--color-text-muted)' }}>
|
||
{periodLabel ? `${periodLabel} · ` : ''}сколько заказов проходит каждую ступень
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Сама воронка ─────────────────────────────────────────────────── */}
|
||
{total === 0 ? (
|
||
<div style={{ color: 'var(--color-text-muted)', fontSize: fontSize.s, padding: '1.5rem', textAlign: 'center' }}>
|
||
За период заказов нет
|
||
</div>
|
||
) : (
|
||
<div style={{ width: '100%', height: mobile ? 200 : 240 }}>
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<FunnelChart margin={{ top: 4, right: mobile ? 4 : 24, bottom: 4, left: mobile ? 4 : 24 }}>
|
||
<Tooltip content={<FunnelTooltip />} />
|
||
<Funnel dataKey="value" nameKey="name" data={data} isAnimationActive={false} stroke="none">
|
||
<LabelList
|
||
position="inside"
|
||
fill="#ffffff"
|
||
stroke="none"
|
||
dataKey="caption"
|
||
style={{ fontSize: mobile ? '0.62rem' : '0.75rem', fontWeight: 600 }}
|
||
/>
|
||
</Funnel>
|
||
</FunnelChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Легенда: те же цифры списком (воронку читать на телефоне тяжело) ─ */}
|
||
{total > 0 && (
|
||
<div style={{ display: 'grid', gap: '0.2rem', marginTop: '0.4rem' }}>
|
||
{data.map((d) => (
|
||
<div key={d.name} style={{ display: 'flex', alignItems: 'baseline', gap: '0.45rem', fontSize: fontSize.s }}>
|
||
<span style={{ width: '9px', height: '9px', borderRadius: '3px', background: d.fill, display: 'inline-block' }} />
|
||
<span style={{ flex: '1 1 auto', color: 'var(--color-text-muted)' }}>{d.name}</span>
|
||
<strong style={{ color: 'var(--color-text)', fontSize: fontSize.s }}>{d.caption}</strong>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Конверсии между ступенями ────────────────────────────────────── */}
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', margin: '0.5rem 0 0.75rem' }}>
|
||
{data.slice(1).map((d, i) => (
|
||
<Chip
|
||
key={d.name}
|
||
label={`${data[i].name} → ${d.name}`}
|
||
value={d.convPct != null ? `${d.convPct}%` : '—'}
|
||
color={d.fill}
|
||
fontSize={fontSize}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── Мимо цепочки ─────────────────────────────────────────────────── */}
|
||
<div style={{
|
||
padding: '0.5rem 0.65rem', borderRadius: '10px', marginBottom: '0.7rem',
|
||
background: 'rgba(234,179,8,0.10)', border: '1px solid rgba(234,179,8,0.35)',
|
||
fontSize: fontSize.s, color: 'var(--color-text)',
|
||
}}>
|
||
<strong>Мимо цепочки (без даты)</strong>: выдано {bypass.delivered ?? 0} · платное хранение {bypass.paid_storage ?? 0} ·
|
||
{' '}отменено {bypass.cancelled ?? 0} · без согласования {bypass.no_confirmation ?? 0}
|
||
<div style={{ color: 'var(--color-text-muted)', fontSize: fontSize.xs, marginTop: '0.15rem' }}>
|
||
Эти заказы довели до выдачи без согласования даты — воронка выше их не считает.
|
||
«Без согласования» — заказы, закрытые (в т.ч. обменом из 1С) без выбранной даты и без ответа клиента.
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Как согласовали дату: пуша / SMS-ссылка / звонок / логист ─────── */}
|
||
{channelRows.length > 0 && (
|
||
<div style={{ marginTop: '0.75rem' }}>
|
||
<div style={{
|
||
fontSize: fontSize.xs, textTransform: 'uppercase', letterSpacing: '0.06em',
|
||
color: 'var(--color-text-muted)', marginBottom: '0.35rem',
|
||
}}>
|
||
Как согласовали дату
|
||
</div>
|
||
<div style={{ display: 'grid', gap: '0.3rem' }}>
|
||
{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 (
|
||
<div key={row.channel} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: fontSize.s }}>
|
||
<span style={{ width: mobile ? '6.5rem' : '8.5rem', flex: '0 0 auto', color: 'var(--color-text-muted)' }}>
|
||
<span aria-hidden="true">{meta.icon}</span> {meta.label}
|
||
</span>
|
||
<span style={{
|
||
flex: '1 1 auto', height: '8px', borderRadius: '4px',
|
||
background: 'var(--color-surface-strong)', overflow: 'hidden', minWidth: '40px',
|
||
}}>
|
||
<span style={{
|
||
display: 'block', height: '100%', width: `${Math.max(share, 2)}%`,
|
||
background: CHANNEL_COLORS[row.channel] || '#64748b',
|
||
}} />
|
||
</span>
|
||
<strong style={{ color: 'var(--color-text)', fontSize: fontSize.s }}>{row.count}</strong>
|
||
<span style={{ color: 'var(--color-text-muted)', fontSize: fontSize.xs, width: '2.6rem', textAlign: 'right' }}>{share}%</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Откуда взялась дата + тип заказа ─────────────────────────────── */}
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
||
{sources.map((s) => (
|
||
<Chip
|
||
key={s.source}
|
||
label={SOURCE_LABELS[s.source] || s.source}
|
||
value={s.count}
|
||
color={s.source === 'manual' ? '#eab308' : s.source === 'autocall' ? '#6366f1' : '#06b6d4'}
|
||
fontSize={fontSize}
|
||
/>
|
||
))}
|
||
<Chip label="Самовывоз" value={split.pickup ?? 0} color="#f59e0b" fontSize={fontSize} />
|
||
<Chip label="Доставка" value={split.delivery ?? 0} color="#6366f1" fontSize={fontSize} />
|
||
</div>
|
||
</Panel>
|
||
);
|
||
};
|
||
|
||
export default OrderPathFunnelBlock;
|