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 }); } } 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();