diff --git a/public/service-worker.js b/public/service-worker.js
index e16296b..93f6c0c 100644
--- a/public/service-worker.js
+++ b/public/service-worker.js
@@ -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"];
diff --git a/src/components/admin/AdminDashboard.jsx b/src/components/admin/AdminDashboard.jsx
index e93b3c0..db998fb 100644
--- a/src/components/admin/AdminDashboard.jsx
+++ b/src/components/admin/AdminDashboard.jsx
@@ -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 = () => {
+ {/* ── Путь заказа: воронка по ступеням обработки ─────────────────────── */}
+
+
{/* ── Воронка коммуникаций: push → SMS → звонки → согласование ───────── */}
diff --git a/src/components/admin/analytics/OrderPathFunnelBlock.jsx b/src/components/admin/analytics/OrderPathFunnelBlock.jsx
new file mode 100644
index 0000000..7801791
--- /dev/null
+++ b/src/components/admin/analytics/OrderPathFunnelBlock.jsx
@@ -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 (
+
+
{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 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}
+
+ Эти заказы довели до выдачи без согласования даты — воронка выше их не считает.
+
+
+
+ {/* ── Откуда взялась дата + тип заказа ─────────────────────────────── */}
+
+ {sources.map((s) => (
+
+ ))}
+
+
+
+
+ );
+};
+
+export default OrderPathFunnelBlock;
diff --git a/src/components/admin/analytics/OrderPathFunnelBlock.test.jsx b/src/components/admin/analytics/OrderPathFunnelBlock.test.jsx
new file mode 100644
index 0000000..19c9005
--- /dev/null
+++ b/src/components/admin/analytics/OrderPathFunnelBlock.test.jsx
@@ -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();
+
+ 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();
+ expect(e).toContain("За период заказов нет");
+ expect(e).toContain("Путь заказа");
+ });
+
+ it("показывает заглушку загрузки, пока данных нет", () => {
+ const l = renderToStaticMarkup();
+ expect(l).toContain("Путь заказа");
+ expect(l).toContain("Загружаю…");
+ });
+});
diff --git a/supabase/admin_comms_funnel.sql b/supabase/admin_comms_funnel.sql
index 842ee70..6d9d1d2 100644
--- a/supabase/admin_comms_funnel.sql
+++ b/supabase/admin_comms_funnel.sql
@@ -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