// Statistics screen const { useState: useStateS, useEffect: useEffectS, useMemo: useMemoS } = React; function fmtDuration(seconds) { if (seconds == null) return "—"; const s = Math.round(seconds); if (s < 60) return `${s} сек`; if (s < 3600) { const m = Math.floor(s / 60), r = s % 60; return r ? `${m} мин ${r} сек` : `${m} мин`; } const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60); if (s < 86400) return m ? `${h} ч ${m} мин` : `${h} ч`; const d = Math.floor(s / 86400), rh = Math.floor((s % 86400) / 3600); return rh ? `${d} д ${rh} ч` : `${d} д`; } function StatCard({ label, value, sub }) { return (
{label}
{value}
{sub &&
{sub}
}
); } function LineChart({ data, days = 14 }) { if (!data || data.length < 2) { return (
Нет данных за период
); } const max = Math.max(...data); const min = Math.min(...data); const w = 100, h = 100; const pts = data.map((v, i) => { const x = (i / (data.length - 1)) * w; const y = h - ((v - min) / (max - min || 1)) * h * 0.85 - 5; return [x, y]; }); const path = pts.map(([x, y], i) => (i === 0 ? `M ${x} ${y}` : `L ${x} ${y}`)).join(" "); const area = path + ` L ${w} ${h} L 0 ${h} Z`; const today = new Date(); const labelCount = 7; const step = Math.floor((data.length - 1) / (labelCount - 1)); const labels = Array.from({ length: labelCount }, (_, i) => { const d = new Date(today); d.setDate(d.getDate() - (data.length - 1 - i * step)); return d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" }).replace(".", ""); }); const lastVal = data[data.length - 1]; return (
Обращения по дням
последние {days} дней
{[0, 25, 50, 75].map((y) => ( ))} {pts.map(([x, y], i) => ( ))} {lastVal > 0 && (
сегодня
{lastVal} обращ.
)}
{labels.map((l, i) => {l})}
); } function HeatmapChart({ data }) { const max = data && data.length ? Math.max(...data) : 1; const peakHour = data && data.length ? data.indexOf(Math.max(...data)) : 0; return (
Обращения по часам
средние значения за 14 дней · пик в {peakHour}:00
{(data || Array(24).fill(0)).map((v, i) => { const intensity = v / (max || 1); const h = Math.max(8, intensity * 100); return (
{i}:00 · {v}
); })}
00040812162023
); } function TopQuestionsChart({ data }) { if (!data || data.length === 0) { return (
Топ-10 частых вопросов
за последние 30 дней
Нет данных
); } const max = data[0].count; return (
Топ-10 частых вопросов
за последние 30 дней
{data.map((q, i) => (
{i + 1}
{q.q}
{q.count}
))}
); } // На телефоне таблица из пяти колонок не читается — те же данные показываем // карточками: имя со статусом сверху, метрики в ряд под ним. function OperatorCards({ operators }) { return (
{operators.map((op) => (
{op.name}
{op.role === "admin" ? "Администратор" : "Агент"} · {op.tg}
{op.online ? "Онлайн" : "Офлайн"}
{[["Диалогов", op.dialogs_count ?? 0], ["Первый ответ", fmtDuration(op.first_response_avg)], ["Ср. ответ", fmtDuration(op.next_response_avg)]].map(([l, v]) => (
{l}
{v}
))}
))}
); } function OperatorsTable({ operators, compact = false }) { return (
Операторы
{operators.filter((o) => o.online).length} онлайн
{compact ? : ( {operators.map((op) => ( ))}
Имя Диалогов Первый ответ Ср. ответ Статус
{op.name}
{op.role === "admin" ? "Администратор" : "Агент"} · {op.tg}
{op.dialogs_count ?? 0} {fmtDuration(op.first_response_avg)} {fmtDuration(op.next_response_avg)} {op.online ? "Онлайн" : "Офлайн"}
)}
); } function StatisticsScreen({ serviceId = null, mobileChrome = null }) { const [range, setRange] = useStateS("14d"); const [stats, setStats] = useStateS(null); const [times, setTimes] = useStateS(null); const days = range === "today" ? 1 : range === "7d" ? 7 : range === "14d" ? 14 : 30; useEffectS(() => { setStats(null); setTimes(null); let stale = false; // serviceId === null — сводная статистика по всем доступным сервисам // (режим «Все сервисы» в переключателе). const svc = serviceId === null ? "" : `&service_id=${serviceId}`; Promise.all([ window.apiFetch("GET", `/api/stats?days=${days}${svc}`), window.apiFetch("GET", `/api/stats/times?days=${days}${svc}`), ]).then(([s, t]) => { if (!stale) { setStats(s); setTimes(t); } }).catch(() => {}); return () => { stale = true; }; }, [days, serviceId]); const ranges = [ { id: "today", label: "Сегодня" }, { id: "7d", label: "7 дней" }, { id: "14d", label: "14 дней" }, { id: "30d", label: "30 дней" }, ]; const team = times?.team || {}; // Merge operator time stats into base operators list from /api/stats const operators = useMemoS(() => { const base = stats?.operators || []; const timeOps = times?.operators || []; const timeMap = Object.fromEntries(timeOps.map((o) => [o.id, o])); return base.map((op) => ({ ...op, ...(timeMap[op.id] || {}) })); }, [stats, times]); const chrome = mobileChrome; const currentName = chrome && (chrome.currentServiceId == null ? "Все сервисы" : (chrome.services || []).find((s) => s.id === chrome.currentServiceId)?.name || ""); return (
{chrome && ( <> } /> )}
{/* Header */}

Статистика

данные за выбранный период
{/* shrink-0 обязателен: без него флекс-дети сжимаются раньше, чем включится прокрутка, и whitespace-nowrap обрезает подписи. */}
{ranges.map((r) => ( ))}
{/* KPI row */}
{/* Close time banner */} {team.close_time_avg != null && (
Среднее время закрытия тикета
{fmtDuration(team.close_time_avg)}
)} {/* Charts row */}
{/* Bottom row */}
); } Object.assign(window, { StatisticsScreen });