feat(analytics): «Путь заказа» — воронка по ступеням обработки

Владелец: «а путь заказа можно сдать какой-то красивой воронкой?»

В admin_comms_funnel добавлен блок order_path: этапы (групп в работе → первое SMS →
дата согласована → выдано по цепочке), честная плашка «мимо цепочки» (выдано без даты,
платное хранение, отмены), источники даты (менеджер/робот/без даты) и разбивка
самовывоз/доставка. Считается так, что водитель без даты не едет: последний этап —
это выданные С датой, поэтому воронка монотонная.

OrderPathFunnelBlock рисует воронку штатным recharts FunnelChart (градиент по этапам,
подписи «значение · %», тултип с конверсией шага), под ней — легенда теми же цифрами
(на телефоне воронку читать тяжело), чипы конверсий между ступенями, плашка «мимо
цепочки» и чипы источников/типа заказа. Блок стоит над воронкой коммуникаций.

За 30 дней: 396 → первое SMS 340 (85.9%) → дата 161 (47.4% от SMS) → выдано по цепочке
134 (83.2%); мимо цепочки 204; дата от менеджера 135, от робота 6; самовывоз 235,
доставка 161. Тест: OrderPathFunnelBlock.test.jsx (6 проверок). SW v127.
This commit is contained in:
Hermes 2026-09-29 11:03:24 +02:00
parent 48fb7ad662
commit c3df1fc0e1
5 changed files with 300 additions and 3 deletions

View File

@ -1,7 +1,7 @@
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
if (!isLocalhost) {
const STATIC_CACHE = "construction-delivery-static-v126";
const STATIC_CACHE = "construction-delivery-static-v127";
const RUNTIME_CACHE = "construction-delivery-runtime-v119";
const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];

View File

