Files
call-center/ui/supervisor/app.js
T
Hermes Agent 1ad4e1ec6e feat: surface L2 agent pool and escalations on supervisor screen
- routing-service: GET /escalations (list recent escalation attempts)
- supervisor UI: new panel showing the real agent pool (status, level,
  tenant, skills, calls handled) fed by GET /agents, with a form to add
  operators to the pool
- supervisor UI: new live escalation feed (AI->L2 handoffs, status,
  assigned operator), auto-refreshed every 5s alongside live calls
2026-08-29 13:28:14 +05:00

1216 lines
44 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: '',
},
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('<', '&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 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 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 = '<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 });
}
}
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 `<span class="status-badge ${tone}">${escapeHtml(label)}</span>`;
}
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 = '<li class="empty-state">Пул пуст. Добавьте оператора ниже.</li>';
return;
}
$('callAgentsList').innerHTML = items
.map(
(item) => `
<li class="agent-pool-item">
<strong>${escapeHtml(item.display_name)} · вн. ${escapeHtml(item.extension)}</strong>
${statusBadge(AGENT_STATUS_LABELS, AGENT_STATUS_TONE, item.status)}
<span class="status-badge is-muted">${escapeHtml(item.level)}</span>
${item.enabled ? '' : '<span class="status-badge is-danger">отключён</span>'}
<span class="agent-pool-meta">tenant: ${escapeHtml((item.tenant_ids || []).join(', ') || '-')}</span>
<span class="agent-pool-meta">навыки: ${escapeHtml((item.skills || []).join(', ') || '-')}</span>
<span class="agent-pool-meta">принято звонков: ${Number(item.calls_handled_count || 0)}</span>
${item.current_call_id ? `<span class="agent-pool-meta">текущий звонок: ${escapeHtml(item.current_call_id)}</span>` : ''}
</li>
`,
)
.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 = '<li class="empty-state">Не удалось загрузить пул операторов.</li>';
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 = '<li class="empty-state">Эскалаций пока не было.</li>';
return;
}
$('escalationsList').innerHTML = items
.map(
(item) => `
<li class="escalation-item">
<strong>звонок ${escapeHtml(item.call_id)}</strong>
${statusBadge(ESCALATION_STATUS_LABELS, ESCALATION_STATUS_TONE, item.status)}
<span class="status-badge is-muted">${escapeHtml(item.from_level)}${escapeHtml(item.to_level)}</span>
<span class="escalation-meta">причина: ${escapeHtml(item.reason_code || '-')}</span>
<span class="escalation-meta">оператор: ${escapeHtml(item.assigned_agent_id || 'не назначен')}</span>
<span class="escalation-meta">запрошена: ${formatTime(item.requested_at)}</span>
${item.summary ? `<span class="escalation-meta">тема: ${escapeHtml(item.summary)}</span>` : ''}
</li>
`,
)
.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 = '<li class="empty-state">Не удалось загрузить эскалации.</li>';
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 = '<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(),
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();