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 }) => (
+
+);
+
+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 → ссылка → звонки → согласование → ручная работа → выдача.';