feat(analytics): воронка коммуникаций на странице «Аналитика»
Владелец: «нужно видеть, как всё происходит: push отправили — реакции не было, потом SMS, потом звонок; с какого раза человек ответил на звонок, кто вообще не поднимает трубку». Один RPC admin_comms_funnel(p_days, p_date_from, p_date_to) — период считается как у admin_automation_economics (30 = группы, созданные за 30 дней; 0 = всё время) — отдаёт: этапы цепочки по когорте (группы → push → первое SMS → открыли ссылку → позвонили → взяли трубку → согласовано → в ручную → выдано), разбивку звонков по попыткам (всего/взяли/согласовали/не взяли + % ответов), каналы подтверждения (робот / менеджер / ссылка), список «не поднимают трубку» и «открыли ссылку, но не подтвердили», и «silent» — кого не достали ни ссылкой, ни звонком. Блок CommsFunnelBlock на «Аналитике» под KPI-карточками, те же табы периода, mobile-first, время в МСК. За 30 дней: 396 групп, первое SMS 340, открыли ссылку 1, звонили 26, взяли трубку 14, согласовано 128, в ручную 22, выдано 357; 265 (66.9%) не достали никак. С попыток: 1-я — 42 ответа из 64, 2-я — 2 из 6, 3-я — 4 из 4. Тест: src/components/admin/analytics/CommsFunnelBlock.test.jsx (7 проверок, фикстура по реальному ответу RPC). SW: v123 -> v124.
This commit is contained in:
parent
5979b03f9f
commit
188db318e3
|
|
@ -1,7 +1,7 @@
|
||||||
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
const isLocalhost = self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1";
|
||||||
|
|
||||||
if (!isLocalhost) {
|
if (!isLocalhost) {
|
||||||
const STATIC_CACHE = "construction-delivery-static-v123";
|
const STATIC_CACHE = "construction-delivery-static-v124";
|
||||||
const RUNTIME_CACHE = "construction-delivery-runtime-v119";
|
const RUNTIME_CACHE = "construction-delivery-runtime-v119";
|
||||||
const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
const APP_SHELL_URLS = ["/", "/client", "/index.html", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png"];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { Skeleton } from '../UI/Loading';
|
||||||
import { useAdminStats } from '../../hooks/useAdminStats';
|
import { useAdminStats } from '../../hooks/useAdminStats';
|
||||||
import { usePickupStats } from '../../hooks/usePickupStats';
|
import { usePickupStats } from '../../hooks/usePickupStats';
|
||||||
import { PickupStatsPanel } from './PickupStatsPanel';
|
import { PickupStatsPanel } from './PickupStatsPanel';
|
||||||
|
import { CommsFunnelBlock } from './analytics/CommsFunnelBlock';
|
||||||
|
|
||||||
// ── Mobile Detection Hook ───────────────────────────────────────────────────
|
// ── Mobile Detection Hook ───────────────────────────────────────────────────
|
||||||
const useIsMobile = () => {
|
const useIsMobile = () => {
|
||||||
|
|
@ -129,7 +130,7 @@ const SectionHeader = ({ title, subtitle, mobile }) => (
|
||||||
export const AdminDashboard = () => {
|
export const AdminDashboard = () => {
|
||||||
const [period, setPeriod] = useState('7d');
|
const [period, setPeriod] = useState('7d');
|
||||||
const mobile = useIsMobile();
|
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 { stats: pickupStats, isLoading: pickupLoading } = usePickupStats(period);
|
||||||
|
|
||||||
const periodLabel = PERIOD_LABELS[period] || '';
|
const periodLabel = PERIOD_LABELS[period] || '';
|
||||||
|
|
@ -238,6 +239,9 @@ export const AdminDashboard = () => {
|
||||||
<KpiCard label="% доставки" value={sv.delivery_rate != null ? sv.delivery_rate + '%' : '—'} color="var(--color-text)" mobile={mobile} />
|
<KpiCard label="% доставки" value={sv.delivery_rate != null ? sv.delivery_rate + '%' : '—'} color="var(--color-text)" mobile={mobile} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Воронка коммуникаций: push → SMS → звонки → согласование ───────── */}
|
||||||
|
<CommsFunnelBlock funnel={funnel} isLoading={isLoading} mobile={mobile} periodLabel={periodLabel} />
|
||||||
|
|
||||||
{/* ── Main Grid: Charts + Tables ───────────────────────────────────────── */}
|
{/* ── Main Grid: Charts + Tables ───────────────────────────────────────── */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: gridCols, gap: mobile ? '0.75rem' : '1.5rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: gridCols, gap: mobile ? '0.75rem' : '1.5rem' }}>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }) => (
|
||||||
|
<div style={{ marginBottom: '0.6rem' }}>
|
||||||
|
<h3 style={{ fontSize: mobile ? '0.9rem' : '1rem', fontWeight: 700, color: 'var(--color-text)', marginBottom: subtitle ? '0.1rem' : 0 }}>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{subtitle && (
|
||||||
|
<div style={{ fontSize: mobile ? '0.65rem' : '0.72rem', color: 'var(--color-text-muted)' }}>{subtitle}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const StageRow = ({ stage, max, mobile, fontSize }) => {
|
||||||
|
const pct = max > 0 ? Math.max(0, (stage.value / max) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.35rem' }}>
|
||||||
|
<div style={{
|
||||||
|
flex: '0 0 auto', width: mobile ? '96px' : '150px', fontSize: fontSize.xs,
|
||||||
|
color: 'var(--color-text-muted)', textAlign: 'right', lineHeight: 1.2,
|
||||||
|
}}>
|
||||||
|
{stage.label}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
flex: '1 1 auto', height: mobile ? '16px' : '22px',
|
||||||
|
background: 'var(--color-border, rgba(51,65,85,0.4))', borderRadius: '4px', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: pct + '%', height: '100%', background: STAGE_COLORS[stage.key] || '#3b82f6',
|
||||||
|
borderRadius: '4px', transition: 'width 0.4s ease', minWidth: pct > 0 ? '4px' : '0',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
flex: '0 0 auto', width: mobile ? '62px' : '86px', fontSize: fontSize.s,
|
||||||
|
display: 'flex', gap: '0.3rem', alignItems: 'baseline',
|
||||||
|
}}>
|
||||||
|
<strong style={{ color: 'var(--color-text)' }}>{stage.value}</strong>
|
||||||
|
<span style={{ color: 'var(--color-text-muted)', fontSize: fontSize.xs }}>{stage.pct}%</span>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Th = ({ children, mobile, fontSize, align = 'left' }) => (
|
||||||
|
<th style={{
|
||||||
|
textAlign: align, padding: mobile ? '0.3rem 0.35rem' : '0.4rem 0.5rem',
|
||||||
|
fontSize: '13px', fontWeight: 600, color: 'var(--color-text-muted)',
|
||||||
|
borderBottom: '1px solid var(--color-border, #334155)', whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Td = ({ children, mobile, fontSize, muted = false, align = 'left' }) => (
|
||||||
|
<td style={{
|
||||||
|
textAlign: align, padding: mobile ? '0.3rem 0.35rem' : '0.4rem 0.5rem',
|
||||||
|
fontSize: fontSize.s, color: muted ? 'var(--color-text-muted)' : 'var(--color-text)',
|
||||||
|
borderBottom: '1px solid var(--color-border, rgba(51,65,85,0.35))', whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
|
||||||
|
const EmptyRow = ({ cols, mobile, fontSize, text }) => (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={cols} style={{
|
||||||
|
padding: '0.6rem', textAlign: 'center', fontSize: fontSize.s,
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
}}>
|
||||||
|
{text}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem' }}>
|
||||||
|
<SectionHeader title="Воронка коммуникаций" subtitle={periodLabel} mobile={mobile} />
|
||||||
|
<div style={{ color: 'var(--color-text-muted)', fontSize: fontSize.s, padding: '1rem 0' }}>Загружаю…</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel style={{ padding: mobile ? '0.75rem' : '1.25rem' }}>
|
||||||
|
<SectionHeader
|
||||||
|
title="Воронка коммуникаций"
|
||||||
|
subtitle={periodLabel ? `${periodLabel} · как проходили контакты с клиентами` : 'Как проходили контакты с клиентами'}
|
||||||
|
mobile={mobile}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Этапы цепочки ───────────────────────────────────────────────── */}
|
||||||
|
<div style={{ marginBottom: '0.75rem' }}>
|
||||||
|
{stages.map((s) => (
|
||||||
|
<StageRow key={s.key} stage={s} max={max} mobile={mobile} fontSize={fontSize} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginBottom: '0.9rem' }}>
|
||||||
|
<Chip label="Не достали (ни ссылка, ни звонок)" value={`${silent.count} · ${silent.pct}%`} color="#ef4444" fontSize={fontSize} />
|
||||||
|
{stages.find((s) => s.key === 'push') && (
|
||||||
|
<Chip
|
||||||
|
label={stages.find((s) => s.key === 'push').value > 0 ? 'Push-подписки' : 'Push не используется, подписок'}
|
||||||
|
value={pushSubs}
|
||||||
|
color="#a855f7"
|
||||||
|
fontSize={fontSize}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── С какой попытки отвечают ─────────────────────────────────────── */}
|
||||||
|
<div style={{ marginBottom: '0.9rem', overflowX: 'auto' }}>
|
||||||
|
<div style={{ fontSize: fontSize.m, fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.35rem' }}>
|
||||||
|
С какой попытки отвечают
|
||||||
|
</div>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<Th mobile={mobile} fontSize={fontSize}>Попытка</Th>
|
||||||
|
<Th mobile={mobile} fontSize={fontSize} align="right">Звонков</Th>
|
||||||
|
<Th mobile={mobile} fontSize={fontSize} align="right">Взяли трубку</Th>
|
||||||
|
<Th mobile={mobile} fontSize={fontSize} align="right">Согласовали</Th>
|
||||||
|
<Th mobile={mobile} fontSize={fontSize} align="right">Не взяли</Th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{attempts.length === 0 ? (
|
||||||
|
<EmptyRow cols={5} mobile={mobile} fontSize={fontSize} text="За период робот не звонил" />
|
||||||
|
) : attempts.map((a) => (
|
||||||
|
<tr key={a.attempt}>
|
||||||
|
<Td mobile={mobile} fontSize={fontSize}>{a.attempt}-я</Td>
|
||||||
|
<Td mobile={mobile} fontSize={fontSize} align="right">{a.total}</Td>
|
||||||
|
<Td mobile={mobile} fontSize={fontSize} align="right">
|
||||||
|
{a.answered} <span style={{ color: 'var(--color-text-muted)' }}>({a.answered_pct}%)</span>
|
||||||
|
</Td>
|
||||||
|
<Td mobile={mobile} fontSize={fontSize} align="right">{a.confirmed}</Td>
|
||||||
|
<Td mobile={mobile} fontSize={fontSize} align="right" muted>{a.no_answer}</Td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Чем подтвердили ─────────────────────────────────────────────── */}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginBottom: '0.9rem' }}>
|
||||||
|
{channels.length === 0 ? (
|
||||||
|
<div style={{ fontSize: fontSize.s, color: 'var(--color-text-muted)' }}>Подтверждений за период нет</div>
|
||||||
|
) : channels.map((c) => (
|
||||||
|
<Chip
|
||||||
|
key={c.source}
|
||||||
|
label={CHANNEL_LABELS[c.source] || c.source}
|
||||||
|
value={c.count}
|
||||||
|
color={c.source === 'autocall' ? '#6366f1' : c.source === 'manual' ? '#eab308' : '#06b6d4'}
|
||||||
|
fontSize={fontSize}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Не поднимают трубку ─────────────────────────────────────────── */}
|
||||||
|
<div style={{ marginBottom: '0.9rem', overflowX: 'auto' }}>
|
||||||
|
<div style={{ fontSize: fontSize.m, fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.35rem' }}>
|
||||||
|
Не поднимают трубку
|
||||||
|
</div>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<Th mobile={mobile} fontSize={fontSize}>Телефон</Th>
|
||||||
|
<Th mobile={mobile} fontSize={fontSize}>Город</Th>
|
||||||
|
<Th mobile={mobile} fontSize={fontSize}>Тип</Th>
|
||||||
|
<Th mobile={mobile} fontSize={fontSize} align="right">Попыток</Th>
|
||||||
|
<Th mobile={mobile} fontSize={fontSize}>Последний звонок</Th>
|
||||||
|
<Th mobile={mobile} fontSize={fontSize}>Статус</Th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{noAnswer.length === 0 ? (
|
||||||
|
<EmptyRow cols={6} mobile={mobile} fontSize={fontSize} text="Нет таких номеров за период" />
|
||||||
|
) : noAnswer.map((n) => (
|
||||||
|
<tr key={n.phone}>
|
||||||
|
<Td mobile={mobile} fontSize={fontSize}>{n.phone}</Td>
|
||||||
|
<Td mobile={mobile} fontSize={fontSize} muted>{n.town}</Td>
|
||||||
|
<Td mobile={mobile} fontSize={fontSize} muted>{KIND_LABELS[n.kind] || n.kind}</Td>
|
||||||
|
<Td mobile={mobile} fontSize={fontSize} align="right">{n.attempts}</Td>
|
||||||
|
<Td mobile={mobile} fontSize={fontSize} muted>{fmtDateTime(n.last_call)}</Td>
|
||||||
|
<Td mobile={mobile} fontSize={fontSize} muted>{STATUS_LABELS[n.status] || n.status || '—'}</Td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Открыли ссылку, но не подтвердили ───────────────────────────── */}
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<div style={{ fontSize: fontSize.m, fontWeight: 600, color: 'var(--color-text)', marginBottom: '0.35rem' }}>
|
||||||
|
Открыли ссылку, но не подтвердили
|
||||||
|
</div>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<Th mobile={mobile} fontSize={fontSize}>Заказ</Th>
|
||||||
|
<Th mobile={mobile} fontSize={fontSize}>Клиент</Th>
|
||||||
|
<Th mobile={mobile} fontSize={fontSize}>Телефон</Th>
|
||||||
|
<Th mobile={mobile} fontSize={fontSize} align="right">Открытий</Th>
|
||||||
|
<Th mobile={mobile} fontSize={fontSize}>Последнее</Th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{openedNoConfirm.length === 0 ? (
|
||||||
|
<EmptyRow cols={5} mobile={mobile} fontSize={fontSize} text="Все, кто открыл ссылку, подтвердили" />
|
||||||
|
) : openedNoConfirm.map((o) => (
|
||||||
|
<tr key={o.num + o.phone}>
|
||||||
|
<Td mobile={mobile} fontSize={fontSize}>{o.num}</Td>
|
||||||
|
<Td mobile={mobile} fontSize={fontSize} muted>{o.name}</Td>
|
||||||
|
<Td mobile={mobile} fontSize={fontSize} muted>{o.phone}</Td>
|
||||||
|
<Td mobile={mobile} fontSize={fontSize} align="right">{o.opens}</Td>
|
||||||
|
<Td mobile={mobile} fontSize={fontSize} muted>{fmtDateTime(o.opened)}</Td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CommsFunnelBlock;
|
||||||
|
|
@ -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(<CommsFunnelBlock funnel={funnel} mobile={false} periodLabel="30 дней" />);
|
||||||
|
|
||||||
|
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(<CommsFunnelBlock funnel={emptyFunnel} mobile={true} periodLabel="Сегодня" />);
|
||||||
|
expect(empty).toContain("За период робот не звонил");
|
||||||
|
expect(empty).toContain("Нет таких номеров за период");
|
||||||
|
expect(empty).toContain("Все, кто открыл ссылку, подтвердили");
|
||||||
|
expect(empty).toContain("Подтверждений за период нет");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("показывает заглушку загрузки, пока данных нет", () => {
|
||||||
|
const loading = renderToStaticMarkup(<CommsFunnelBlock funnel={null} isLoading mobile={false} periodLabel="7 дней" />);
|
||||||
|
expect(loading).toContain("Воронка коммуникаций");
|
||||||
|
expect(loading).toContain("Загружаю…");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -18,6 +18,7 @@ export const useAdminStats = (period = "30d", dateFrom = null, dateTo = null) =>
|
||||||
const [dailyTrend, setDailyTrend] = useState([]);
|
const [dailyTrend, setDailyTrend] = useState([]);
|
||||||
const [driverStats, setDriverStats] = useState([]);
|
const [driverStats, setDriverStats] = useState([]);
|
||||||
const [economics, setEconomics] = useState(null);
|
const [economics, setEconomics] = useState(null);
|
||||||
|
const [funnel, setFunnel] = useState(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
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_from: dateFrom || null,
|
||||||
p_date_to: dateTo || 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_delivery_stats", params),
|
||||||
rpcCall("admin_status_distribution", params),
|
rpcCall("admin_status_distribution", params),
|
||||||
rpcCall("admin_daily_trend", params),
|
rpcCall("admin_daily_trend", params),
|
||||||
rpcCall("admin_driver_stats", params),
|
rpcCall("admin_driver_stats", params),
|
||||||
rpcCall("admin_automation_economics", params),
|
rpcCall("admin_automation_economics", params),
|
||||||
|
rpcCall("admin_comms_funnel", params),
|
||||||
]);
|
]);
|
||||||
setStats(s?.[0] || null);
|
setStats(s?.[0] || null);
|
||||||
setStatusDist(sd || []);
|
setStatusDist(sd || []);
|
||||||
setDailyTrend(dt || []);
|
setDailyTrend(dt || []);
|
||||||
setDriverStats(ds || []);
|
setDriverStats(ds || []);
|
||||||
setEconomics(econ?.[0] || null);
|
setEconomics(econ?.[0] || null);
|
||||||
|
setFunnel(fn || null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message || "Ошибка загрузки статистики");
|
setError(e.message || "Ошибка загрузки статистики");
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -53,5 +56,5 @@ export const useAdminStats = (period = "30d", dateFrom = null, dateTo = null) =>
|
||||||
|
|
||||||
useEffect(() => { fetchAll(); }, [fetchAll]);
|
useEffect(() => { fetchAll(); }, [fetchAll]);
|
||||||
|
|
||||||
return { stats, statusDist, dailyTrend, driverStats, economics, isLoading, error, refetch: fetchAll };
|
return { stats, statusDist, dailyTrend, driverStats, economics, funnel, isLoading, error, refetch: fetchAll };
|
||||||
};
|
};
|
||||||
|
|
@ -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 → ссылка → звонки → согласование → ручная работа → выдача.';
|
||||||
Loading…
Reference in New Issue