chore: remove dead supervisor analytics and archived tooling
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user