// 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 (
);
}
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 && (
)}
{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 (
);
})}
00 04 08 12 16 20 23
);
}
function TopQuestionsChart({ data }) {
if (!data || data.length === 0) {
return (
Топ-10 частых вопросов
за последние 30 дней
Нет данных
);
}
const max = data[0].count;
return (
Топ-10 частых вопросов
за последние 30 дней
{data.map((q, i) => (
))}
);
}
// На телефоне таблица из пяти колонок не читается — те же данные показываем
// карточками: имя со статусом сверху, метрики в ряд под ним.
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]) => (
))}
))}
);
}
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) => (
setRange(r.id)}
className={
"shrink-0 px-3 rounded-md text-xs font-medium transition whitespace-nowrap " +
(chrome ? "min-h-[40px] " : "py-1.5 ") +
(range === r.id ? "bg-[#4F8EF7] text-white" : "text-[#6b7280] hover:text-[#f1f1f5]")
}
>
{r.label}
))}
{/* KPI row */}
{/* Close time banner */}
{team.close_time_avg != null && (
Среднее время закрытия тикета
{fmtDuration(team.close_time_avg)}
)}
{/* Charts row */}
{/* Bottom row */}
);
}
Object.assign(window, { StatisticsScreen });