Files
call-center/ui/supervisor/app.js
T

1809 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const state = {
user: 'supervisor',
role: 'supervisor',
token: null,
authSource: 'local',
fullName: null,
selectedRecordingId: null,
recordingPreviewUrl: null,
logLines: [],
knowledge: {
categories: [],
articles: [],
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',
providerLabel: 'Keycloak',
},
};
const $ = (id) => document.getElementById(id);
const SESSION_STORAGE_KEY = 'cc_session';
const ROLE_LABELS = {
admin: 'Администратор',
supervisor: 'Супервизор',
operator: 'Оператор',
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') {
return '/admin';
}
if (role === 'supervisor') {
return '/supervisor';
}
if (role === 'analyst') {
return '/analyst';
}
return '/operator';
}
function applyRoleNavigation() {
document.querySelectorAll('[data-shell]').forEach((link) => {
const allowed = (link.dataset.roles || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
link.style.display = !allowed.length || allowed.includes(state.role) ? '' : 'none';
});
}
function ensurePageAccess() {
const allowed = ['admin', 'supervisor'];
if (!allowed.includes(state.role)) {
window.location.href = destinationForRole(state.role);
return false;
}
return true;
}
function persistSession() {
window.localStorage.setItem(
SESSION_STORAGE_KEY,
JSON.stringify({
access_token: state.token,
user: state.user,
role: state.role,
auth_source: state.authSource,
full_name: state.fullName,
}),
);
}
function clearStoredSession() {
window.localStorage.removeItem(SESSION_STORAGE_KEY);
}
function restoreStoredSession() {
const raw = window.localStorage.getItem(SESSION_STORAGE_KEY);
if (!raw) {
return false;
}
try {
const payload = JSON.parse(raw);
if (!payload?.access_token) {
clearStoredSession();
return false;
}
state.token = payload.access_token;
state.user = payload.user || state.user;
state.role = payload.role || state.role;
state.authSource = payload.auth_source || 'local';
state.fullName = payload.full_name || null;
$('sessionUser').value = state.user;
$('sessionRole').value = state.role;
if ($('authPanel')) {
$('authPanel').style.display = 'none';
}
updateProfileMeta();
applyRoleNavigation();
return true;
} catch {
clearStoredSession();
return false;
}
}
function logout() {
if (state.recordingPreviewUrl) {
URL.revokeObjectURL(state.recordingPreviewUrl);
state.recordingPreviewUrl = null;
}
clearStoredSession();
window.location.href = '/';
}
function syncSessionFromInputs() {
state.user = $('sessionUser').value.trim() || 'supervisor';
state.role = $('sessionRole').value || 'supervisor';
}
function log(message, payload) {
const ts = new Date().toLocaleTimeString();
const suffix = payload ? ` ${JSON.stringify(payload)}` : '';
state.logLines.unshift(`[${ts}] ${message}${suffix}`);
state.logLines = state.logLines.slice(0, 14);
$('eventLog').textContent = state.logLines.join('\n');
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function renderSummaryCard(label, value, note = '') {
return `
<div class="summary-card">
<div class="summary-label">${label}</div>
<div class="summary-value">${value}</div>
${note ? `<div class="summary-note">${note}</div>` : ''}
</div>
`;
}
function updateProfileMeta() {
const profileName = $('profileName');
const profileRole = $('profileRole');
if (!profileName || !profileRole) {
return;
}
profileName.textContent = state.fullName || state.user || 'Пользователь KonturCC';
profileRole.textContent = ROLE_LABELS[state.role] || state.role || 'Пользователь';
}
function updateSessionInfo(extra = '') {
if (!state.token) {
$('sessionInfo').textContent = 'Токен: отсутствует';
return;
}
const source = state.authSource === 'oidc' ? 'корпоративный' : 'локальный';
$('sessionInfo').textContent = `Токен активен | роль: ${state.role} | вход: ${source}${extra ? ` | ${extra}` : ''}`;
}
function applyAuthenticatedSession(data) {
state.token = data.access_token;
state.role = data.role;
state.user = data.username || state.user;
state.authSource = data.auth_source || 'local';
state.fullName = data.full_name || null;
$('sessionUser').value = state.user;
$('sessionRole').value = data.role;
updateSessionInfo(data.provider ? `провайдер: ${data.provider}` : '');
updateProfileMeta();
persistSession();
applyRoleNavigation();
}
async function api(service, path, options = {}) {
syncSessionFromInputs();
const headers = {
'Content-Type': 'application/json',
'X-User': state.user,
'X-Role': state.role,
...(options.headers || {}),
};
if (state.token) {
headers.Authorization = `Bearer ${state.token}`;
}
const response = await fetch(`/proxy/${service}/${path}`, { ...options, headers });
let data;
try {
data = await response.json();
} catch {
data = { error: 'Сервис вернул ответ не в JSON-формате' };
}
if (!response.ok) {
if (response.status === 401 && state.token) {
state.token = null;
state.authSource = 'local';
clearStoredSession();
updateSessionInfo('требуется повторный вход');
window.location.href = '/';
}
throw new Error(`${response.status}: ${JSON.stringify(data)}`);
}
return data;
}
async function fetchBinary(service, path) {
syncSessionFromInputs();
const headers = {
'X-User': state.user,
'X-Role': state.role,
};
if (state.token) {
headers.Authorization = `Bearer ${state.token}`;
}
const response = await fetch(`/proxy/${service}/${path}`, {
method: 'GET',
headers,
});
if (!response.ok) {
const detail = await response.text();
if (response.status === 401 && state.token) {
state.token = null;
state.authSource = 'local';
clearStoredSession();
updateSessionInfo('требуется повторный вход');
window.location.href = '/';
}
throw new Error(`${response.status}: ${detail}`);
}
return response;
}
async function checkGateway() {
try {
const response = await fetch('/health');
const data = await response.json();
$('gatewayStatus').textContent = `Шлюз: ${data.status === 'ok' ? 'готов' : data.status}`;
} catch {
$('gatewayStatus').textContent = 'Шлюз: недоступен';
}
}
async function loadOidcConfig() {
try {
const response = await fetch('/proxy/auth/auth/oidc/config');
const data = await response.json();
state.oidc.enabled = Boolean(data.enabled);
state.oidc.loginPath = data.login_path || '/auth/oidc/start?return_mode=popup';
state.oidc.providerLabel = data.provider_label || 'Keycloak';
$('corporateLoginBtn').disabled = !state.oidc.enabled;
$('corporateLoginBtn').textContent = state.oidc.enabled
? `Корпоративный вход (${state.oidc.providerLabel})`
: 'Корпоративный вход недоступен';
} catch {
$('corporateLoginBtn').disabled = true;
$('corporateLoginBtn').textContent = 'Корпоративный вход недоступен';
}
}
function startCorporateLogin() {
if (!state.oidc.enabled) {
log('Корпоративный вход недоступен');
return;
}
const popup = window.open(`/proxy/auth${state.oidc.loginPath}`, 'oidc-login', 'width=620,height=760');
if (!popup) {
log('Не удалось открыть окно корпоративного входа');
return;
}
log('Открыт корпоративный вход', { provider: state.oidc.providerLabel });
}
function handleOidcMessage(event) {
if (!event?.data || typeof event.data !== 'object') {
return;
}
if (event.data.type === 'oidc-login' && event.data.access_token) {
applyAuthenticatedSession(event.data);
log('Корпоративный вход выполнен', { user: state.user, role: state.role });
return;
}
if (event.data.type === 'oidc-error') {
log('Корпоративный вход не выполнен', { error: event.data.message || 'неизвестно' });
updateSessionInfo(`ошибка SSO: ${event.data.message || 'неизвестно'}`);
}
}
async function login() {
syncSessionFromInputs();
try {
const data = await api('auth', 'auth/login', {
method: 'POST',
body: JSON.stringify({
username: state.user,
password: $('sessionPassword').value,
}),
});
applyAuthenticatedSession({ ...data, username: state.user, auth_source: data.auth_source || 'local' });
log('Локальный вход выполнен', { user: state.user, role: data.role });
} catch (err) {
$('sessionInfo').textContent = `Ошибка входа: ${err.message}`;
log('Вход не выполнен', { error: err.message });
}
}
function renderRealtime(data) {
const agents = data?.agents || {};
const byState = agents.by_state || {};
const firstQueue = (data?.queues || [])[0] || {};
$('realtimeSummary').innerHTML = [
renderSummaryCard('Агентов', Number(agents.total || 0), 'Текущее состояние команды'),
renderSummaryCard('Готовы', Number(byState.READY || 0), 'Свободны'),
renderSummaryCard('Заняты', Number(byState.BUSY || 0), 'В обработке'),
renderSummaryCard('Очередь', firstQueue.queue_id || 'нет', `В очереди: ${Number(firstQueue.in_queue || 0)}`),
].join('');
$('realtimeOutput').textContent = JSON.stringify(data, null, 2);
}
function renderKpi(data) {
const kpi = data?.kpi || {};
$('kpiSummary').innerHTML = [
renderSummaryCard('SL', `${Number(kpi.SL || 0).toFixed(2)}%`),
renderSummaryCard('ASA', `${Number(kpi.ASA || 0).toFixed(2)} с`),
renderSummaryCard('AHT', `${Number(kpi.AHT || 0).toFixed(2)} с`),
renderSummaryCard('Потери', `${Number(kpi.Abandon || 0).toFixed(2)}%`),
renderSummaryCard('FCR', `${Number(kpi.FCR || 0).toFixed(2)}%`),
].join('');
$('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 '-';
}
const date = new Date(ts);
if (Number.isNaN(date.getTime())) {
return ts;
}
return date.toLocaleString();
}
function renderLiveCalls(items) {
if (!items.length) {
$('liveCallsList').innerHTML = '<li class="empty-state">Новых звонков от Asterisk пока нет.</li>';
return;
}
$('liveCallsList').innerHTML = items
.map(
(item) => `
<li>
<strong>${item.call_id}</strong><br/>
обращение: ${item.interaction_id || '-'}<br/>
статус: ${item.status}<br/>
очередь: ${item.queue_id || '-'}<br/>
начат: ${formatTime(item.started_at)}<br/>
завершён: ${formatTime(item.ended_at)}<br/>
запись: ${item.has_recording ? 'да' : 'нет'}
</li>
`,
)
.join('');
}
function kbCategoryName(categoryId) {
const category = state.knowledge.categories.find((item) => item.category_id === categoryId);
return category?.name || categoryId || 'Без категории';
}
function kbLanguageLabel(language) {
return String(language || 'ru').toUpperCase();
}
function renderKnowledgeSummary() {
const ruCount = state.knowledge.articles.filter((item) => item.language === 'ru').length;
const kzCount = state.knowledge.articles.filter((item) => item.language === 'kz').length;
$('kbSummary').innerHTML = [
renderSummaryCard('Категорий', state.knowledge.categories.length, 'Структура базы знаний'),
renderSummaryCard('Статей', state.knowledge.articles.length, 'Текущий результат поиска'),
renderSummaryCard('RU', ruCount, 'Русские статьи в текущем срезе'),
renderSummaryCard('KZ', kzCount, 'Казахские статьи в текущем срезе'),
].join('');
}
function syncKnowledgeCategoryOptions(preferredCategoryId = '') {
const select = $('kbArticleCategory');
const currentValue = preferredCategoryId || select.value;
if (!state.knowledge.categories.length) {
select.innerHTML = '<option value="">Сначала создайте категорию</option>';
select.value = '';
return;
}
select.innerHTML = state.knowledge.categories
.map((item) => `<option value="${escapeHtml(item.category_id)}">${escapeHtml(item.name)}</option>`)
.join('');
const nextValue = state.knowledge.categories.some((item) => item.category_id === currentValue)
? currentValue
: state.knowledge.categories[0].category_id;
select.value = nextValue;
}
function renderKnowledgeCategories() {
if (!state.knowledge.categories.length) {
$('kbCategoriesList').innerHTML = '<li class="empty-state">Категории базы знаний пока не созданы.</li>';
return;
}
$('kbCategoriesList').innerHTML = state.knowledge.categories
.map(
(item) => `
<li>
<strong>${escapeHtml(item.name)}</strong><br/>
ID: ${escapeHtml(item.category_id)}<br/>
описание: ${escapeHtml(item.description || '—')}
</li>
`,
)
.join('');
}
function renderKnowledgeArticlePreview(article = null) {
$('kbArticlePreview').textContent = article ? JSON.stringify(article, null, 2) : 'Выберите статью из списка ниже.';
}
function resetKnowledgeArticleForm() {
state.knowledge.selectedArticleId = '';
$('kbArticleGroupId').value = '';
$('kbArticleLanguage').value = 'ru';
$('kbArticleTitle').value = '';
$('kbArticleTags').value = '';
$('kbArticleBody').value = '';
$('saveKbArticleBtn').textContent = 'Сохранить статью';
$('kbFormHint').textContent = 'Создайте новую статью или выберите существующую для редактирования.';
syncKnowledgeCategoryOptions();
renderKnowledgeArticlePreview(null);
}
function fillKnowledgeArticleForm(article) {
if (!article) {
resetKnowledgeArticleForm();
return;
}
state.knowledge.selectedArticleId = article.article_id;
$('kbArticleGroupId').value = article.article_group_id || '';
$('kbArticleLanguage').value = article.language || 'ru';
$('kbArticleTitle').value = article.title || '';
$('kbArticleTags').value = Array.isArray(article.tags) ? article.tags.join(', ') : '';
$('kbArticleBody').value = article.body || '';
syncKnowledgeCategoryOptions(article.category_id || '');
$('saveKbArticleBtn').textContent = 'Обновить статью';
$('kbFormHint').textContent = `Редактирование статьи: ${article.title || article.article_id}`;
renderKnowledgeArticlePreview(article);
}
function renderKnowledgeArticles() {
renderKnowledgeSummary();
if (!state.knowledge.articles.length) {
$('kbArticlesList').innerHTML = '<li class="empty-state">Статьи базы знаний не найдены.</li>';
return;
}
$('kbArticlesList').innerHTML = state.knowledge.articles
.map((item) => {
const tags = Array.isArray(item.tags) && item.tags.length ? item.tags.join(', ') : '—';
return `
<li>
<strong>${escapeHtml(item.title)}</strong><br/>
категория: ${escapeHtml(kbCategoryName(item.category_id))}<br/>
язык: ${escapeHtml(kbLanguageLabel(item.language))}<br/>
ID группы: ${escapeHtml(item.article_group_id || item.article_id)}<br/>
теги: ${escapeHtml(tags)}<br/>
<button class="btn ghost" type="button" data-kb-article-id="${escapeHtml(item.article_id)}">Открыть</button>
</li>
`;
})
.join('');
}
function knowledgeArticlePayloadFromForm() {
return {
category_id: $('kbArticleCategory').value.trim(),
article_group_id: $('kbArticleGroupId').value.trim() || null,
language: $('kbArticleLanguage').value.trim() || 'ru',
title: $('kbArticleTitle').value.trim(),
body: $('kbArticleBody').value.trim(),
tags: $('kbArticleTags').value
.split(',')
.map((item) => item.trim())
.filter(Boolean),
};
}
async function loadKnowledgeCategories(silent = false) {
const data = await api('kb', 'knowledge/categories');
state.knowledge.categories = Array.isArray(data) ? data : [];
syncKnowledgeCategoryOptions();
renderKnowledgeCategories();
renderKnowledgeSummary();
if (!silent) {
log('Категории базы знаний обновлены', { count: state.knowledge.categories.length });
}
}
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;
renderKnowledgeArticles();
if (state.knowledge.selectedArticleId) {
const selected = state.knowledge.articles.find((item) => item.article_id === state.knowledge.selectedArticleId);
if (selected) {
renderKnowledgeArticlePreview(selected);
}
}
if (!silent) {
log('Статьи базы знаний обновлены', { count: state.knowledge.articles.length, query });
}
}
async function refreshKnowledgeBase(silent = false) {
try {
await loadKnowledgeCategories(true);
await loadKnowledgeArticles(true);
if (!silent) {
log('База знаний обновлена', {
categories: state.knowledge.categories.length,
articles: state.knowledge.articles.length,
});
}
} catch (err) {
$('kbCategoriesList').innerHTML = '<li class="empty-state">Не удалось загрузить категории базы знаний.</li>';
$('kbArticlesList').innerHTML = '<li class="empty-state">Не удалось загрузить статьи базы знаний.</li>';
$('kbArticlePreview').textContent = `Ошибка базы знаний: ${err.message}`;
if (!silent) {
log('Не удалось обновить базу знаний', { error: err.message });
}
}
}
async function createKnowledgeCategory() {
const name = $('kbCategoryName').value.trim();
const description = $('kbCategoryDescription').value.trim();
if (!name) {
$('kbFormHint').textContent = 'Укажите название категории перед сохранением.';
return;
}
try {
const created = await api('kb', 'knowledge/categories', {
method: 'POST',
body: JSON.stringify({ name, description }),
});
$('kbCategoryName').value = '';
$('kbCategoryDescription').value = '';
await loadKnowledgeCategories(true);
syncKnowledgeCategoryOptions(created.category_id);
renderKnowledgeCategories();
renderKnowledgeSummary();
$('kbFormHint').textContent = `Категория создана: ${created.name}`;
log('Категория базы знаний создана', { category_id: created.category_id, name: created.name });
} catch (err) {
$('kbFormHint').textContent = `Не удалось создать категорию: ${err.message}`;
log('Не удалось создать категорию базы знаний', { error: err.message });
}
}
async function saveKnowledgeArticle() {
const payload = knowledgeArticlePayloadFromForm();
if (!payload.category_id) {
$('kbFormHint').textContent = 'Сначала создайте или выберите категорию.';
return;
}
if (!payload.title || !payload.body) {
$('kbFormHint').textContent = 'Заполните заголовок и текст статьи.';
return;
}
try {
const articleId = state.knowledge.selectedArticleId;
const data = articleId
? await api('kb', `knowledge/articles/${encodeURIComponent(articleId)}`, {
method: 'PATCH',
body: JSON.stringify({
article_group_id: payload.article_group_id,
language: payload.language,
title: payload.title,
body: payload.body,
tags: payload.tags,
}),
})
: await api('kb', 'knowledge/articles', {
method: 'POST',
body: JSON.stringify(payload),
});
await loadKnowledgeArticles(true);
fillKnowledgeArticleForm(data);
$('kbFormHint').textContent = articleId ? `Статья обновлена: ${data.title}` : `Статья создана: ${data.title}`;
log(articleId ? 'Статья базы знаний обновлена' : 'Статья базы знаний создана', { article_id: data.article_id, title: data.title });
} catch (err) {
$('kbFormHint').textContent = `Не удалось сохранить статью: ${err.message}`;
log('Не удалось сохранить статью базы знаний', { error: err.message });
}
}
function openKnowledgeArticleFromList(event) {
const button = event.target.closest('[data-kb-article-id]');
if (!button) {
return;
}
const articleId = button.dataset.kbArticleId || '';
const article = state.knowledge.articles.find((item) => item.article_id === articleId);
if (!article) {
return;
}
fillKnowledgeArticleForm(article);
}
async function loadLiveCalls(silent = false) {
try {
const data = await api('supervisor', 'supervisor/live-calls?limit=20');
const items = Array.isArray(data?.items) ? data.items : [];
renderLiveCalls(items);
if (!silent) {
log('Лента звонков обновлена', { count: items.length });
}
} catch (err) {
$('liveCallsList').innerHTML = '<li class="empty-state">Не удалось загрузить звонки.</li>';
if (!silent) {
log('Не удалось обновить ленту звонков', { error: err.message });
}
}
}
async function loadRealtime() {
try {
const data = await api('supervisor', 'supervisor/realtime');
renderRealtime(data);
log('Оперативный срез обновлён', { agents: data?.agents?.total || 0 });
} catch (err) {
$('realtimeOutput').textContent = err.message;
log('Не удалось обновить оперативный срез', { error: err.message });
}
}
async function loadKpi() {
try {
const queueId = encodeURIComponent($('queueMetricId').value.trim() || 'line2');
const data = await api('reporting', `reports/kpi?queue_id=${queueId}`);
renderKpi(data);
log('KPI обновлены', data?.kpi || {});
} catch (err) {
$('kpiOutput').textContent = err.message;
log('Не удалось обновить KPI', { error: err.message });
}
}
async function loadAgents() {
try {
const data = await api('supervisor', 'supervisor/agents');
if (!data.length) {
$('agentsList').innerHTML = '<li class="empty-state">Агенты пока не зафиксированы.</li>';
return;
}
$('agentsList').innerHTML = data
.map((item) => `<li><strong>${item.agent_id}</strong><br/>статус: ${item.state}<br/>очередь: ${item.queue_id || 'не указана'}</li>`)
.join('');
log('Список агентов обновлён', { count: data.length });
} catch (err) {
$('agentsList').innerHTML = '<li class="empty-state">Не удалось загрузить агентов.</li>';
log('Не удалось загрузить агентов', { error: err.message });
}
}
async function setAgentState() {
try {
const data = await api('supervisor', 'supervisor/agent-states', {
method: 'POST',
body: JSON.stringify({
agent_id: $('agentId').value.trim() || 'supervisor_demo_agent',
state: $('agentState').value,
queue_id: $('agentQueueId').value.trim() || null,
}),
});
log('Статус агента обновлён', { agent_id: data.agent_id, state: data.state });
await Promise.all([loadAgents(), loadRealtime()]);
} catch (err) {
log('Не удалось обновить статус агента', { error: err.message });
}
}
async function saveQueueMetrics() {
try {
const queueId = encodeURIComponent($('queueMetricId').value.trim() || 'line2');
const inQueue = Number($('queueInQueue').value || 0);
const avgWait = Number($('queueAvgWait').value || 0);
const data = await api(
'supervisor',
`supervisor/queue-metrics?queue_id=${queueId}&in_queue=${inQueue}&avg_wait_seconds=${avgWait}`,
{ method: 'POST' },
);
$('queueMetricsOutput').textContent = JSON.stringify(data, null, 2);
log('Показатели очереди обновлены', { queue_id: data.queue_id, in_queue: data.in_queue });
await Promise.all([loadRealtime(), loadKpi()]);
} catch (err) {
$('queueMetricsOutput').textContent = err.message;
log('Не удалось обновить очередь', { error: err.message });
}
}
function setRecordingPreviewEmpty(message) {
$('recordingMetaOutput').textContent = message || '';
$('recordingSelectionHint').textContent = message || 'Выберите запись для предпросмотра.';
$('recordingPlayer').removeAttribute('src');
$('recordingPlayer').load();
$('downloadRecordingBtn').disabled = true;
state.selectedRecordingId = null;
if (state.recordingPreviewUrl) {
URL.revokeObjectURL(state.recordingPreviewUrl);
state.recordingPreviewUrl = null;
}
}
function renderRecordingsList(data) {
if (!data.length) {
$('recordingsList').innerHTML = '<li class="empty-state">Записи не найдены.</li>';
setRecordingPreviewEmpty('По текущим фильтрам записи не найдены.');
return;
}
$('recordingsList').innerHTML = data
.map(
(item) => `
<li>
<strong>${item.recording_id}</strong><br/>
звонок: ${item.call_id}<br/>
файл: ${item.file_name}<br/>
статус: ${item.status}
<div class="actions compact-actions">
<button type="button" class="btn ghost recording-open-btn" data-recording-id="${item.recording_id}">Открыть</button>
</div>
</li>
`,
)
.join('');
document.querySelectorAll('.recording-open-btn').forEach((button) => {
button.addEventListener('click', () => {
void openRecording(button.dataset.recordingId || '');
});
});
}
function renderRecordingMeta(data) {
$('recordingMetaOutput').textContent = JSON.stringify(data, null, 2);
$('recordingSelectionHint').textContent = `Выбрано: ${data.recording_id} | статус: ${data.status}`;
$('downloadRecordingBtn').disabled = false;
}
async function loadRecordings() {
try {
const params = new URLSearchParams();
const callId = $('recordingCallId').value.trim();
const interactionId = $('recordingInteractionId').value.trim();
const status = $('recordingStatus').value;
if (callId) {
params.set('call_id', callId);
}
if (interactionId) {
params.set('interaction_id', interactionId);
}
if (status) {
params.set('status', status);
}
params.set('limit', '50');
const suffix = params.toString() ? `?${params.toString()}` : '';
const data = await api('recording', `recordings${suffix}`);
renderRecordingsList(data);
log('Список записей обновлён', { count: data.length });
} catch (err) {
$('recordingsList').innerHTML = '<li class="empty-state">Не удалось загрузить записи.</li>';
setRecordingPreviewEmpty(`Ошибка: ${err.message}`);
log('Не удалось загрузить записи', { error: err.message });
}
}
async function loadRecordingPreview(recordingId) {
const response = await fetchBinary('recording', `recordings/${encodeURIComponent(recordingId)}/content?disposition=inline`);
const blob = await response.blob();
if (state.recordingPreviewUrl) {
URL.revokeObjectURL(state.recordingPreviewUrl);
}
state.recordingPreviewUrl = URL.createObjectURL(blob);
$('recordingPlayer').src = state.recordingPreviewUrl;
$('recordingPlayer').load();
}
async function openRecording(recordingId) {
if (!recordingId) {
return;
}
try {
const data = await api('recording', `recordings/${encodeURIComponent(recordingId)}`);
state.selectedRecordingId = recordingId;
renderRecordingMeta(data);
await loadRecordingPreview(recordingId);
log('Запись открыта', { recording_id: recordingId });
} catch (err) {
setRecordingPreviewEmpty(`Не удалось открыть запись: ${err.message}`);
log('Не удалось открыть запись', { error: err.message });
}
}
async function resolveRecordingEvent(rawValue) {
const value = (rawValue || '').trim();
if (!value) {
return null;
}
const events = await api('voice', 'integrations/voice/events?limit=300');
if (value.startsWith('vev_')) {
const direct = events.find((item) => item.event_id === value) || null;
return direct ? { eventId: value, event: direct } : { eventId: value, event: null };
}
const recordingEvents = events.filter((item) => item.event_type === 'recording.ready');
for (let i = recordingEvents.length - 1; i >= 0; i -= 1) {
const item = recordingEvents[i];
if (item.interaction_id === value || item.call_id === value) {
return { eventId: item.event_id, event: item };
}
}
return null;
}
async function resolveVoiceContext(rawValue) {
const value = (rawValue || '').trim();
if (!value) {
return null;
}
const events = await api('voice', 'integrations/voice/events?limit=300');
const matches = events.filter(
(item) => item.event_id === value || item.interaction_id === value || item.call_id === value,
);
if (!matches.length) {
return null;
}
return matches[matches.length - 1];
}
async function importRecording() {
const rawInput = $('recordingEventId').value.trim();
if (!rawInput) {
setRecordingPreviewEmpty('Укажите ID voice_event, обращения или звонка перед импортом.');
return;
}
try {
const resolved = await resolveRecordingEvent(rawInput);
if (!resolved?.eventId) {
const ctx = await resolveVoiceContext(rawInput);
const callId = ctx?.call_id || '';
const interactionId = ctx?.interaction_id || '';
if (callId) {
$('recordingCallId').value = callId;
}
if (interactionId) {
$('recordingInteractionId').value = interactionId;
}
if (callId) {
const existing = await api('recording', `recordings?call_id=${encodeURIComponent(callId)}&limit=10`);
if (Array.isArray(existing) && existing.length) {
await loadRecordings();
await openRecording(existing[0].recording_id);
log('Запись уже импортирована автоматически', { call_id: callId, recording_id: existing[0].recording_id });
return;
}
}
const reason = ctx?.event_type === 'call.started'
? 'Звонок ещё активен или только что завершился. Подождите 5-15 секунд до события recording.ready.'
: `Событие recording.ready для "${rawInput}" не найдено.`;
setRecordingPreviewEmpty(reason);
log('Импорт записи не выполнен', { error: reason, input: rawInput });
return;
}
const { eventId, event } = resolved;
if (eventId !== rawInput) {
$('recordingEventId').value = eventId;
log('Событие записи найдено автоматически', { input: rawInput, event_id: eventId });
}
if (event?.payload?.source === 'asterisk' && !event?.payload?.source_path) {
$('recordingCallId').value = event.call_id || '';
$('recordingInteractionId').value = event.interaction_id || '';
await loadRecordings();
setRecordingPreviewEmpty(
'Записи Asterisk импортируются автоматически. Используйте список ниже и откройте последнюю запись.',
);
log('Ручной импорт пропущен для события Asterisk', { event_id: eventId, call_id: event.call_id });
return;
}
const data = await api('recording', `recordings/import-from-voice-event/${encodeURIComponent(eventId)}`, {
method: 'POST',
});
$('recordingCallId').value = data.call_id;
$('recordingInteractionId').value = data.interaction_id || '';
await loadRecordings();
await openRecording(data.recording_id);
log('Запись импортирована', { recording_id: data.recording_id, event_id: eventId });
} catch (err) {
setRecordingPreviewEmpty(`Импорт не выполнен: ${err.message}`);
log('Импорт записи не выполнен', { error: err.message });
}
}
async function archiveRecording() {
if (!state.selectedRecordingId) {
setRecordingPreviewEmpty('Сначала откройте запись.');
return;
}
try {
const data = await api('recording', `recordings/${encodeURIComponent(state.selectedRecordingId)}/archive`, {
method: 'POST',
});
renderRecordingMeta(data);
await loadRecordings();
log('Запись отправлена в архив', { recording_id: data.recording_id });
} catch (err) {
log('Не удалось отправить запись в архив', { error: err.message });
}
}
async function downloadRecording() {
if (!state.selectedRecordingId) {
setRecordingPreviewEmpty('Сначала откройте запись.');
return;
}
try {
const response = await fetchBinary(
'recording',
`recordings/${encodeURIComponent(state.selectedRecordingId)}/content?disposition=attachment`,
);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const contentDisposition = response.headers.get('Content-Disposition') || '';
const fileNameMatch = /filename=\"?([^\";]+)\"?/i.exec(contentDisposition);
link.href = url;
link.download = fileNameMatch?.[1] || `${state.selectedRecordingId}.wav`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
log('Запись скачана', { recording_id: state.selectedRecordingId });
} catch (err) {
log('Не удалось скачать запись', { error: err.message });
}
}
function wire() {
$('loginBtn').addEventListener('click', login);
$('corporateLoginBtn').addEventListener('click', startCorporateLogin);
$('refreshBtn').addEventListener('click', async () => {
await Promise.all([loadRealtime(), loadKpi(), loadLiveCalls(), loadAgents(), loadRecordings(), refreshKnowledgeBase(true)]);
log('Данные супервизора обновлены');
});
$('loadRealtimeBtn').addEventListener('click', loadRealtime);
$('loadKpiBtn').addEventListener('click', loadKpi);
$('loadLiveCallsBtn').addEventListener('click', () => loadLiveCalls());
$('loadAgentsBtn').addEventListener('click', loadAgents);
$('setAgentBtn').addEventListener('click', setAgentState);
$('saveQueueMetricsBtn').addEventListener('click', saveQueueMetrics);
$('createKbCategoryBtn').addEventListener('click', createKnowledgeCategory);
$('loadKbBtn').addEventListener('click', () => refreshKnowledgeBase());
$('searchKbBtn').addEventListener('click', () => loadKnowledgeArticles());
$('saveKbArticleBtn').addEventListener('click', saveKnowledgeArticle);
$('resetKbArticleBtn').addEventListener('click', resetKnowledgeArticleForm);
$('kbArticlesList').addEventListener('click', openKnowledgeArticleFromList);
$('importRecordingBtn').addEventListener('click', importRecording);
$('loadRecordingsBtn').addEventListener('click', loadRecordings);
$('archiveRecordingBtn').addEventListener('click', archiveRecording);
$('downloadRecordingBtn').addEventListener('click', downloadRecording);
$('logoutBtn').addEventListener('click', logout);
window.addEventListener('message', handleOidcMessage);
}
async function init() {
if (!restoreStoredSession()) {
window.location.href = '/';
return;
}
if (!ensurePageAccess()) {
return;
}
wire();
updateSessionInfo();
setRecordingPreviewEmpty('');
resetKnowledgeArticleForm();
await Promise.all([
checkGateway(),
loadOidcConfig(),
loadRealtime(),
loadKpi(),
loadLiveCalls(true),
loadAgents(),
loadRecordings(),
refreshKnowledgeBase(true),
]);
window.setInterval(() => {
void loadLiveCalls(true);
}, 5000);
log('Пульт супервизора готов');
}
init();