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('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
function renderSummaryCard(label, value, note = '') {
return `
${label}
${value}
${note ? `
${note}
` : ''}
`;
}
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 = [''];
if (selected !== 'all' && !state.analytics.queueOptions.some((item) => item.queue_id === selected)) {
options.push(``);
}
options.push(
...state.analytics.queueOptions.map(
(item) => ``,
),
);
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 `
${formatAnalyticsMetric(metric, value)}
${note ? `
${note}
` : ''}
`;
}
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 = 'Нет данных по каналам за выбранный период.
';
return;
}
container.innerHTML = `
Канал
Всего
Обработано
Потеряно
Доля ответа
${rows
.map((item) => {
const answerRate = item.total ? (item.answered / item.total) * 100 : 0;
return `
${escapeHtml(analyticsChannelLabel(item.channel))}
${formatAnalyticsNumber(item.total, 0)}
${formatAnalyticsNumber(item.answered, 0)}
${formatAnalyticsNumber(item.abandoned, 0)}
${formatAnalyticsNumber(answerRate, 2)}%
`;
})
.join('')}
`;
}
function renderAnalyticsQueueTable() {
const container = $('analyticsQueueTable');
const rows = state.analytics.queueRows || [];
if (!rows.length) {
const reason = state.analytics.queueAccessError
? 'Таблица очередей недоступна для текущей роли: backend не разрешил получить каталог очередей.'
: 'Очереди пока не вернули исторические данные за выбранное окно.';
container.innerHTML = `${escapeHtml(reason)}
`;
return;
}
container.innerHTML = `
Очередь
Всего
Обработано
SL
Потери
${rows
.map((item) => `
`)
.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 = `
Что уже есть
${implemented.length
? implemented
.map((item) => `${escapeHtml(item.code)} · ${escapeHtml(item.label)}`)
.join('')
: 'Сервис coverage пока не вернул каталог метрик.'}
Разрезы
${dimensions.length
? dimensions.map((item) => `${escapeHtml(item)}`).join('')
: 'Нет данных о разрезах.'}
Фильтры
${filters.length
? filters.map((item) => `${escapeHtml(item)}`).join('')
: 'Нет данных о фильтрах.'}
Аналитика по агентам
Появится, когда reporting начнёт отдавать детализацию по agent_id и персональные ряды производительности.
AI-аналитика
Ждём отдельные AI-метрики: долю закрытий, передачу оператору, задержку ответа и покрытие по AI-сводкам.
Качество речи
Блок будет строиться поверх скоринга транскриптов, QA-правил и расширенных метаданных записей.
${
state.analytics.queueAccessError
? `
Доступ к каталогу очередей
${escapeHtml(state.analytics.queueAccessError)}
`
: ''
}
`;
}
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 = 'Нет данных тренда за выбранный период.
';
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 `
${escapeHtml(formatAnalyticsNumber(labelValue, metric === 'volume' ? 0 : 1))}
`;
})
.join('');
const markers = values
.map((value, index) => {
const point = pointFor(value, index);
return ``;
})
.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 `${escapeHtml(item.label)}`;
})
.join('');
container.innerHTML = `
${escapeHtml(metric === 'volume' ? 'Volume' : metric)}
${escapeHtml(formatAnalyticsMetric(metric, values[values.length - 1] || 0))} в последней точке
`;
}
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 = 'Новых звонков от Asterisk пока нет.';
return;
}
$('liveCallsList').innerHTML = items
.map(
(item) => `
${item.call_id}
обращение: ${item.interaction_id || '-'}
статус: ${item.status}
очередь: ${item.queue_id || '-'}
начат: ${formatTime(item.started_at)}
завершён: ${formatTime(item.ended_at)}
запись: ${item.has_recording ? 'да' : 'нет'}
`,
)
.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 = '';
select.value = '';
return;
}
select.innerHTML = state.knowledge.categories
.map((item) => ``)
.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 = 'Категории базы знаний пока не созданы.';
return;
}
$('kbCategoriesList').innerHTML = state.knowledge.categories
.map(
(item) => `
${escapeHtml(item.name)}
ID: ${escapeHtml(item.category_id)}
описание: ${escapeHtml(item.description || '—')}
`,
)
.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 = 'Статьи базы знаний не найдены.';
return;
}
$('kbArticlesList').innerHTML = state.knowledge.articles
.map((item) => {
const tags = Array.isArray(item.tags) && item.tags.length ? item.tags.join(', ') : '—';
return `
${escapeHtml(item.title)}
категория: ${escapeHtml(kbCategoryName(item.category_id))}
язык: ${escapeHtml(kbLanguageLabel(item.language))}
ID группы: ${escapeHtml(item.article_group_id || item.article_id)}
теги: ${escapeHtml(tags)}
`;
})
.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 = 'Не удалось загрузить категории базы знаний.';
$('kbArticlesList').innerHTML = 'Не удалось загрузить статьи базы знаний.';
$('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 = 'Не удалось загрузить звонки.';
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 = 'Агенты пока не зафиксированы.';
return;
}
$('agentsList').innerHTML = data
.map((item) => `${item.agent_id}
статус: ${item.state}
очередь: ${item.queue_id || 'не указана'}`)
.join('');
log('Список агентов обновлён', { count: data.length });
} catch (err) {
$('agentsList').innerHTML = 'Не удалось загрузить агентов.';
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 = 'Записи не найдены.';
setRecordingPreviewEmpty('По текущим фильтрам записи не найдены.');
return;
}
$('recordingsList').innerHTML = data
.map(
(item) => `
${item.recording_id}
звонок: ${item.call_id}
файл: ${item.file_name}
статус: ${item.status}
`,
)
.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 = 'Не удалось загрузить записи.';
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();