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