const state = {
user: 'supervisor',
role: 'supervisor',
token: null,
authSource: 'local',
fullName: null,
selectedRecordingId: null,
recordingPreviewUrl: null,
logLines: [],
knowledge: {
categories: [],
articles: [],
selectedArticleId: '',
lastQuery: '',
},
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: 'Аналитик',
};
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 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 params = new URLSearchParams({ q: query, limit: '100' });
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 });
}
}
const AGENT_STATUS_LABELS = {
AVAILABLE: 'Свободен',
RESERVED: 'Зарезервирован',
RINGING: 'Звонок идёт',
TALKING: 'На линии',
AFTER_CALL_WORK: 'Постобработка',
PAUSED: 'Пауза',
OFFLINE: 'Offline',
};
const AGENT_STATUS_TONE = {
AVAILABLE: 'is-success',
RESERVED: 'is-warning',
RINGING: 'is-warning',
TALKING: 'is-success',
AFTER_CALL_WORK: 'is-warning',
PAUSED: 'is-muted',
OFFLINE: 'is-danger',
};
const ESCALATION_STATUS_LABELS = {
requested: 'Запрошена',
ringing: 'Дозвон оператора',
completed: 'Завершена',
failed: 'Не удалась',
};
const ESCALATION_STATUS_TONE = {
requested: 'is-warning',
ringing: 'is-warning',
completed: 'is-success',
failed: 'is-danger',
};
function statusBadge(labels, tones, value) {
const label = labels[value] || value || 'неизвестно';
const tone = tones[value] || 'is-muted';
return `${escapeHtml(label)}`;
}
function renderCallAgents(items) {
const byStatus = {};
items.forEach((item) => {
byStatus[item.status] = (byStatus[item.status] || 0) + 1;
});
$('callAgentsSummary').innerHTML = [
renderSummaryCard('Всего в пуле', items.length, 'Операторы L2/L3'),
renderSummaryCard('Свободны', byStatus.AVAILABLE || 0, 'Могут принять звонок сейчас'),
renderSummaryCard('На линии', (byStatus.TALKING || 0) + (byStatus.RINGING || 0) + (byStatus.RESERVED || 0), 'Заняты звонком или дозвоном'),
renderSummaryCard('Offline', byStatus.OFFLINE || 0, 'Недоступны'),
].join('');
if (!items.length) {
$('callAgentsList').innerHTML = 'Пул пуст. Добавьте оператора ниже.';
return;
}
$('callAgentsList').innerHTML = items
.map(
(item) => `
${escapeHtml(item.display_name)} · вн. ${escapeHtml(item.extension)}
${statusBadge(AGENT_STATUS_LABELS, AGENT_STATUS_TONE, item.status)}
${escapeHtml(item.level)}
${item.enabled ? '' : 'отключён'}
tenant: ${escapeHtml((item.tenant_ids || []).join(', ') || '-')}
навыки: ${escapeHtml((item.skills || []).join(', ') || '-')}
принято звонков: ${Number(item.calls_handled_count || 0)}
${item.current_call_id ? `текущий звонок: ${escapeHtml(item.current_call_id)}` : ''}
`,
)
.join('');
}
async function loadCallAgents(silent = false) {
try {
const data = await api('routing', 'agents');
renderCallAgents(Array.isArray(data) ? data : []);
if (!silent) {
log('Пул L2-операторов обновлён', { count: data.length });
}
} catch (err) {
$('callAgentsList').innerHTML = 'Не удалось загрузить пул операторов.';
if (!silent) {
log('Не удалось загрузить пул операторов', { error: err.message });
}
}
}
async function createCallAgent() {
try {
const extension = $('newAgentExtension').value.trim();
if (!extension) {
log('Укажите внутренний номер оператора');
return;
}
const skills = $('newAgentSkills').value
.split(',')
.map((item) => item.trim())
.filter(Boolean);
const tenantId = $('newAgentTenant').value.trim();
const data = await api('routing', 'agents', {
method: 'POST',
body: JSON.stringify({
extension,
display_name: $('newAgentDisplayName').value.trim() || `Оператор ${extension}`,
level: $('newAgentLevel').value,
tenant_ids: tenantId ? [tenantId] : [],
skills,
max_concurrent_calls: 1,
enabled: true,
}),
});
log('Оператор добавлен в пул', { agent_id: data.agent_id, extension: data.extension });
$('newAgentExtension').value = '';
$('newAgentDisplayName').value = '';
$('newAgentSkills').value = '';
await loadCallAgents();
} catch (err) {
log('Не удалось добавить оператора', { error: err.message });
}
}
function renderEscalations(items) {
const byStatus = {};
items.forEach((item) => {
byStatus[item.status] = (byStatus[item.status] || 0) + 1;
});
$('escalationsSummary').innerHTML = [
renderSummaryCard('Активных', (byStatus.requested || 0) + (byStatus.ringing || 0), 'Ждут/дозваниваются до оператора'),
renderSummaryCard('Завершено', byStatus.completed || 0, 'Успешно приняты оператором'),
renderSummaryCard('Не удалось', byStatus.failed || 0, 'Свободного оператора не нашлось'),
].join('');
if (!items.length) {
$('escalationsList').innerHTML = 'Эскалаций пока не было.';
return;
}
$('escalationsList').innerHTML = items
.map(
(item) => `
звонок ${escapeHtml(item.call_id)}
${statusBadge(ESCALATION_STATUS_LABELS, ESCALATION_STATUS_TONE, item.status)}
${escapeHtml(item.from_level)} → ${escapeHtml(item.to_level)}
причина: ${escapeHtml(item.reason_code || '-')}
оператор: ${escapeHtml(item.assigned_agent_id || 'не назначен')}
запрошена: ${formatTime(item.requested_at)}
${item.summary ? `тема: ${escapeHtml(item.summary)}` : ''}
`,
)
.join('');
}
async function loadEscalations(silent = false) {
try {
const data = await api('routing', 'escalations?limit=30');
renderEscalations(Array.isArray(data) ? data : []);
if (!silent) {
log('Лента эскалаций обновлена', { count: data.length });
}
} catch (err) {
$('escalationsList').innerHTML = 'Не удалось загрузить эскалации.';
if (!silent) {
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(),
loadCallAgents(),
loadEscalations(),
loadRecordings(),
refreshKnowledgeBase(true),
]);
log('Данные супервизора обновлены');
});
$('loadRealtimeBtn').addEventListener('click', loadRealtime);
$('loadKpiBtn').addEventListener('click', loadKpi);
$('loadLiveCallsBtn').addEventListener('click', () => loadLiveCalls());
$('loadAgentsBtn').addEventListener('click', loadAgents);
$('setAgentBtn').addEventListener('click', setAgentState);
$('loadCallAgentsBtn').addEventListener('click', () => loadCallAgents());
$('createCallAgentBtn').addEventListener('click', createCallAgent);
$('loadEscalationsBtn').addEventListener('click', () => loadEscalations());
$('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(),
loadCallAgents(true),
loadEscalations(true),
loadRecordings(),
refreshKnowledgeBase(true),
]);
window.setInterval(() => {
void loadLiveCalls(true);
void loadCallAgents(true);
void loadEscalations(true);
}, 5000);
log('Пульт супервизора готов');
}
init();