diff --git a/public/service-worker.js b/public/service-worker.js index 835c4c8..e646b7e 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-v123"; + const STATIC_CACHE = "construction-delivery-static-v124"; 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 b074e1d..e93b3c0 100644 --- a/src/components/admin/AdminDashboard.jsx +++ b/src/components/admin/AdminDashboard.jsx @@ -16,6 +16,7 @@ import { Skeleton } from '../UI/Loading'; import { useAdminStats } from '../../hooks/useAdminStats'; import { usePickupStats } from '../../hooks/usePickupStats'; import { PickupStatsPanel } from './PickupStatsPanel'; +import { CommsFunnelBlock } from './analytics/CommsFunnelBlock'; // ── Mobile Detection Hook ─────────────────────────────────────────────────── const useIsMobile = () => { @@ -129,7 +130,7 @@ const SectionHeader = ({ title, subtitle, mobile }) => ( export const AdminDashboard = () => { const [period, setPeriod] = useState('7d'); const mobile = useIsMobile(); - const { stats, statusDist, dailyTrend, driverStats, economics, isLoading, error, refetch } = useAdminStats(period); + const { stats, statusDist, dailyTrend, driverStats, economics, funnel, isLoading, error, refetch } = useAdminStats(period); const { stats: pickupStats, isLoading: pickupLoading } = usePickupStats(period); const periodLabel = PERIOD_LABELS[period] || ''; @@ -238,6 +239,9 @@ export const AdminDashboard = () => { + {/* ── Воронка коммуникаций: push → SMS → звонки → согласование ───────── */} + + {/* ── Main Grid: Charts + Tables ───────────────────────────────────────── */}
diff --git a/src/components/admin/analytics/CommsFunnelBlock.jsx b/src/components/admin/analytics/CommsFunnelBlock.jsx new file mode 100644 index 0000000..1159462 --- /dev/null +++ b/src/components/admin/analytics/CommsFunnelBlock.jsx @@ -0,0 +1,302 @@ +/** + * @file CommsFunnelBlock.jsx + * @description Воронка коммуникаций для страницы «Аналитика» (mega_admin/admin). + * Показывает цепочку по когорте групп за период: push → SMS → открытие ссылки → + * звонки робота (с какой попытки берут трубку) → согласование → ручная работа → выдача. + * Плюс два рабочих списка: «не поднимают трубку» и «открыли ссылку, но не подтвердили». + * Данные — один вызов admin_comms_funnel (см. supabase/admin_comms_funnel.sql). + */ +import React from 'react'; +import { Panel } from '../../UI/Panel'; + +const STAGE_COLORS = { + groups: '#94a3b8', + push: '#a855f7', + sms1: '#3b82f6', + opened: '#06b6d4', + called: '#6366f1', + answered: '#14b8a6', + confirmed: '#22c55e', + manual: '#eab308', + done: '#10b981', +}; + +const CHANNEL_LABELS = { + autocall: 'Робот-звонок', + manual: 'Менеджер', + link: 'Ссылка из SMS', + automation: 'Автоматически', +}; + +const STATUS_LABELS = { + first_sms_sent: 'Ждёт решения', + second_sms_sent: 'Второе SMS', + link_ready: 'Ссылка готова', + confirmed: 'Согласовано', + manual_required: 'В ручной работе', + completed: 'Завершено', + opt_out: 'Отказ', +}; + +const KIND_LABELS = { pickup: 'самовывоз', delivery: 'доставка' }; + +// Время показываем в МСК — как во всех отчётах. +const fmtDateTime = (iso) => { + if (!iso) return '—'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return '—'; + return d.toLocaleString('ru-RU', { + timeZone: 'Europe/Moscow', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', + }); +}; + +const SectionHeader = ({ title, subtitle, mobile }) => ( +
+

+ {title} +