@ -17,6 +17,7 @@ import { useAdminStats } from '../../hooks/useAdminStats';
import { usePickupStats } from '../../hooks/usePickupStats';
import { PickupStatsPanel } from './PickupStatsPanel';
import { CommsFunnelBlock } from './analytics/CommsFunnelBlock';
import { OrderPathFunnelBlock } from './analytics/OrderPathFunnelBlock';
// ── Mobile Detection Hook ───────────────────────────────────────────────────
const useIsMobile = () => {
@ -239,6 +240,9 @@ export const AdminDashboard = () => {
<KpiCard label="% доставки" value={sv.delivery_rate != null ? sv.delivery_rate + '%' : '—'} color="var(--color-text)" mobile={mobile} />
</div>
{/* ── Путь заказа: воронка по ступеням обработки ─────────────────────── */}
<OrderPathFunnelBlock funnel={funnel} isLoading={isLoading} mobile={mobile} periodLabel={periodLabel} />
{/* ── Воронка коммуникаций: push → SMS → звонки → согласование ───────── */}
<CommsFunnelBlock funnel={funnel} isLoading={isLoading} mobile={mobile} periodLabel={periodLabel} />

View File

@ -0,0 +1,182 @@
/**
* @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';
// Цвета этапов: от «всех» к «прошли всю цепочку» — холодный → зелёный
const STAGE_COLORS = {
created: '#64748b',
sms: '#3b82f6',
dated: '#8b5cf6',
done: '#22c55e',
};
const SOURCE_LABELS = {
manual: 'Менеджер',
autocall: 'Робот-звонок',
link: 'Ссылка из SMS',
automation: 'Автоматически',
none: 'В обход (без даты)',
};
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 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}
<div style={{ color: 'var(--color-text-muted)', fontSize: fontSize.xs, marginTop: '0.15rem' }}>
Эти заказы довели до выдачи без согласования даты — воронка выше их не считает.
</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;

View File

@ -0,0 +1,74 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { OrderPathFunnelBlock } from "./OrderPathFunnelBlock";
// Фикстура — блок order_path из admin_comms_funnel (реальные цифры за 30 дней на 29.09.2026).
const funnel = {
order_path: {
stages: [
{ key: "created", label: "Групп в работе", value: 396, pct: 100 },
{ key: "sms", label: "Первое SMS", value: 340, pct: 85.9 },
{ key: "dated", label: "Дата согласована", value: 161, pct: 40.7 },
{ key: "done", label: "Выдано по цепочке", value: 134, pct: 33.8 },
],
bypass: { delivered: 199, paid_storage: 3, cancelled: 0 },
split: { pickup: 235, delivery: 161 },
sources: [
{ source: "manual", count: 135 },
{ source: "autocall", count: 6 },
{ source: "none", count: 20 },
],
},
};
const empty = { order_path: { stages: [], bypass: {}, split: {}, sources: [] } };
describe("OrderPathFunnelBlock", () => {
const html = renderToStaticMarkup(<OrderPathFunnelBlock funnel={funnel} mobile={false} periodLabel="30 дней" />);
it("подписывает этапы со значениями и процентами", () => {
for (const label of ["Групп в работе", "Первое SMS", "Дата согласована", "Выдано по цепочке"]) {
expect(html).toContain(label);
}
expect(html).toContain("396 · 100%");
expect(html).toContain("340 · 85.9%");
expect(html).toContain("134 · 33.8%");
});
it("считает конверсию между ступенями", () => {
expect(html).toContain("Групп в работе → Первое SMS");
expect(html).toContain("85.9%");
expect(html).toContain("Первое SMS → Дата согласована");
expect(html).toContain("47.4%"); // 161/340
expect(html).toContain("Дата согласована → Выдано по цепочке");
});
it("честно показывает то, что прошло мимо цепочки", () => {
expect(html).toContain("Мимо цепочки (без даты)");
expect(html).toContain("выдано 199");
expect(html).toContain("платное хранение 3");
expect(html).toContain("воронка выше их не считает");
});
it("показывает источники даты и разбивку по типу заказа", () => {
expect(html).toContain("Менеджер");
expect(html).toContain("Робот-звонок");
expect(html).toContain("В обход (без даты)");
expect(html).toContain("Самовывоз");
expect(html).toContain("235");
expect(html).toContain("Доставка");
});
it("на пустом периоде пишет заглушку", () => {
const e = renderToStaticMarkup(<OrderPathFunnelBlock funnel={empty} mobile periodLabel="Сегодня" />);
expect(e).toContain("За период заказов нет");
expect(e).toContain("Путь заказа");
});
it("показывает заглушку загрузки, пока данных нет", () => {
const l = renderToStaticMarkup(<OrderPathFunnelBlock funnel={null} isLoading mobile={false} />);
expect(l).toContain("Путь заказа");
expect(l).toContain("Загружаю…");
});
});

View File

@ -70,6 +70,27 @@ pct as (
case when groups_cnt > 0 then round(silent_cnt::numeric / groups_cnt * 100, 1) else 0 end as silent_pct
from stages
),
path as (
select
count(*) as created,
count(*) filter (where first_sms_sent_at is not null) as sms,
count(*) filter (where delivery_date is not null or pickup_date is not null) as dated,
count(*) filter (where (delivery_date is not null or pickup_date is not null)
and delivery_status in ('delivered', 'picked_up')) as chain_done,
count(*) filter (where delivery_status in ('delivered', 'picked_up')
and delivery_date is null and pickup_date is null) as bypass_done,
count(*) filter (where delivery_status = 'paid_storage') as paid_storage,
count(*) filter (where delivery_status = 'cancelled') as cancelled,
count(*) filter (where delivery_type = 'pickup') as pickup_cnt,
count(*) filter (where delivery_type = 'delivery') as delivery_cnt
from cohort
),
path_sources as (
select coalesce(delivery_date_source, 'none') as source, count(*) as cnt
from cohort
where delivery_date is not null or pickup_date is not null
group by 1
),
attempt_rows as (
select attempt_no,
count(*) as total,
@ -140,9 +161,25 @@ select jsonb_build_object(
'last_call', n.last_call, 'status', n.status)) from no_answer_rows n), '[]'::jsonb),
'opened_no_confirm', coalesce((select jsonb_agg(jsonb_build_object(
'num', o.num, 'name', o.name, 'phone', o.phone,
'opened', o.opened, 'opens', o.opens)) from opened_rows o), '[]'::jsonb)
'opened', o.opened, 'opens', o.opens)) from opened_rows o), '[]'::jsonb),
-- Путь заказа: воронка по цепочке (без даты водитель не едет) + то, что прошло мимо неё.
'order_path', jsonb_build_object(
'stages', jsonb_build_array(
jsonb_build_object('key', 'created', 'label', 'Групп в работе', 'value', pp.created, 'pct', 100),
jsonb_build_object('key', 'sms', 'label', 'Первое SMS', 'value', pp.sms,
'pct', case when pp.created > 0 then round(pp.sms::numeric / pp.created * 100, 1) else 0 end),
jsonb_build_object('key', 'dated', 'label', 'Дата согласована', 'value', pp.dated,
'pct', case when pp.created > 0 then round(pp.dated::numeric / pp.created * 100, 1) else 0 end),
jsonb_build_object('key', 'done', 'label', 'Выдано по цепочке', 'value', pp.chain_done,
'pct', case when pp.created > 0 then round(pp.chain_done::numeric / pp.created * 100, 1) else 0 end)
),
'bypass', jsonb_build_object('delivered', pp.bypass_done, 'paid_storage', pp.paid_storage, 'cancelled', pp.cancelled),
'split', jsonb_build_object('pickup', pp.pickup_cnt, 'delivery', pp.delivery_cnt),
'sources', coalesce((select jsonb_agg(jsonb_build_object('source', ps.source, 'count', ps.cnt)
order by ps.cnt desc) from path_sources ps), '[]'::jsonb)
)
)
from stages s, pct p;
from stages s, pct p, path pp;
$function$;
comment on function public.admin_comms_funnel(integer, timestamp with time zone, timestamp with time zone) is