chore: remove dead supervisor analytics and archived tooling

This commit is contained in:
Yera All
2026-04-08 15:59:07 +05:00
parent cf824f3fdd
commit bfaec370f9
23 changed files with 0 additions and 6484 deletions
-781
View File
@@ -13,27 +13,6 @@ const state = {
selectedArticleId: '',
lastQuery: '',
},
analytics: {
preset: '7d',
fromTs: '',
toTs: '',
queueId: 'all',
channel: 'all',
trendMetric: 'volume',
overview: null,
compare: null,
trend: [],
channelRows: [],
queueRows: [],
coverage: null,
queueOptions: [],
realtime: null,
liveCallsCount: 0,
loading: false,
error: '',
queueAccessError: '',
requestId: 0,
},
oidc: {
enabled: false,
loginPath: '/auth/oidc/start?return_mode=popup',
@@ -50,26 +29,6 @@ const ROLE_LABELS = {
analyst: 'Аналитик',
};
const ANALYTICS_CHANNEL_OPTIONS = [
{ value: 'all', label: 'Все каналы' },
{ value: 'voice', label: 'Голос' },
{ value: 'telegram', label: 'Telegram' },
{ value: 'whatsapp', label: 'WhatsApp' },
{ value: 'webchat', label: 'Веб-чат' },
{ value: 'email', label: 'Электронная почта' },
];
const ANALYTICS_METRIC_META = {
total: { unit: 'count', better: 'up', note: 'Всего обращений в окне' },
answered: { unit: 'count', better: 'up', note: 'Обработано командой' },
SL: { unit: 'pct', better: 'up', note: 'Доля в SLA' },
ASA: { unit: 'seconds', better: 'down', note: 'Среднее ожидание' },
AHT: { unit: 'seconds', better: 'down', note: 'Среднее время обработки' },
Abandon: { unit: 'pct', better: 'down', note: 'Потерянные обращения' },
FCR: { unit: 'pct', better: 'up', note: 'Решено с первого контакта' },
DigitalShare: { unit: 'pct', better: 'neutral', note: 'Доля цифровых каналов' },
volume: { unit: 'count', better: 'up', note: 'Объём по временной точке' },
};
function destinationForRole(role) {
if (role === 'admin') {
@@ -381,742 +340,6 @@ function renderKpi(data) {
$('kpiOutput').textContent = JSON.stringify(data, null, 2);
}
function emptyKpiEnvelope(fromTs = null, toTs = null) {
return {
window: { from: fromTs, to: toTs },
volume: {
total: 0,
answered: 0,
abandoned: 0,
},
kpi: {
SL: 0,
ASA: 0,
AHT: 0,
Abandon: 0,
FCR: 0,
AnswerRate: 0,
WaitP95: 0,
HandleP95: 0,
Occupancy: 0,
DigitalShare: 0,
},
breakdowns: {
by_channel: {},
},
filters: {},
};
}
function pad2(value) {
return String(value).padStart(2, '0');
}
function startOfDay(date) {
const next = new Date(date);
next.setHours(0, 0, 0, 0);
return next;
}
function addDays(date, days) {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
function formatDateTimeLocalValue(date) {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}T${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
}
function normalizeAnalyticsRange(from, to) {
const safeFrom = new Date(from);
const safeTo = new Date(to);
if (Number.isNaN(safeFrom.getTime()) || Number.isNaN(safeTo.getTime())) {
return { from: new Date(), to: new Date() };
}
if (safeTo <= safeFrom) {
return { from: safeFrom, to: new Date(safeFrom.getTime() + 60 * 60 * 1000) };
}
return { from: safeFrom, to: safeTo };
}
function analyticsRangeFromControls() {
const preset = $('analyticsPreset').value || state.analytics.preset || '7d';
const customFrom = $('analyticsFrom').value.trim();
const customTo = $('analyticsTo').value.trim();
const now = new Date();
let from;
let to;
let custom = false;
if (customFrom && customTo) {
custom = true;
from = new Date(customFrom);
to = new Date(customTo);
} else if (preset === 'today') {
from = startOfDay(now);
to = now;
} else if (preset === '30d') {
from = startOfDay(addDays(now, -29));
to = now;
} else {
from = startOfDay(addDays(now, -6));
to = now;
}
const current = normalizeAnalyticsRange(from, to);
const durationMs = Math.max(current.to.getTime() - current.from.getTime(), 60 * 60 * 1000);
const previous = {
from: new Date(current.from.getTime() - durationMs),
to: new Date(current.from.getTime()),
};
return {
preset,
custom,
current,
previous,
};
}
function syncAnalyticsStateFromControls() {
state.analytics.preset = $('analyticsPreset').value || '7d';
state.analytics.fromTs = $('analyticsFrom').value.trim();
state.analytics.toTs = $('analyticsTo').value.trim();
state.analytics.queueId = $('analyticsQueue').value || 'all';
state.analytics.channel = $('analyticsChannel').value || 'all';
state.analytics.trendMetric = $('analyticsTrendMetric').value || 'volume';
}
function syncAnalyticsControlsFromState() {
$('analyticsPreset').value = state.analytics.preset || '7d';
$('analyticsFrom').value = state.analytics.fromTs || '';
$('analyticsTo').value = state.analytics.toTs || '';
$('analyticsChannel').value = state.analytics.channel || 'all';
$('analyticsTrendMetric').value = state.analytics.trendMetric || 'volume';
renderAnalyticsQueueOptions();
}
function resetAnalyticsFilters() {
state.analytics.preset = '7d';
state.analytics.fromTs = '';
state.analytics.toTs = '';
state.analytics.queueId = 'all';
state.analytics.channel = 'all';
state.analytics.trendMetric = 'volume';
syncAnalyticsControlsFromState();
}
function analyticsQuery(range, overrides = {}) {
const params = new URLSearchParams();
params.set('from_ts', range.from.toISOString());
params.set('to_ts', range.to.toISOString());
const queueId = overrides.queueId ?? state.analytics.queueId;
const channel = overrides.channel ?? state.analytics.channel;
if (queueId && queueId !== 'all') {
params.set('queue_id', queueId);
}
if (channel && channel !== 'all') {
params.set('channel', channel);
}
return params.toString();
}
async function fetchAnalyticsKpi(range, overrides = {}) {
return api('reporting', `reports/kpi?${analyticsQuery(range, overrides)}`);
}
function renderAnalyticsQueueOptions() {
const select = $('analyticsQueue');
if (!select) {
return;
}
const selected = state.analytics.queueId || 'all';
const options = ['<option value="all">Все очереди</option>'];
if (selected !== 'all' && !state.analytics.queueOptions.some((item) => item.queue_id === selected)) {
options.push(`<option value="${escapeHtml(selected)}">${escapeHtml(selected)}</option>`);
}
options.push(
...state.analytics.queueOptions.map(
(item) => `<option value="${escapeHtml(item.queue_id)}">${escapeHtml(item.name || item.queue_id)}</option>`,
),
);
select.innerHTML = options.join('');
select.value = options.some((item) => item.includes(`value="${selected}"`)) ? selected : 'all';
}
async function loadAnalyticsQueueOptions() {
try {
const data = await api('routing', 'queues');
state.analytics.queueOptions = Array.isArray(data) ? data : [];
state.analytics.queueAccessError = '';
} catch (err) {
state.analytics.queueOptions = [];
state.analytics.queueAccessError = err.message;
}
renderAnalyticsQueueOptions();
return state.analytics.queueOptions;
}
function formatAnalyticsNumber(value, digits = 0) {
return new Intl.NumberFormat('ru-RU', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(Number(value || 0));
}
function analyticsChannelLabel(channel) {
const meta = ANALYTICS_CHANNEL_OPTIONS.find((item) => item.value === channel);
return meta ? meta.label : String(channel || 'неизвестно');
}
function metricValueFromPayload(metric, payload) {
if (metric === 'volume' || metric === 'total') {
return Number(payload?.volume?.total || 0);
}
if (metric === 'answered') {
return Number(payload?.volume?.answered || 0);
}
return Number(payload?.kpi?.[metric] || 0);
}
function formatAnalyticsMetric(metric, value) {
const meta = ANALYTICS_METRIC_META[metric] || { unit: 'count' };
if (meta.unit === 'pct') {
return `${formatAnalyticsNumber(value, 2)}%`;
}
if (meta.unit === 'seconds') {
return `${formatAnalyticsNumber(value, 2)} с`;
}
return formatAnalyticsNumber(value, 0);
}
function signedDelta(value, digits = 1) {
if (!value) {
return '0';
}
const sign = value > 0 ? '+' : '-';
return `${sign}${formatAnalyticsNumber(Math.abs(value), digits)}`;
}
function analyticsDelta(metric, currentPayload, previousPayload) {
const current = metricValueFromPayload(metric, currentPayload);
const previous = metricValueFromPayload(metric, previousPayload);
const diff = current - previous;
const meta = ANALYTICS_METRIC_META[metric] || { unit: 'count', better: 'neutral' };
let text = signedDelta(diff, meta.unit === 'count' ? 0 : 1);
if (meta.unit === 'pct') {
text = `${text} п.п.`;
} else if (meta.unit === 'seconds') {
text = `${text} с`;
}
let tone = 'neutral';
if (meta.better === 'up') {
tone = diff > 0 ? 'positive' : diff < 0 ? 'negative' : 'neutral';
} else if (meta.better === 'down') {
tone = diff < 0 ? 'positive' : diff > 0 ? 'negative' : 'neutral';
}
return { text, tone };
}
function renderAnalyticsCard(label, metric, payload, previousPayload) {
const value = metricValueFromPayload(metric, payload);
const delta = analyticsDelta(metric, payload, previousPayload);
const note = ANALYTICS_METRIC_META[metric]?.note || '';
return `
<div class="summary-card analytics-card">
<div class="analytics-card-top">
<div class="summary-label">${label}</div>
<span class="analytics-delta analytics-delta-${delta.tone}">${delta.text}</span>
</div>
<div class="summary-value">${formatAnalyticsMetric(metric, value)}</div>
${note ? `<div class="summary-note">${note}</div>` : ''}
</div>
`;
}
function updateAnalyticsRangeHint(range) {
const filters = [];
if (state.analytics.queueId !== 'all') {
filters.push(`очередь: ${state.analytics.queueId}`);
}
if (state.analytics.channel !== 'all') {
filters.push(`канал: ${state.analytics.channel}`);
}
const suffix = filters.length ? ` | ${filters.join(' | ')}` : '';
$('analyticsRangeHint').textContent = range.custom
? `Кастомный диапазон: ${formatTime(range.current.from.toISOString())} - ${formatTime(range.current.to.toISOString())}${suffix}`
: `Период: ${range.preset}${suffix}`;
}
function renderAnalyticsOperationsStrip() {
const realtime = state.analytics.realtime || {};
const agents = realtime?.agents || {};
const byState = agents.by_state || {};
const firstQueue = Array.isArray(realtime?.queues) ? realtime.queues[0] || {} : {};
$('analyticsOperations').innerHTML = [
renderSummaryCard('Агенты', Number(agents.total || 0), 'Активный состав в оперативном срезе'),
renderSummaryCard('Готовы', Number(byState.READY || 0), 'Свободные агенты'),
renderSummaryCard('Заняты', Number(byState.BUSY || 0), 'В обработке'),
renderSummaryCard('Живые звонки', Number(state.analytics.liveCallsCount || 0), 'Последние звонки от Asterisk'),
renderSummaryCard('Очередь', firstQueue.queue_id || 'нет', `В очереди: ${Number(firstQueue.in_queue || 0)}`),
].join('');
}
function renderAnalyticsOverview() {
const current = state.analytics.overview || emptyKpiEnvelope();
const previous = state.analytics.compare || emptyKpiEnvelope();
$('analyticsOverview').innerHTML = [
renderAnalyticsCard('Поток', 'total', current, previous),
renderAnalyticsCard('Обработано', 'answered', current, previous),
renderAnalyticsCard('SL', 'SL', current, previous),
renderAnalyticsCard('ASA', 'ASA', current, previous),
renderAnalyticsCard('AHT', 'AHT', current, previous),
renderAnalyticsCard('Потери', 'Abandon', current, previous),
renderAnalyticsCard('FCR', 'FCR', current, previous),
renderAnalyticsCard('Цифровая доля', 'DigitalShare', current, previous),
].join('');
}
function renderAnalyticsChannelTable() {
const container = $('analyticsChannelTable');
const rows = state.analytics.channelRows || [];
if (!rows.length) {
container.innerHTML = '<div class="empty-state">Нет данных по каналам за выбранный период.</div>';
return;
}
container.innerHTML = `
<div class="analytics-table-head analytics-channel-grid">
<span>Канал</span>
<span>Всего</span>
<span>Обработано</span>
<span>Потеряно</span>
<span>Доля ответа</span>
</div>
${rows
.map((item) => {
const answerRate = item.total ? (item.answered / item.total) * 100 : 0;
return `
<div class="analytics-table-row analytics-channel-grid">
<span>${escapeHtml(analyticsChannelLabel(item.channel))}</span>
<span>${formatAnalyticsNumber(item.total, 0)}</span>
<span>${formatAnalyticsNumber(item.answered, 0)}</span>
<span>${formatAnalyticsNumber(item.abandoned, 0)}</span>
<span>${formatAnalyticsNumber(answerRate, 2)}%</span>
</div>
`;
})
.join('')}
`;
}
function renderAnalyticsQueueTable() {
const container = $('analyticsQueueTable');
const rows = state.analytics.queueRows || [];
if (!rows.length) {
const reason = state.analytics.queueAccessError
? 'Таблица очередей недоступна для текущей роли: backend не разрешил получить каталог очередей.'
: 'Очереди пока не вернули исторические данные за выбранное окно.';
container.innerHTML = `<div class="empty-state">${escapeHtml(reason)}</div>`;
return;
}
container.innerHTML = `
<div class="analytics-table-head analytics-queue-grid">
<span>Очередь</span>
<span>Всего</span>
<span>Обработано</span>
<span>SL</span>
<span>Потери</span>
</div>
${rows
.map((item) => `
<button class="analytics-table-row analytics-table-button analytics-queue-grid${item.queue_id === state.analytics.queueId ? ' selected' : ''}" type="button" data-analytics-queue-id="${escapeHtml(item.queue_id)}">
<span><strong>${escapeHtml(item.name || item.queue_id)}</strong><small>${escapeHtml(item.queue_id)}</small></span>
<span>${formatAnalyticsNumber(item.total, 0)}</span>
<span>${formatAnalyticsNumber(item.answered, 0)}</span>
<span>${formatAnalyticsNumber(item.SL, 2)}%</span>
<span>${formatAnalyticsNumber(item.Abandon, 2)}%</span>
</button>
`)
.join('')}
`;
}
function renderAnalyticsCoverage() {
const implemented = Array.isArray(state.analytics.coverage?.implemented_metrics)
? state.analytics.coverage.implemented_metrics
: [];
const dimensions = Array.isArray(state.analytics.coverage?.dimensions)
? state.analytics.coverage.dimensions
: [];
const filters = Array.isArray(state.analytics.coverage?.supported_filters)
? state.analytics.coverage.supported_filters
: [];
$('analyticsCoverage').innerHTML = `
<div class="analytics-coverage-list">
<div class="analytics-coverage-block">
<h4>Что уже есть</h4>
<div class="analytics-coverage-chips">
${implemented.length
? implemented
.map((item) => `<span class="analytics-coverage-chip">${escapeHtml(item.code)} · ${escapeHtml(item.label)}</span>`)
.join('')
: '<span class="empty-state">Сервис coverage пока не вернул каталог метрик.</span>'}
</div>
</div>
<div class="analytics-coverage-block">
<h4>Разрезы</h4>
<div class="analytics-coverage-chips">
${dimensions.length
? dimensions.map((item) => `<span class="analytics-coverage-chip soft">${escapeHtml(item)}</span>`).join('')
: '<span class="empty-state">Нет данных о разрезах.</span>'}
</div>
</div>
<div class="analytics-coverage-block">
<h4>Фильтры</h4>
<div class="analytics-coverage-chips">
${filters.length
? filters.map((item) => `<span class="analytics-coverage-chip soft">${escapeHtml(item)}</span>`).join('')
: '<span class="empty-state">Нет данных о фильтрах.</span>'}
</div>
</div>
</div>
<div class="analytics-placeholder-grid">
<article class="analytics-placeholder-card">
<strong>Аналитика по агентам</strong>
<p>Появится, когда reporting начнёт отдавать детализацию по <code>agent_id</code> и персональные ряды производительности.</p>
</article>
<article class="analytics-placeholder-card">
<strong>AI-аналитика</strong>
<p>Ждём отдельные AI-метрики: долю закрытий, передачу оператору, задержку ответа и покрытие по AI-сводкам.</p>
</article>
<article class="analytics-placeholder-card">
<strong>Качество речи</strong>
<p>Блок будет строиться поверх скоринга транскриптов, QA-правил и расширенных метаданных записей.</p>
</article>
${
state.analytics.queueAccessError
? `<article class="analytics-placeholder-card">
<strong>Доступ к каталогу очередей</strong>
<p>${escapeHtml(state.analytics.queueAccessError)}</p>
</article>`
: ''
}
</div>
`;
}
function buildTrendBuckets(range, preset, custom) {
const buckets = [];
const hourMs = 60 * 60 * 1000;
const dayMs = 24 * hourMs;
if (custom) {
const fromDay = startOfDay(range.from);
const endDay = startOfDay(addDays(range.to, 1));
const totalDays = Math.max(1, Math.ceil((endDay.getTime() - fromDay.getTime()) / dayMs));
const bucketDays = Math.max(1, Math.ceil(totalDays / 31));
for (let cursor = new Date(fromDay); cursor < endDay; cursor = new Date(cursor.getTime() + bucketDays * dayMs)) {
const bucketFrom = new Date(cursor);
const bucketTo = new Date(Math.min(bucketFrom.getTime() + bucketDays * dayMs, endDay.getTime()));
const lastDay = new Date(bucketTo.getTime() - dayMs);
const label = bucketDays === 1
? bucketFrom.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' })
: `${bucketFrom.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' })} - ${lastDay.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' })}`;
buckets.push({ label, range: { from: bucketFrom, to: bucketTo } });
}
return buckets.slice(0, 31);
}
if (preset === 'today') {
const dayStart = startOfDay(range.from);
for (let hour = 0; hour < 24; hour += 1) {
const bucketFrom = new Date(dayStart.getTime() + hour * hourMs);
const bucketTo = new Date(bucketFrom.getTime() + hourMs);
buckets.push({
label: `${pad2(hour)}:00`,
range: { from: bucketFrom, to: bucketTo },
});
}
return buckets;
}
const days = preset === '30d' ? 30 : 7;
const firstDay = startOfDay(range.from);
for (let day = 0; day < days; day += 1) {
const bucketFrom = new Date(firstDay.getTime() + day * dayMs);
const bucketTo = new Date(bucketFrom.getTime() + dayMs);
buckets.push({
label: bucketFrom.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' }),
range: { from: bucketFrom, to: bucketTo },
});
}
return buckets;
}
async function loadAnalyticsTrend(rangeMeta, requestId) {
const buckets = buildTrendBuckets(rangeMeta.current, rangeMeta.preset, rangeMeta.custom);
const items = await Promise.all(
buckets.map(async (bucket) => {
try {
const payload = await fetchAnalyticsKpi(bucket.range);
return { ...bucket, payload };
} catch {
return { ...bucket, payload: emptyKpiEnvelope(bucket.range.from.toISOString(), bucket.range.to.toISOString()) };
}
}),
);
if (requestId !== state.analytics.requestId) {
return [];
}
return items;
}
async function loadAnalyticsQueueRows(rangeMeta, queues, requestId) {
if (!queues.length) {
return [];
}
const rows = await Promise.all(
queues.map(async (queue) => {
try {
const payload = await fetchAnalyticsKpi(rangeMeta.current, {
queueId: queue.queue_id,
channel: state.analytics.channel,
});
return {
queue_id: queue.queue_id,
name: queue.name || queue.queue_id,
total: Number(payload?.volume?.total || 0),
answered: Number(payload?.volume?.answered || 0),
SL: Number(payload?.kpi?.SL || 0),
Abandon: Number(payload?.kpi?.Abandon || 0),
};
} catch {
return {
queue_id: queue.queue_id,
name: queue.name || queue.queue_id,
total: 0,
answered: 0,
SL: 0,
Abandon: 0,
};
}
}),
);
if (requestId !== state.analytics.requestId) {
return [];
}
return rows.sort((a, b) => b.total - a.total || a.name.localeCompare(b.name));
}
function renderAnalyticsTrendChart() {
const metric = state.analytics.trendMetric || 'volume';
const items = state.analytics.trend || [];
const container = $('analyticsTrendChart');
if (!items.length) {
container.innerHTML = '<div class="empty-state">Нет данных тренда за выбранный период.</div>';
return;
}
const values = items.map((item) => metricValueFromPayload(metric, item.payload));
const maxValue = Math.max(...values, 1);
const width = 720;
const height = 220;
const paddingLeft = 38;
const paddingRight = 16;
const paddingTop = 16;
const paddingBottom = 36;
const chartWidth = width - paddingLeft - paddingRight;
const chartHeight = height - paddingTop - paddingBottom;
const step = items.length > 1 ? chartWidth / (items.length - 1) : chartWidth;
const pointFor = (value, index) => {
const x = paddingLeft + index * step;
const y = paddingTop + chartHeight - (value / maxValue) * chartHeight;
return { x, y };
};
const polyline = values
.map((value, index) => {
const point = pointFor(value, index);
return `${point.x},${point.y}`;
})
.join(' ');
const grid = [0, 0.25, 0.5, 0.75, 1]
.map((ratio) => {
const y = paddingTop + chartHeight - ratio * chartHeight;
const labelValue = maxValue * ratio;
return `
<line x1="${paddingLeft}" y1="${y}" x2="${width - paddingRight}" y2="${y}" class="analytics-grid-line"></line>
<text x="${paddingLeft - 8}" y="${y + 4}" class="analytics-axis-label" text-anchor="end">${escapeHtml(formatAnalyticsNumber(labelValue, metric === 'volume' ? 0 : 1))}</text>
`;
})
.join('');
const markers = values
.map((value, index) => {
const point = pointFor(value, index);
return `<circle cx="${point.x}" cy="${point.y}" r="4" class="analytics-point"></circle>`;
})
.join('');
const labelStep = items.length > 12 ? Math.ceil(items.length / 6) : 1;
const labels = items
.map((item, index) => {
if (index % labelStep !== 0 && index !== items.length - 1) {
return '';
}
const point = pointFor(values[index], index);
return `<text x="${point.x}" y="${height - 10}" class="analytics-axis-label" text-anchor="middle">${escapeHtml(item.label)}</text>`;
})
.join('');
container.innerHTML = `
<div class="analytics-chart-meta">
<strong>${escapeHtml(metric === 'volume' ? 'Volume' : metric)}</strong>
<span>${escapeHtml(formatAnalyticsMetric(metric, values[values.length - 1] || 0))} в последней точке</span>
</div>
<svg viewBox="0 0 ${width} ${height}" class="analytics-chart-svg" role="img" aria-label="Analytics trend chart">
${grid}
<polyline points="${polyline}" class="analytics-line"></polyline>
${markers}
${labels}
</svg>
`;
}
function renderAnalyticsEmptyState() {
const box = $('analyticsEmptyState');
if (state.analytics.error) {
box.hidden = false;
box.textContent = `Не удалось загрузить аналитику: ${state.analytics.error}`;
return;
}
const total = Number(state.analytics.overview?.volume?.total || 0);
if (!state.analytics.loading && total === 0) {
box.hidden = false;
box.textContent = 'За выбранный период данных пока нет. Фильтры сохранены, а заглушки остаются видимыми.';
return;
}
box.hidden = true;
box.textContent = '';
}
function renderAnalyticsDashboard(rangeMeta = analyticsRangeFromControls()) {
$('analyticsRefreshBtn').disabled = state.analytics.loading;
renderAnalyticsQueueOptions();
updateAnalyticsRangeHint(rangeMeta);
renderAnalyticsOperationsStrip();
renderAnalyticsOverview();
renderAnalyticsTrendChart();
renderAnalyticsChannelTable();
renderAnalyticsQueueTable();
renderAnalyticsCoverage();
renderAnalyticsEmptyState();
}
async function loadAnalyticsDashboard(silent = false) {
syncAnalyticsStateFromControls();
const rangeMeta = analyticsRangeFromControls();
const requestId = state.analytics.requestId + 1;
state.analytics.requestId = requestId;
state.analytics.loading = true;
state.analytics.error = '';
renderAnalyticsDashboard(rangeMeta);
try {
const queuePromise = loadAnalyticsQueueOptions();
const [current, previous, coverage, realtime, liveCalls, queues] = await Promise.all([
fetchAnalyticsKpi(rangeMeta.current),
fetchAnalyticsKpi(rangeMeta.previous).catch(() => emptyKpiEnvelope()),
api('reporting', 'reports/coverage').catch(() => ({ implemented_metrics: [], dimensions: [], supported_filters: [] })),
api('supervisor', 'supervisor/realtime').catch(() => ({ agents: { total: 0, by_state: {} }, queues: [] })),
api('supervisor', 'supervisor/live-calls?limit=20').catch(() => ({ items: [] })),
queuePromise,
]);
if (requestId !== state.analytics.requestId) {
return;
}
state.analytics.overview = current || emptyKpiEnvelope(rangeMeta.current.from.toISOString(), rangeMeta.current.to.toISOString());
state.analytics.compare = previous || emptyKpiEnvelope(rangeMeta.previous.from.toISOString(), rangeMeta.previous.to.toISOString());
state.analytics.coverage = coverage || { implemented_metrics: [], dimensions: [], supported_filters: [] };
state.analytics.realtime = realtime || { agents: { total: 0, by_state: {} }, queues: [] };
state.analytics.liveCallsCount = Array.isArray(liveCalls?.items) ? liveCalls.items.length : 0;
const byChannel = current?.breakdowns?.by_channel || {};
state.analytics.channelRows = Object.entries(byChannel)
.map(([channel, payload]) => ({
channel,
total: Number(payload?.total || 0),
answered: Number(payload?.answered || 0),
abandoned: Number(payload?.abandoned || 0),
}))
.sort((a, b) => b.total - a.total || a.channel.localeCompare(b.channel));
const [trend, queueRows] = await Promise.all([
loadAnalyticsTrend(rangeMeta, requestId),
loadAnalyticsQueueRows(rangeMeta, queues, requestId),
]);
if (requestId !== state.analytics.requestId) {
return;
}
state.analytics.trend = trend;
state.analytics.queueRows = queueRows;
state.analytics.loading = false;
renderAnalyticsDashboard(rangeMeta);
if (!silent) {
log('Analytics dashboard updated', {
total: current?.volume?.total || 0,
queues: queueRows.length,
trend_points: trend.length,
});
}
} catch (err) {
if (requestId !== state.analytics.requestId) {
return;
}
state.analytics.loading = false;
state.analytics.error = err.message;
state.analytics.overview = emptyKpiEnvelope();
state.analytics.compare = emptyKpiEnvelope();
state.analytics.trend = [];
state.analytics.channelRows = [];
state.analytics.queueRows = [];
renderAnalyticsDashboard(rangeMeta);
if (!silent) {
log('Не удалось обновить аналитическую витрину', { error: err.message });
}
}
}
function handleAnalyticsQueueClick(event) {
const button = event.target.closest('[data-analytics-queue-id]');
if (!button) {
return;
}
const queueId = button.dataset.analyticsQueueId || 'all';
state.analytics.queueId = queueId;
$('analyticsQueue').value = queueId;
$('queueMetricId').value = queueId;
void loadAnalyticsDashboard();
}
function formatTime(ts) {
if (!ts) {
return '-';
@@ -1290,11 +513,7 @@ async function loadKnowledgeCategories(silent = false) {
async function loadKnowledgeArticles(silent = false) {
const query = $('kbSearchQuery').value.trim();
const language = $('kbSearchLanguage') ? $('kbSearchLanguage').value.trim() : '';
const params = new URLSearchParams({ q: query, limit: '100' });
if (language) {
params.set('language', language);
}
const data = await api('kb', `knowledge/search?${params.toString()}`);
state.knowledge.articles = Array.isArray(data) ? data : [];
state.knowledge.lastQuery = query;