supersam/src/components/admin/analytics/OrderPathFunnelBlock.jsx

244 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @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;