+ {subtitle && ( +
{subtitle}
+ )} +
+); + +const StageRow = ({ stage, max, mobile, fontSize }) => { + const pct = max > 0 ? Math.max(0, (stage.value / max) * 100) : 0; + return ( +
+
+ {stage.label} +
+
+
0 ? '4px' : '0', + }} /> +
+
+ {stage.value} + {stage.pct}% +
+
+ ); +}; + +const Chip = ({ label, value, color, fontSize }) => ( +
+ + {label} + {value} +
+); + +const Th = ({ children, mobile, fontSize, align = 'left' }) => ( + + {children} + +); + +const Td = ({ children, mobile, fontSize, muted = false, align = 'left' }) => ( + + {children} + +); + +const EmptyRow = ({ cols, mobile, fontSize, text }) => ( + + + {text} + + +); + +export const CommsFunnelBlock = ({ 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 stages = funnel?.stages || []; + const max = stages.length ? Math.max(...stages.map((s) => Number(s.value) || 0)) : 0; + const silent = funnel?.silent || { count: 0, pct: 0 }; + const attempts = funnel?.attempts || []; + const channels = funnel?.channels || []; + const noAnswer = funnel?.no_answer_list || []; + const openedNoConfirm = funnel?.opened_no_confirm || []; + const pushSubs = funnel?.push_subs ?? 0; + + if (isLoading || !funnel) { + return ( + + +
Загружаю…
+
+ ); + } + + return ( + + + + {/* ── Этапы цепочки ───────────────────────────────────────────────── */} +
+ {stages.map((s) => ( + + ))} +
+ +
+ + {stages.find((s) => s.key === 'push') && ( + s.key === 'push').value > 0 ? 'Push-подписки' : 'Push не используется, подписок'} + value={pushSubs} + color="#a855f7" + fontSize={fontSize} + /> + )} +
+ + {/* ── С какой попытки отвечают ─────────────────────────────────────── */} +
+
+ С какой попытки отвечают +
+ + + + + + + + + + + + {attempts.length === 0 ? ( + + ) : attempts.map((a) => ( + + + + + + + + ))} + +
ПопыткаЗвонковВзяли трубкуСогласовалиНе взяли
{a.attempt}-я{a.total} + {a.answered} ({a.answered_pct}%) + {a.confirmed}{a.no_answer}
+
+ + {/* ── Чем подтвердили ─────────────────────────────────────────────── */} +
+ {channels.length === 0 ? ( +
Подтверждений за период нет
+ ) : channels.map((c) => ( + + ))} +
+ + {/* ── Не поднимают трубку ─────────────────────────────────────────── */} +
+
+ Не поднимают трубку +
+ + + + + + + + + + + + + {noAnswer.length === 0 ? ( + + ) : noAnswer.map((n) => ( + + + + + + + + + ))} + +
ТелефонГородТипПопытокПоследний звонокСтатус
{n.phone}{n.town}{KIND_LABELS[n.kind] || n.kind}{n.attempts}{fmtDateTime(n.last_call)}{STATUS_LABELS[n.status] || n.status || '—'}
+
+ + {/* ── Открыли ссылку, но не подтвердили ───────────────────────────── */} +
+
+ Открыли ссылку, но не подтвердили +
+ + + + + + + + + + + + {openedNoConfirm.length === 0 ? ( + + ) : openedNoConfirm.map((o) => ( + + + + + + + + ))} + +
ЗаказКлиентТелефонОткрытийПоследнее
{o.num}{o.name}{o.phone}{o.opens}{fmtDateTime(o.opened)}
+
+
+ ); +}; + +export default CommsFunnelBlock; diff --git a/src/components/admin/analytics/CommsFunnelBlock.test.jsx b/src/components/admin/analytics/CommsFunnelBlock.test.jsx new file mode 100644 index 0000000..6cb115c --- /dev/null +++ b/src/components/admin/analytics/CommsFunnelBlock.test.jsx @@ -0,0 +1,105 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { CommsFunnelBlock } from "./CommsFunnelBlock"; + +// Фикстура повторяет ответ admin_comms_funnel (30 дней, реальные цифры на 29.09.2026). +const funnel = { + period: { days: 30, from: null, to: null }, + stages: [ + { key: "groups", label: "Групп в работе", value: 396, pct: 100 }, + { key: "push", label: "Push отправлен", value: 0, pct: 0 }, + { key: "sms1", label: "Первое SMS", value: 340, pct: 85.9 }, + { key: "opened", label: "Открыли ссылку", value: 1, pct: 0.3 }, + { key: "called", label: "Робот позвонил", value: 26, pct: 6.6 }, + { key: "answered", label: "Взяли трубку", value: 14, pct: 3.5 }, + { key: "confirmed", label: "Согласовано", value: 128, pct: 32.3 }, + { key: "manual", label: "В ручную работу", value: 22, pct: 5.6 }, + { key: "done", label: "Выдано/доставлено", value: 357, pct: 90.2 }, + ], + silent: { count: 265, pct: 66.9 }, + second_sms: { count: 0, pct: 0 }, + push_subs: 6, + attempts: [ + { attempt: 1, total: 64, answered: 42, confirmed: 19, no_answer: 22, answered_pct: 65.6, confirmed_pct: 29.7 }, + { attempt: 2, total: 6, answered: 2, confirmed: 0, no_answer: 4, answered_pct: 33.3, confirmed_pct: 0 }, + { attempt: 3, total: 4, answered: 4, confirmed: 2, no_answer: 0, answered_pct: 100, confirmed_pct: 50 }, + ], + channels: [ + { source: "link", count: 103 }, + { source: "manual", count: 19 }, + { source: "autocall", count: 6 }, + ], + no_answer_list: [ + { phone: "9787617568", town: "Севастополь", kind: "pickup", attempts: 2, last_call: "2026-09-28T11:15:13Z", status: "manual_required" }, + ], + opened_no_confirm: [ + { num: "9788324113|28.09.2026", name: "ЗАКАЗЧИК", phone: "9788324113", opens: 14, opened: "2026-09-28T10:04:52Z" }, + ], +}; + +const emptyFunnel = { + stages: [], + silent: { count: 0, pct: 0 }, + push_subs: 0, + attempts: [], + channels: [], + no_answer_list: [], + opened_no_confirm: [], +}; + +describe("CommsFunnelBlock", () => { + const html = renderToStaticMarkup(); + + it("показывает все этапы цепочки со значениями и процентами", () => { + for (const label of ["Групп в работе", "Push отправлен", "Первое SMS", "Открыли ссылку", + "Робот позвонил", "Взяли трубку", "Согласовано", "В ручную работу", "Выдано/доставлено"]) { + expect(html).toContain(label); + } + expect(html).toContain("396"); + expect(html).toContain("85.9%"); + expect(html).toContain("340"); + }); + + it("показывает, сколько не достали и что push не используется", () => { + expect(html).toContain("Не достали (ни ссылка, ни звонок)"); + expect(html).toContain("265 · 66.9%"); + expect(html).toContain("Push не используется, подписок"); + }); + + it("разбивает звонки по попыткам с процентом ответов", () => { + expect(html).toContain("С какой попытки отвечают"); + expect(html).toContain("1-я"); + expect(html).toContain("65.6%"); + expect(html).toContain("3-я"); + }); + + it("показывает каналы подтверждения человеческими подписями", () => { + expect(html).toContain("Робот-звонок"); + expect(html).toContain("Менеджер"); + expect(html).toContain("Ссылка из SMS"); + }); + + it("перечисляет тех, кто не берёт трубку, и тех, кто открыл ссылку без подтверждения", () => { + expect(html).toContain("Не поднимают трубку"); + expect(html).toContain("9787617568"); + expect(html).toContain("самовывоз"); + expect(html).toContain("В ручной работе"); + expect(html).toContain("Открыли ссылку, но не подтвердили"); + expect(html).toContain("9788324113"); + }); + + it("на пустых данных пишет понятные заглушки, а не пустые таблицы", () => { + const empty = renderToStaticMarkup(); + expect(empty).toContain("За период робот не звонил"); + expect(empty).toContain("Нет таких номеров за период"); + expect(empty).toContain("Все, кто открыл ссылку, подтвердили"); + expect(empty).toContain("Подтверждений за период нет"); + }); + + it("показывает заглушку загрузки, пока данных нет", () => { + const loading = renderToStaticMarkup(); + expect(loading).toContain("Воронка коммуникаций"); + expect(loading).toContain("Загружаю…"); + }); +}); diff --git a/src/hooks/useAdminStats.js b/src/hooks/useAdminStats.js index ff12991..66685f2 100644 --- a/src/hooks/useAdminStats.js +++ b/src/hooks/useAdminStats.js @@ -18,6 +18,7 @@ export const useAdminStats = (period = "30d", dateFrom = null, dateTo = null) => const [dailyTrend, setDailyTrend] = useState([]); const [driverStats, setDriverStats] = useState([]); const [economics, setEconomics] = useState(null); + const [funnel, setFunnel] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -32,18 +33,20 @@ export const useAdminStats = (period = "30d", dateFrom = null, dateTo = null) => p_date_from: dateFrom || null, p_date_to: dateTo || null, }; - const [s, sd, dt, ds, econ] = await Promise.all([ + const [s, sd, dt, ds, econ, fn] = await Promise.all([ rpcCall("admin_delivery_stats", params), rpcCall("admin_status_distribution", params), rpcCall("admin_daily_trend", params), rpcCall("admin_driver_stats", params), rpcCall("admin_automation_economics", params), + rpcCall("admin_comms_funnel", params), ]); setStats(s?.[0] || null); setStatusDist(sd || []); setDailyTrend(dt || []); setDriverStats(ds || []); setEconomics(econ?.[0] || null); + setFunnel(fn || null); } catch (e) { setError(e.message || "Ошибка загрузки статистики"); } finally { @@ -53,5 +56,5 @@ export const useAdminStats = (period = "30d", dateFrom = null, dateTo = null) => useEffect(() => { fetchAll(); }, [fetchAll]); - return { stats, statusDist, dailyTrend, driverStats, economics, isLoading, error, refetch: fetchAll }; + return { stats, statusDist, dailyTrend, driverStats, economics, funnel, isLoading, error, refetch: fetchAll }; }; \ No newline at end of file diff --git a/supabase/admin_comms_funnel.sql b/supabase/admin_comms_funnel.sql new file mode 100644 index 0000000..842ee70 --- /dev/null +++ b/supabase/admin_comms_funnel.sql @@ -0,0 +1,149 @@ +-- admin_comms_funnel — воронка коммуникаций для страницы «Аналитика» (mega_admin/admin). +-- Один вызов отдаёт всё, что нужно блоку «Воронка коммуникаций»: +-- stages — этапы цепочки по когорте групп за период (push → SMS → звонки → согласование → ручная → выдача); +-- attempts — с какой попытки берут трубку и согласовывают; +-- channels — чем подтвердили (робот / менеджер / ссылка); +-- lists — «не поднимают трубку» и «открыли ссылку, но не подтвердили» (по 10 записей, для работы менеджера); +-- silent — сколько не достали ни ссылкой, ни звонком. +-- Конвенция периода — как у admin_automation_economics: p_days=30 → группы, созданные за 30 дней; +-- p_days=0 → всё время; p_date_from/p_date_to — явное окно (перекрывает p_days). +create or replace function public.admin_comms_funnel( + p_days integer default 30, + p_date_from timestamp with time zone default null, + p_date_to timestamp with time zone default null +) returns jsonb +language sql +stable +security definer +set search_path to 'public', 'extensions' +as $function$ +with cohort as ( + select og.* + from order_groups og + where og.created_at >= coalesce( + p_date_from, + case when p_days = 0 then '-infinity'::timestamptz + else now() - make_interval(days => p_days) end) + and og.created_at <= coalesce(p_date_to, now()) +), +calls as ( + select l.* from autocall_log l join cohort c on c.id = l.order_group_id +), +inv as ( + select i.* from delivery_invitations i join cohort c on c.id = i.order_group_id +), +-- группы, которых не достали ни ссылкой, ни ответом на звонок +silent as ( + select c.id + from cohort c + where c.notification_status not in ('confirmed', 'opt_out') + and not exists (select 1 from inv i where i.order_group_id = c.id and i.opened_at is not null) + and not exists (select 1 from calls l + where l.order_group_id = c.id + and l.outcome in ('confirmed', 'no_input_day', 'no_input_half')) +), +stages as ( + select + (select count(*) from cohort) as groups_cnt, + (select count(*) from cohort c where exists (select 1 from push_log p where p.order_group_id = c.id)) as push_cnt, + (select count(*) from cohort c where c.first_sms_sent_at is not null) as sms1_cnt, + (select count(*) from cohort c where c.second_sms_sent_at is not null) as sms2_cnt, + (select count(*) from inv i where i.opened_at is not null) as opened_cnt, + (select count(*) from cohort c where exists (select 1 from calls l where l.order_group_id = c.id)) as called_cnt, + (select count(*) from cohort c where exists ( + select 1 from calls l where l.order_group_id = c.id + and l.outcome in ('confirmed', 'no_input_day', 'no_input_half'))) as answered_cnt, + (select count(*) from cohort c where c.notification_status = 'confirmed') as confirmed_cnt, + (select count(*) from cohort c where c.notification_status = 'manual_required') as manual_cnt, + (select count(*) from cohort c where c.delivery_status in ('delivered', 'picked_up')) as done_cnt, + (select count(*) from silent) as silent_cnt +), +pct as ( + select + case when groups_cnt > 0 then round(push_cnt::numeric / groups_cnt * 100, 1) else 0 end as push_pct, + case when groups_cnt > 0 then round(sms1_cnt::numeric / groups_cnt * 100, 1) else 0 end as sms1_pct, + case when groups_cnt > 0 then round(opened_cnt::numeric / groups_cnt * 100, 1) else 0 end as opened_pct, + case when groups_cnt > 0 then round(called_cnt::numeric / groups_cnt * 100, 1) else 0 end as called_pct, + case when groups_cnt > 0 then round(answered_cnt::numeric / groups_cnt * 100, 1) else 0 end as answered_pct, + case when groups_cnt > 0 then round(confirmed_cnt::numeric / groups_cnt * 100, 1) else 0 end as confirmed_pct, + case when groups_cnt > 0 then round(manual_cnt::numeric / groups_cnt * 100, 1) else 0 end as manual_pct, + case when groups_cnt > 0 then round(silent_cnt::numeric / groups_cnt * 100, 1) else 0 end as silent_pct + from stages +), +attempt_rows as ( + select attempt_no, + count(*) as total, + count(*) filter (where outcome <> 'no_answer') as answered, + count(*) filter (where outcome = 'confirmed') as confirmed, + count(*) filter (where outcome = 'no_answer') as no_answer + from calls + group by attempt_no + order by attempt_no +), +channel_rows as ( + select coalesce( + c.delivery_date_source, + case when exists (select 1 from calls l where l.order_group_id = c.id and l.changed) + then 'autocall' end, + 'link' + ) as source, + count(*) as cnt + from cohort c + where c.notification_status = 'confirmed' + group by 1 +), +no_answer_rows as ( + select c.customer_phone as phone, coalesce(nullif(c.town, ''), '—') as town, + c.delivery_type as kind, count(*) as attempts, + max(l.created_at) as last_call, c.notification_status as status + from calls l join cohort c on c.id = l.order_group_id + group by 1, 2, 3, c.id, c.notification_status + having count(*) filter (where l.outcome <> 'no_answer') = 0 + order by attempts desc, max(l.created_at) desc + limit 10 +), +opened_rows as ( + select i.order_number as num, coalesce(nullif(i.customer_name, ''), '—') as name, + i.customer_phone as phone, i.opened_at as opened, i.access_count as opens + from inv i + where i.opened_at is not null and i.confirmed_at is null + order by i.opened_at desc + limit 10 +) +select jsonb_build_object( + 'period', jsonb_build_object('days', p_days, 'from', p_date_from, 'to', p_date_to), + 'stages', jsonb_build_array( + jsonb_build_object('key', 'groups', 'label', 'Групп в работе', 'value', s.groups_cnt, 'pct', 100), + jsonb_build_object('key', 'push', 'label', 'Push отправлен', 'value', s.push_cnt, 'pct', p.push_pct), + jsonb_build_object('key', 'sms1', 'label', 'Первое SMS', 'value', s.sms1_cnt, 'pct', p.sms1_pct), + jsonb_build_object('key', 'opened', 'label', 'Открыли ссылку', 'value', s.opened_cnt, 'pct', p.opened_pct), + jsonb_build_object('key', 'called', 'label', 'Робот позвонил', 'value', s.called_cnt, 'pct', p.called_pct), + jsonb_build_object('key', 'answered', 'label', 'Взяли трубку', 'value', s.answered_cnt, 'pct', p.answered_pct), + jsonb_build_object('key', 'confirmed', 'label', 'Согласовано', 'value', s.confirmed_cnt,'pct', p.confirmed_pct), + jsonb_build_object('key', 'manual', 'label', 'В ручную работу', 'value', s.manual_cnt, 'pct', p.manual_pct), + jsonb_build_object('key', 'done', 'label', 'Выдано/доставлено','value', s.done_cnt, 'pct', case when s.groups_cnt > 0 then round(s.done_cnt::numeric / s.groups_cnt * 100, 1) else 0 end) + ), + 'silent', jsonb_build_object('count', s.silent_cnt, 'pct', p.silent_pct), + 'push_subs', (select count(*) from push_subscriptions), + 'second_sms', jsonb_build_object('count', s.sms2_cnt, + 'pct', case when s.groups_cnt > 0 then round(s.sms2_cnt::numeric / s.groups_cnt * 100, 1) else 0 end), + 'attempts', coalesce((select jsonb_agg(jsonb_build_object( + 'attempt', a.attempt_no, 'total', a.total, 'answered', a.answered, + 'confirmed', a.confirmed, 'no_answer', a.no_answer, + 'answered_pct', case when a.total > 0 then round(a.answered::numeric / a.total * 100, 1) else 0 end, + 'confirmed_pct', case when a.total > 0 then round(a.confirmed::numeric / a.total * 100, 1) else 0 end + ) order by a.attempt_no) from attempt_rows a), '[]'::jsonb), + 'channels', coalesce((select jsonb_agg(jsonb_build_object('source', ch.source, 'count', ch.cnt) + order by ch.cnt desc) from channel_rows ch), '[]'::jsonb), + 'no_answer_list', coalesce((select jsonb_agg(jsonb_build_object( + 'phone', n.phone, 'town', n.town, 'kind', n.kind, 'attempts', n.attempts, + '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) +) +from stages s, pct p; +$function$; + +comment on function public.admin_comms_funnel(integer, timestamp with time zone, timestamp with time zone) is + 'Воронка коммуникаций для аналитики: этапы push → SMS → ссылка → звонки → согласование → ручная работа → выдача.';