1809 lines
65 KiB
JavaScript
1809 lines
65 KiB
JavaScript
const state = {
|
||
user: 'supervisor',
|
||
role: 'supervisor',
|
||
token: null,
|
||
authSource: 'local',
|
||
fullName: null,
|
||
selectedRecordingId: null,
|
||
recordingPreviewUrl: null,
|
||
logLines: [],
|
||
knowledge: {
|
||
categories: [],
|
||
articles: [],
|
||
selectedArticleId: '',
|
||
lastQuery: '',
|
||
},
|
||
analytics: {
|
||
preset: '7d',
|
||
fromTs: '',
|
||
toTs: '',
|
||
queueId: 'all',
|
||
channel: 'all',
|
||
trendMetric: 'volume',
|
||
overview: null,
|
||
compare: null,
|
||
trend: [],
|
||
channelRows: [],
|
||
queueRows: [],
|
||
coverage: null,
|
||
queueOptions: [],
|
||
realtime: null,
|
||
liveCallsCount: 0,
|
||
loading: false,
|
||
error: '',
|
||
queueAccessError: '',
|
||
requestId: 0,
|
||
},
|
||
oidc: {
|
||
enabled: false,
|
||
loginPath: '/auth/oidc/start?return_mode=popup',
|
||
providerLabel: 'Keycloak',
|
||
},
|
||
};
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const SESSION_STORAGE_KEY = 'cc_session';
|
||
const ROLE_LABELS = {
|
||
admin: 'Администратор',
|
||
supervisor: 'Супервизор',
|
||
operator: 'Оператор',
|
||
analyst: 'Аналитик',
|
||
};
|
||
|
||
const ANALYTICS_CHANNEL_OPTIONS = [
|
||
{ value: 'all', label: 'Все каналы' },
|
||
{ value: 'voice', label: 'Голос' },
|
||
{ value: 'telegram', label: 'Telegram' },
|
||
{ value: 'whatsapp', label: 'WhatsApp' },
|
||
{ value: 'webchat', label: 'Веб-чат' },
|
||
{ value: 'email', label: 'Электронная почта' },
|
||
];
|
||
|
||
const ANALYTICS_METRIC_META = {
|
||
total: { unit: 'count', better: 'up', note: 'Всего обращений в окне' },
|
||
answered: { unit: 'count', better: 'up', note: 'Обработано командой' },
|
||
SL: { unit: 'pct', better: 'up', note: 'Доля в SLA' },
|
||
ASA: { unit: 'seconds', better: 'down', note: 'Среднее ожидание' },
|
||
AHT: { unit: 'seconds', better: 'down', note: 'Среднее время обработки' },
|
||
Abandon: { unit: 'pct', better: 'down', note: 'Потерянные обращения' },
|
||
FCR: { unit: 'pct', better: 'up', note: 'Решено с первого контакта' },
|
||
DigitalShare: { unit: 'pct', better: 'neutral', note: 'Доля цифровых каналов' },
|
||
volume: { unit: 'count', better: 'up', note: 'Объём по временной точке' },
|
||
};
|
||
|
||
function destinationForRole(role) {
|
||
if (role === 'admin') {
|
||
return '/admin';
|
||
}
|
||
if (role === 'supervisor') {
|
||
return '/supervisor';
|
||
}
|
||
if (role === 'analyst') {
|
||
return '/analyst';
|
||
}
|
||
return '/operator';
|
||
}
|
||
|
||
function applyRoleNavigation() {
|
||
document.querySelectorAll('[data-shell]').forEach((link) => {
|
||
const allowed = (link.dataset.roles || '')
|
||
.split(',')
|
||
.map((item) => item.trim())
|
||
.filter(Boolean);
|
||
link.style.display = !allowed.length || allowed.includes(state.role) ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
function ensurePageAccess() {
|
||
const allowed = ['admin', 'supervisor'];
|
||
if (!allowed.includes(state.role)) {
|
||
window.location.href = destinationForRole(state.role);
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function persistSession() {
|
||
window.localStorage.setItem(
|
||
SESSION_STORAGE_KEY,
|
||
JSON.stringify({
|
||
access_token: state.token,
|
||
user: state.user,
|
||
role: state.role,
|
||
auth_source: state.authSource,
|
||
full_name: state.fullName,
|
||
}),
|
||
);
|
||
}
|
||
|
||
function clearStoredSession() {
|
||
window.localStorage.removeItem(SESSION_STORAGE_KEY);
|
||
}
|
||
|
||
function restoreStoredSession() {
|
||
const raw = window.localStorage.getItem(SESSION_STORAGE_KEY);
|
||
if (!raw) {
|
||
return false;
|
||
}
|
||
try {
|
||
const payload = JSON.parse(raw);
|
||
if (!payload?.access_token) {
|
||
clearStoredSession();
|
||
return false;
|
||
}
|
||
state.token = payload.access_token;
|
||
state.user = payload.user || state.user;
|
||
state.role = payload.role || state.role;
|
||
state.authSource = payload.auth_source || 'local';
|
||
state.fullName = payload.full_name || null;
|
||
$('sessionUser').value = state.user;
|
||
$('sessionRole').value = state.role;
|
||
if ($('authPanel')) {
|
||
$('authPanel').style.display = 'none';
|
||
}
|
||
updateProfileMeta();
|
||
applyRoleNavigation();
|
||
return true;
|
||
} catch {
|
||
clearStoredSession();
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function logout() {
|
||
if (state.recordingPreviewUrl) {
|
||
URL.revokeObjectURL(state.recordingPreviewUrl);
|
||
state.recordingPreviewUrl = null;
|
||
}
|
||
clearStoredSession();
|
||
window.location.href = '/';
|
||
}
|
||
|
||
function syncSessionFromInputs() {
|
||
state.user = $('sessionUser').value.trim() || 'supervisor';
|
||
state.role = $('sessionRole').value || 'supervisor';
|
||
}
|
||
|
||
function log(message, payload) {
|
||
const ts = new Date().toLocaleTimeString();
|
||
const suffix = payload ? ` ${JSON.stringify(payload)}` : '';
|
||
state.logLines.unshift(`[${ts}] ${message}${suffix}`);
|
||
state.logLines = state.logLines.slice(0, 14);
|
||
$('eventLog').textContent = state.logLines.join('\n');
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? '')
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
function renderSummaryCard(label, value, note = '') {
|
||
return `
|
||
<div class="summary-card">
|
||
<div class="summary-label">${label}</div>
|
||
<div class="summary-value">${value}</div>
|
||
${note ? `<div class="summary-note">${note}</div>` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function updateProfileMeta() {
|
||
const profileName = $('profileName');
|
||
const profileRole = $('profileRole');
|
||
if (!profileName || !profileRole) {
|
||
return;
|
||
}
|
||
profileName.textContent = state.fullName || state.user || 'Пользователь KonturCC';
|
||
profileRole.textContent = ROLE_LABELS[state.role] || state.role || 'Пользователь';
|
||
}
|
||
|
||
function updateSessionInfo(extra = '') {
|
||
if (!state.token) {
|
||
$('sessionInfo').textContent = 'Токен: отсутствует';
|
||
return;
|
||
}
|
||
const source = state.authSource === 'oidc' ? 'корпоративный' : 'локальный';
|
||
$('sessionInfo').textContent = `Токен активен | роль: ${state.role} | вход: ${source}${extra ? ` | ${extra}` : ''}`;
|
||
}
|
||
|
||
function applyAuthenticatedSession(data) {
|
||
state.token = data.access_token;
|
||
state.role = data.role;
|
||
state.user = data.username || state.user;
|
||
state.authSource = data.auth_source || 'local';
|
||
state.fullName = data.full_name || null;
|
||
$('sessionUser').value = state.user;
|
||
$('sessionRole').value = data.role;
|
||
updateSessionInfo(data.provider ? `провайдер: ${data.provider}` : '');
|
||
updateProfileMeta();
|
||
persistSession();
|
||
applyRoleNavigation();
|
||
}
|
||
|
||
async function api(service, path, options = {}) {
|
||
syncSessionFromInputs();
|
||
const headers = {
|
||
'Content-Type': 'application/json',
|
||
'X-User': state.user,
|
||
'X-Role': state.role,
|
||
...(options.headers || {}),
|
||
};
|
||
if (state.token) {
|
||
headers.Authorization = `Bearer ${state.token}`;
|
||
}
|
||
|
||
const response = await fetch(`/proxy/${service}/${path}`, { ...options, headers });
|
||
let data;
|
||
try {
|
||
data = await response.json();
|
||
} catch {
|
||
data = { error: 'Сервис вернул ответ не в JSON-формате' };
|
||
}
|
||
if (!response.ok) {
|
||
if (response.status === 401 && state.token) {
|
||
state.token = null;
|
||
state.authSource = 'local';
|
||
clearStoredSession();
|
||
updateSessionInfo('требуется повторный вход');
|
||
window.location.href = '/';
|
||
}
|
||
throw new Error(`${response.status}: ${JSON.stringify(data)}`);
|
||
}
|
||
return data;
|
||
}
|
||
|
||
async function fetchBinary(service, path) {
|
||
syncSessionFromInputs();
|
||
const headers = {
|
||
'X-User': state.user,
|
||
'X-Role': state.role,
|
||
};
|
||
if (state.token) {
|
||
headers.Authorization = `Bearer ${state.token}`;
|
||
}
|
||
const response = await fetch(`/proxy/${service}/${path}`, {
|
||
method: 'GET',
|
||
headers,
|
||
});
|
||
if (!response.ok) {
|
||
const detail = await response.text();
|
||
if (response.status === 401 && state.token) {
|
||
state.token = null;
|
||
state.authSource = 'local';
|
||
clearStoredSession();
|
||
updateSessionInfo('требуется повторный вход');
|
||
window.location.href = '/';
|
||
}
|
||
throw new Error(`${response.status}: ${detail}`);
|
||
}
|
||
return response;
|
||
}
|
||
|
||
async function checkGateway() {
|
||
try {
|
||
const response = await fetch('/health');
|
||
const data = await response.json();
|
||
$('gatewayStatus').textContent = `Шлюз: ${data.status === 'ok' ? 'готов' : data.status}`;
|
||
} catch {
|
||
$('gatewayStatus').textContent = 'Шлюз: недоступен';
|
||
}
|
||
}
|
||
|
||
async function loadOidcConfig() {
|
||
try {
|
||
const response = await fetch('/proxy/auth/auth/oidc/config');
|
||
const data = await response.json();
|
||
state.oidc.enabled = Boolean(data.enabled);
|
||
state.oidc.loginPath = data.login_path || '/auth/oidc/start?return_mode=popup';
|
||
state.oidc.providerLabel = data.provider_label || 'Keycloak';
|
||
$('corporateLoginBtn').disabled = !state.oidc.enabled;
|
||
$('corporateLoginBtn').textContent = state.oidc.enabled
|
||
? `Корпоративный вход (${state.oidc.providerLabel})`
|
||
: 'Корпоративный вход недоступен';
|
||
} catch {
|
||
$('corporateLoginBtn').disabled = true;
|
||
$('corporateLoginBtn').textContent = 'Корпоративный вход недоступен';
|
||
}
|
||
}
|
||
|
||
function startCorporateLogin() {
|
||
if (!state.oidc.enabled) {
|
||
log('Корпоративный вход недоступен');
|
||
return;
|
||
}
|
||
const popup = window.open(`/proxy/auth${state.oidc.loginPath}`, 'oidc-login', 'width=620,height=760');
|
||
if (!popup) {
|
||
log('Не удалось открыть окно корпоративного входа');
|
||
return;
|
||
}
|
||
log('Открыт корпоративный вход', { provider: state.oidc.providerLabel });
|
||
}
|
||
|
||
function handleOidcMessage(event) {
|
||
if (!event?.data || typeof event.data !== 'object') {
|
||
return;
|
||
}
|
||
if (event.data.type === 'oidc-login' && event.data.access_token) {
|
||
applyAuthenticatedSession(event.data);
|
||
log('Корпоративный вход выполнен', { user: state.user, role: state.role });
|
||
return;
|
||
}
|
||
if (event.data.type === 'oidc-error') {
|
||
log('Корпоративный вход не выполнен', { error: event.data.message || 'неизвестно' });
|
||
updateSessionInfo(`ошибка SSO: ${event.data.message || 'неизвестно'}`);
|
||
}
|
||
}
|
||
|
||
async function login() {
|
||
syncSessionFromInputs();
|
||
try {
|
||
const data = await api('auth', 'auth/login', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
username: state.user,
|
||
password: $('sessionPassword').value,
|
||
}),
|
||
});
|
||
applyAuthenticatedSession({ ...data, username: state.user, auth_source: data.auth_source || 'local' });
|
||
log('Локальный вход выполнен', { user: state.user, role: data.role });
|
||
} catch (err) {
|
||
$('sessionInfo').textContent = `Ошибка входа: ${err.message}`;
|
||
log('Вход не выполнен', { error: err.message });
|
||
}
|
||
}
|
||
|
||
function renderRealtime(data) {
|
||
const agents = data?.agents || {};
|
||
const byState = agents.by_state || {};
|
||
const firstQueue = (data?.queues || [])[0] || {};
|
||
$('realtimeSummary').innerHTML = [
|
||
renderSummaryCard('Агентов', Number(agents.total || 0), 'Текущее состояние команды'),
|
||
renderSummaryCard('Готовы', Number(byState.READY || 0), 'Свободны'),
|
||
renderSummaryCard('Заняты', Number(byState.BUSY || 0), 'В обработке'),
|
||
renderSummaryCard('Очередь', firstQueue.queue_id || 'нет', `В очереди: ${Number(firstQueue.in_queue || 0)}`),
|
||
].join('');
|
||
$('realtimeOutput').textContent = JSON.stringify(data, null, 2);
|
||
}
|
||
|
||
function renderKpi(data) {
|
||
const kpi = data?.kpi || {};
|
||
$('kpiSummary').innerHTML = [
|
||
renderSummaryCard('SL', `${Number(kpi.SL || 0).toFixed(2)}%`),
|
||
renderSummaryCard('ASA', `${Number(kpi.ASA || 0).toFixed(2)} с`),
|
||
renderSummaryCard('AHT', `${Number(kpi.AHT || 0).toFixed(2)} с`),
|
||
renderSummaryCard('Потери', `${Number(kpi.Abandon || 0).toFixed(2)}%`),
|
||
renderSummaryCard('FCR', `${Number(kpi.FCR || 0).toFixed(2)}%`),
|
||
].join('');
|
||
$('kpiOutput').textContent = JSON.stringify(data, null, 2);
|
||
}
|
||
|
||
function emptyKpiEnvelope(fromTs = null, toTs = null) {
|
||
return {
|
||
window: { from: fromTs, to: toTs },
|
||
volume: {
|
||
total: 0,
|
||
answered: 0,
|
||
abandoned: 0,
|
||
},
|
||
kpi: {
|
||
SL: 0,
|
||
ASA: 0,
|
||
AHT: 0,
|
||
Abandon: 0,
|
||
FCR: 0,
|
||
AnswerRate: 0,
|
||
WaitP95: 0,
|
||
HandleP95: 0,
|
||
Occupancy: 0,
|
||
DigitalShare: 0,
|
||
},
|
||
breakdowns: {
|
||
by_channel: {},
|
||
},
|
||
filters: {},
|
||
};
|
||
}
|
||
|
||
function pad2(value) {
|
||
return String(value).padStart(2, '0');
|
||
}
|
||
|
||
function startOfDay(date) {
|
||
const next = new Date(date);
|
||
next.setHours(0, 0, 0, 0);
|
||
return next;
|
||
}
|
||
|
||
function addDays(date, days) {
|
||
const next = new Date(date);
|
||
next.setDate(next.getDate() + days);
|
||
return next;
|
||
}
|
||
|
||
function formatDateTimeLocalValue(date) {
|
||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}T${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
|
||
}
|
||
|
||
function normalizeAnalyticsRange(from, to) {
|
||
const safeFrom = new Date(from);
|
||
const safeTo = new Date(to);
|
||
if (Number.isNaN(safeFrom.getTime()) || Number.isNaN(safeTo.getTime())) {
|
||
return { from: new Date(), to: new Date() };
|
||
}
|
||
if (safeTo <= safeFrom) {
|
||
return { from: safeFrom, to: new Date(safeFrom.getTime() + 60 * 60 * 1000) };
|
||
}
|
||
return { from: safeFrom, to: safeTo };
|
||
}
|
||
|
||
function analyticsRangeFromControls() {
|
||
const preset = $('analyticsPreset').value || state.analytics.preset || '7d';
|
||
const customFrom = $('analyticsFrom').value.trim();
|
||
const customTo = $('analyticsTo').value.trim();
|
||
const now = new Date();
|
||
let from;
|
||
let to;
|
||
let custom = false;
|
||
|
||
if (customFrom && customTo) {
|
||
custom = true;
|
||
from = new Date(customFrom);
|
||
to = new Date(customTo);
|
||
} else if (preset === 'today') {
|
||
from = startOfDay(now);
|
||
to = now;
|
||
} else if (preset === '30d') {
|
||
from = startOfDay(addDays(now, -29));
|
||
to = now;
|
||
} else {
|
||
from = startOfDay(addDays(now, -6));
|
||
to = now;
|
||
}
|
||
|
||
const current = normalizeAnalyticsRange(from, to);
|
||
const durationMs = Math.max(current.to.getTime() - current.from.getTime(), 60 * 60 * 1000);
|
||
const previous = {
|
||
from: new Date(current.from.getTime() - durationMs),
|
||
to: new Date(current.from.getTime()),
|
||
};
|
||
|
||
return {
|
||
preset,
|
||
custom,
|
||
current,
|
||
previous,
|
||
};
|
||
}
|
||
|
||
function syncAnalyticsStateFromControls() {
|
||
state.analytics.preset = $('analyticsPreset').value || '7d';
|
||
state.analytics.fromTs = $('analyticsFrom').value.trim();
|
||
state.analytics.toTs = $('analyticsTo').value.trim();
|
||
state.analytics.queueId = $('analyticsQueue').value || 'all';
|
||
state.analytics.channel = $('analyticsChannel').value || 'all';
|
||
state.analytics.trendMetric = $('analyticsTrendMetric').value || 'volume';
|
||
}
|
||
|
||
function syncAnalyticsControlsFromState() {
|
||
$('analyticsPreset').value = state.analytics.preset || '7d';
|
||
$('analyticsFrom').value = state.analytics.fromTs || '';
|
||
$('analyticsTo').value = state.analytics.toTs || '';
|
||
$('analyticsChannel').value = state.analytics.channel || 'all';
|
||
$('analyticsTrendMetric').value = state.analytics.trendMetric || 'volume';
|
||
renderAnalyticsQueueOptions();
|
||
}
|
||
|
||
function resetAnalyticsFilters() {
|
||
state.analytics.preset = '7d';
|
||
state.analytics.fromTs = '';
|
||
state.analytics.toTs = '';
|
||
state.analytics.queueId = 'all';
|
||
state.analytics.channel = 'all';
|
||
state.analytics.trendMetric = 'volume';
|
||
syncAnalyticsControlsFromState();
|
||
}
|
||
|
||
function analyticsQuery(range, overrides = {}) {
|
||
const params = new URLSearchParams();
|
||
params.set('from_ts', range.from.toISOString());
|
||
params.set('to_ts', range.to.toISOString());
|
||
|
||
const queueId = overrides.queueId ?? state.analytics.queueId;
|
||
const channel = overrides.channel ?? state.analytics.channel;
|
||
|
||
if (queueId && queueId !== 'all') {
|
||
params.set('queue_id', queueId);
|
||
}
|
||
if (channel && channel !== 'all') {
|
||
params.set('channel', channel);
|
||
}
|
||
|
||
return params.toString();
|
||
}
|
||
|
||
async function fetchAnalyticsKpi(range, overrides = {}) {
|
||
return api('reporting', `reports/kpi?${analyticsQuery(range, overrides)}`);
|
||
}
|
||
|
||
function renderAnalyticsQueueOptions() {
|
||
const select = $('analyticsQueue');
|
||
if (!select) {
|
||
return;
|
||
}
|
||
const selected = state.analytics.queueId || 'all';
|
||
const options = ['<option value="all">Все очереди</option>'];
|
||
if (selected !== 'all' && !state.analytics.queueOptions.some((item) => item.queue_id === selected)) {
|
||
options.push(`<option value="${escapeHtml(selected)}">${escapeHtml(selected)}</option>`);
|
||
}
|
||
options.push(
|
||
...state.analytics.queueOptions.map(
|
||
(item) => `<option value="${escapeHtml(item.queue_id)}">${escapeHtml(item.name || item.queue_id)}</option>`,
|
||
),
|
||
);
|
||
select.innerHTML = options.join('');
|
||
select.value = options.some((item) => item.includes(`value="${selected}"`)) ? selected : 'all';
|
||
}
|
||
|
||
async function loadAnalyticsQueueOptions() {
|
||
try {
|
||
const data = await api('routing', 'queues');
|
||
state.analytics.queueOptions = Array.isArray(data) ? data : [];
|
||
state.analytics.queueAccessError = '';
|
||
} catch (err) {
|
||
state.analytics.queueOptions = [];
|
||
state.analytics.queueAccessError = err.message;
|
||
}
|
||
renderAnalyticsQueueOptions();
|
||
return state.analytics.queueOptions;
|
||
}
|
||
|
||
function formatAnalyticsNumber(value, digits = 0) {
|
||
return new Intl.NumberFormat('ru-RU', {
|
||
minimumFractionDigits: digits,
|
||
maximumFractionDigits: digits,
|
||
}).format(Number(value || 0));
|
||
}
|
||
|
||
function analyticsChannelLabel(channel) {
|
||
const meta = ANALYTICS_CHANNEL_OPTIONS.find((item) => item.value === channel);
|
||
return meta ? meta.label : String(channel || 'неизвестно');
|
||
}
|
||
|
||
function metricValueFromPayload(metric, payload) {
|
||
if (metric === 'volume' || metric === 'total') {
|
||
return Number(payload?.volume?.total || 0);
|
||
}
|
||
if (metric === 'answered') {
|
||
return Number(payload?.volume?.answered || 0);
|
||
}
|
||
return Number(payload?.kpi?.[metric] || 0);
|
||
}
|
||
|
||
function formatAnalyticsMetric(metric, value) {
|
||
const meta = ANALYTICS_METRIC_META[metric] || { unit: 'count' };
|
||
if (meta.unit === 'pct') {
|
||
return `${formatAnalyticsNumber(value, 2)}%`;
|
||
}
|
||
if (meta.unit === 'seconds') {
|
||
return `${formatAnalyticsNumber(value, 2)} с`;
|
||
}
|
||
return formatAnalyticsNumber(value, 0);
|
||
}
|
||
|
||
function signedDelta(value, digits = 1) {
|
||
if (!value) {
|
||
return '0';
|
||
}
|
||
const sign = value > 0 ? '+' : '-';
|
||
return `${sign}${formatAnalyticsNumber(Math.abs(value), digits)}`;
|
||
}
|
||
|
||
function analyticsDelta(metric, currentPayload, previousPayload) {
|
||
const current = metricValueFromPayload(metric, currentPayload);
|
||
const previous = metricValueFromPayload(metric, previousPayload);
|
||
const diff = current - previous;
|
||
const meta = ANALYTICS_METRIC_META[metric] || { unit: 'count', better: 'neutral' };
|
||
|
||
let text = signedDelta(diff, meta.unit === 'count' ? 0 : 1);
|
||
if (meta.unit === 'pct') {
|
||
text = `${text} п.п.`;
|
||
} else if (meta.unit === 'seconds') {
|
||
text = `${text} с`;
|
||
}
|
||
|
||
let tone = 'neutral';
|
||
if (meta.better === 'up') {
|
||
tone = diff > 0 ? 'positive' : diff < 0 ? 'negative' : 'neutral';
|
||
} else if (meta.better === 'down') {
|
||
tone = diff < 0 ? 'positive' : diff > 0 ? 'negative' : 'neutral';
|
||
}
|
||
|
||
return { text, tone };
|
||
}
|
||
|
||
function renderAnalyticsCard(label, metric, payload, previousPayload) {
|
||
const value = metricValueFromPayload(metric, payload);
|
||
const delta = analyticsDelta(metric, payload, previousPayload);
|
||
const note = ANALYTICS_METRIC_META[metric]?.note || '';
|
||
return `
|
||
<div class="summary-card analytics-card">
|
||
<div class="analytics-card-top">
|
||
<div class="summary-label">${label}</div>
|
||
<span class="analytics-delta analytics-delta-${delta.tone}">${delta.text}</span>
|
||
</div>
|
||
<div class="summary-value">${formatAnalyticsMetric(metric, value)}</div>
|
||
${note ? `<div class="summary-note">${note}</div>` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function updateAnalyticsRangeHint(range) {
|
||
const filters = [];
|
||
if (state.analytics.queueId !== 'all') {
|
||
filters.push(`очередь: ${state.analytics.queueId}`);
|
||
}
|
||
if (state.analytics.channel !== 'all') {
|
||
filters.push(`канал: ${state.analytics.channel}`);
|
||
}
|
||
const suffix = filters.length ? ` | ${filters.join(' | ')}` : '';
|
||
$('analyticsRangeHint').textContent = range.custom
|
||
? `Кастомный диапазон: ${formatTime(range.current.from.toISOString())} - ${formatTime(range.current.to.toISOString())}${suffix}`
|
||
: `Период: ${range.preset}${suffix}`;
|
||
}
|
||
|
||
function renderAnalyticsOperationsStrip() {
|
||
const realtime = state.analytics.realtime || {};
|
||
const agents = realtime?.agents || {};
|
||
const byState = agents.by_state || {};
|
||
const firstQueue = Array.isArray(realtime?.queues) ? realtime.queues[0] || {} : {};
|
||
|
||
$('analyticsOperations').innerHTML = [
|
||
renderSummaryCard('Агенты', Number(agents.total || 0), 'Активный состав в оперативном срезе'),
|
||
renderSummaryCard('Готовы', Number(byState.READY || 0), 'Свободные агенты'),
|
||
renderSummaryCard('Заняты', Number(byState.BUSY || 0), 'В обработке'),
|
||
renderSummaryCard('Живые звонки', Number(state.analytics.liveCallsCount || 0), 'Последние звонки от Asterisk'),
|
||
renderSummaryCard('Очередь', firstQueue.queue_id || 'нет', `В очереди: ${Number(firstQueue.in_queue || 0)}`),
|
||
].join('');
|
||
}
|
||
|
||
function renderAnalyticsOverview() {
|
||
const current = state.analytics.overview || emptyKpiEnvelope();
|
||
const previous = state.analytics.compare || emptyKpiEnvelope();
|
||
$('analyticsOverview').innerHTML = [
|
||
renderAnalyticsCard('Поток', 'total', current, previous),
|
||
renderAnalyticsCard('Обработано', 'answered', current, previous),
|
||
renderAnalyticsCard('SL', 'SL', current, previous),
|
||
renderAnalyticsCard('ASA', 'ASA', current, previous),
|
||
renderAnalyticsCard('AHT', 'AHT', current, previous),
|
||
renderAnalyticsCard('Потери', 'Abandon', current, previous),
|
||
renderAnalyticsCard('FCR', 'FCR', current, previous),
|
||
renderAnalyticsCard('Цифровая доля', 'DigitalShare', current, previous),
|
||
].join('');
|
||
}
|
||
|
||
function renderAnalyticsChannelTable() {
|
||
const container = $('analyticsChannelTable');
|
||
const rows = state.analytics.channelRows || [];
|
||
if (!rows.length) {
|
||
container.innerHTML = '<div class="empty-state">Нет данных по каналам за выбранный период.</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = `
|
||
<div class="analytics-table-head analytics-channel-grid">
|
||
<span>Канал</span>
|
||
<span>Всего</span>
|
||
<span>Обработано</span>
|
||
<span>Потеряно</span>
|
||
<span>Доля ответа</span>
|
||
</div>
|
||
${rows
|
||
.map((item) => {
|
||
const answerRate = item.total ? (item.answered / item.total) * 100 : 0;
|
||
return `
|
||
<div class="analytics-table-row analytics-channel-grid">
|
||
<span>${escapeHtml(analyticsChannelLabel(item.channel))}</span>
|
||
<span>${formatAnalyticsNumber(item.total, 0)}</span>
|
||
<span>${formatAnalyticsNumber(item.answered, 0)}</span>
|
||
<span>${formatAnalyticsNumber(item.abandoned, 0)}</span>
|
||
<span>${formatAnalyticsNumber(answerRate, 2)}%</span>
|
||
</div>
|
||
`;
|
||
})
|
||
.join('')}
|
||
`;
|
||
}
|
||
|
||
function renderAnalyticsQueueTable() {
|
||
const container = $('analyticsQueueTable');
|
||
const rows = state.analytics.queueRows || [];
|
||
if (!rows.length) {
|
||
const reason = state.analytics.queueAccessError
|
||
? 'Таблица очередей недоступна для текущей роли: backend не разрешил получить каталог очередей.'
|
||
: 'Очереди пока не вернули исторические данные за выбранное окно.';
|
||
container.innerHTML = `<div class="empty-state">${escapeHtml(reason)}</div>`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = `
|
||
<div class="analytics-table-head analytics-queue-grid">
|
||
<span>Очередь</span>
|
||
<span>Всего</span>
|
||
<span>Обработано</span>
|
||
<span>SL</span>
|
||
<span>Потери</span>
|
||
</div>
|
||
${rows
|
||
.map((item) => `
|
||
<button class="analytics-table-row analytics-table-button analytics-queue-grid${item.queue_id === state.analytics.queueId ? ' selected' : ''}" type="button" data-analytics-queue-id="${escapeHtml(item.queue_id)}">
|
||
<span><strong>${escapeHtml(item.name || item.queue_id)}</strong><small>${escapeHtml(item.queue_id)}</small></span>
|
||
<span>${formatAnalyticsNumber(item.total, 0)}</span>
|
||
<span>${formatAnalyticsNumber(item.answered, 0)}</span>
|
||
<span>${formatAnalyticsNumber(item.SL, 2)}%</span>
|
||
<span>${formatAnalyticsNumber(item.Abandon, 2)}%</span>
|
||
</button>
|
||
`)
|
||
.join('')}
|
||
`;
|
||
}
|
||
|
||
function renderAnalyticsCoverage() {
|
||
const implemented = Array.isArray(state.analytics.coverage?.implemented_metrics)
|
||
? state.analytics.coverage.implemented_metrics
|
||
: [];
|
||
const dimensions = Array.isArray(state.analytics.coverage?.dimensions)
|
||
? state.analytics.coverage.dimensions
|
||
: [];
|
||
const filters = Array.isArray(state.analytics.coverage?.supported_filters)
|
||
? state.analytics.coverage.supported_filters
|
||
: [];
|
||
|
||
$('analyticsCoverage').innerHTML = `
|
||
<div class="analytics-coverage-list">
|
||
<div class="analytics-coverage-block">
|
||
<h4>Что уже есть</h4>
|
||
<div class="analytics-coverage-chips">
|
||
${implemented.length
|
||
? implemented
|
||
.map((item) => `<span class="analytics-coverage-chip">${escapeHtml(item.code)} · ${escapeHtml(item.label)}</span>`)
|
||
.join('')
|
||
: '<span class="empty-state">Сервис coverage пока не вернул каталог метрик.</span>'}
|
||
</div>
|
||
</div>
|
||
<div class="analytics-coverage-block">
|
||
<h4>Разрезы</h4>
|
||
<div class="analytics-coverage-chips">
|
||
${dimensions.length
|
||
? dimensions.map((item) => `<span class="analytics-coverage-chip soft">${escapeHtml(item)}</span>`).join('')
|
||
: '<span class="empty-state">Нет данных о разрезах.</span>'}
|
||
</div>
|
||
</div>
|
||
<div class="analytics-coverage-block">
|
||
<h4>Фильтры</h4>
|
||
<div class="analytics-coverage-chips">
|
||
${filters.length
|
||
? filters.map((item) => `<span class="analytics-coverage-chip soft">${escapeHtml(item)}</span>`).join('')
|
||
: '<span class="empty-state">Нет данных о фильтрах.</span>'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="analytics-placeholder-grid">
|
||
<article class="analytics-placeholder-card">
|
||
<strong>Аналитика по агентам</strong>
|
||
<p>Появится, когда reporting начнёт отдавать детализацию по <code>agent_id</code> и персональные ряды производительности.</p>
|
||
</article>
|
||
<article class="analytics-placeholder-card">
|
||
<strong>AI-аналитика</strong>
|
||
<p>Ждём отдельные AI-метрики: долю закрытий, передачу оператору, задержку ответа и покрытие по AI-сводкам.</p>
|
||
</article>
|
||
<article class="analytics-placeholder-card">
|
||
<strong>Качество речи</strong>
|
||
<p>Блок будет строиться поверх скоринга транскриптов, QA-правил и расширенных метаданных записей.</p>
|
||
</article>
|
||
${
|
||
state.analytics.queueAccessError
|
||
? `<article class="analytics-placeholder-card">
|
||
<strong>Доступ к каталогу очередей</strong>
|
||
<p>${escapeHtml(state.analytics.queueAccessError)}</p>
|
||
</article>`
|
||
: ''
|
||
}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function buildTrendBuckets(range, preset, custom) {
|
||
const buckets = [];
|
||
const hourMs = 60 * 60 * 1000;
|
||
const dayMs = 24 * hourMs;
|
||
|
||
if (custom) {
|
||
const fromDay = startOfDay(range.from);
|
||
const endDay = startOfDay(addDays(range.to, 1));
|
||
const totalDays = Math.max(1, Math.ceil((endDay.getTime() - fromDay.getTime()) / dayMs));
|
||
const bucketDays = Math.max(1, Math.ceil(totalDays / 31));
|
||
for (let cursor = new Date(fromDay); cursor < endDay; cursor = new Date(cursor.getTime() + bucketDays * dayMs)) {
|
||
const bucketFrom = new Date(cursor);
|
||
const bucketTo = new Date(Math.min(bucketFrom.getTime() + bucketDays * dayMs, endDay.getTime()));
|
||
const lastDay = new Date(bucketTo.getTime() - dayMs);
|
||
const label = bucketDays === 1
|
||
? bucketFrom.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' })
|
||
: `${bucketFrom.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' })} - ${lastDay.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' })}`;
|
||
buckets.push({ label, range: { from: bucketFrom, to: bucketTo } });
|
||
}
|
||
return buckets.slice(0, 31);
|
||
}
|
||
|
||
if (preset === 'today') {
|
||
const dayStart = startOfDay(range.from);
|
||
for (let hour = 0; hour < 24; hour += 1) {
|
||
const bucketFrom = new Date(dayStart.getTime() + hour * hourMs);
|
||
const bucketTo = new Date(bucketFrom.getTime() + hourMs);
|
||
buckets.push({
|
||
label: `${pad2(hour)}:00`,
|
||
range: { from: bucketFrom, to: bucketTo },
|
||
});
|
||
}
|
||
return buckets;
|
||
}
|
||
|
||
const days = preset === '30d' ? 30 : 7;
|
||
const firstDay = startOfDay(range.from);
|
||
for (let day = 0; day < days; day += 1) {
|
||
const bucketFrom = new Date(firstDay.getTime() + day * dayMs);
|
||
const bucketTo = new Date(bucketFrom.getTime() + dayMs);
|
||
buckets.push({
|
||
label: bucketFrom.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' }),
|
||
range: { from: bucketFrom, to: bucketTo },
|
||
});
|
||
}
|
||
return buckets;
|
||
}
|
||
|
||
async function loadAnalyticsTrend(rangeMeta, requestId) {
|
||
const buckets = buildTrendBuckets(rangeMeta.current, rangeMeta.preset, rangeMeta.custom);
|
||
const items = await Promise.all(
|
||
buckets.map(async (bucket) => {
|
||
try {
|
||
const payload = await fetchAnalyticsKpi(bucket.range);
|
||
return { ...bucket, payload };
|
||
} catch {
|
||
return { ...bucket, payload: emptyKpiEnvelope(bucket.range.from.toISOString(), bucket.range.to.toISOString()) };
|
||
}
|
||
}),
|
||
);
|
||
if (requestId !== state.analytics.requestId) {
|
||
return [];
|
||
}
|
||
return items;
|
||
}
|
||
|
||
async function loadAnalyticsQueueRows(rangeMeta, queues, requestId) {
|
||
if (!queues.length) {
|
||
return [];
|
||
}
|
||
const rows = await Promise.all(
|
||
queues.map(async (queue) => {
|
||
try {
|
||
const payload = await fetchAnalyticsKpi(rangeMeta.current, {
|
||
queueId: queue.queue_id,
|
||
channel: state.analytics.channel,
|
||
});
|
||
return {
|
||
queue_id: queue.queue_id,
|
||
name: queue.name || queue.queue_id,
|
||
total: Number(payload?.volume?.total || 0),
|
||
answered: Number(payload?.volume?.answered || 0),
|
||
SL: Number(payload?.kpi?.SL || 0),
|
||
Abandon: Number(payload?.kpi?.Abandon || 0),
|
||
};
|
||
} catch {
|
||
return {
|
||
queue_id: queue.queue_id,
|
||
name: queue.name || queue.queue_id,
|
||
total: 0,
|
||
answered: 0,
|
||
SL: 0,
|
||
Abandon: 0,
|
||
};
|
||
}
|
||
}),
|
||
);
|
||
if (requestId !== state.analytics.requestId) {
|
||
return [];
|
||
}
|
||
return rows.sort((a, b) => b.total - a.total || a.name.localeCompare(b.name));
|
||
}
|
||
|
||
function renderAnalyticsTrendChart() {
|
||
const metric = state.analytics.trendMetric || 'volume';
|
||
const items = state.analytics.trend || [];
|
||
const container = $('analyticsTrendChart');
|
||
if (!items.length) {
|
||
container.innerHTML = '<div class="empty-state">Нет данных тренда за выбранный период.</div>';
|
||
return;
|
||
}
|
||
|
||
const values = items.map((item) => metricValueFromPayload(metric, item.payload));
|
||
const maxValue = Math.max(...values, 1);
|
||
const width = 720;
|
||
const height = 220;
|
||
const paddingLeft = 38;
|
||
const paddingRight = 16;
|
||
const paddingTop = 16;
|
||
const paddingBottom = 36;
|
||
const chartWidth = width - paddingLeft - paddingRight;
|
||
const chartHeight = height - paddingTop - paddingBottom;
|
||
const step = items.length > 1 ? chartWidth / (items.length - 1) : chartWidth;
|
||
|
||
const pointFor = (value, index) => {
|
||
const x = paddingLeft + index * step;
|
||
const y = paddingTop + chartHeight - (value / maxValue) * chartHeight;
|
||
return { x, y };
|
||
};
|
||
|
||
const polyline = values
|
||
.map((value, index) => {
|
||
const point = pointFor(value, index);
|
||
return `${point.x},${point.y}`;
|
||
})
|
||
.join(' ');
|
||
|
||
const grid = [0, 0.25, 0.5, 0.75, 1]
|
||
.map((ratio) => {
|
||
const y = paddingTop + chartHeight - ratio * chartHeight;
|
||
const labelValue = maxValue * ratio;
|
||
return `
|
||
<line x1="${paddingLeft}" y1="${y}" x2="${width - paddingRight}" y2="${y}" class="analytics-grid-line"></line>
|
||
<text x="${paddingLeft - 8}" y="${y + 4}" class="analytics-axis-label" text-anchor="end">${escapeHtml(formatAnalyticsNumber(labelValue, metric === 'volume' ? 0 : 1))}</text>
|
||
`;
|
||
})
|
||
.join('');
|
||
|
||
const markers = values
|
||
.map((value, index) => {
|
||
const point = pointFor(value, index);
|
||
return `<circle cx="${point.x}" cy="${point.y}" r="4" class="analytics-point"></circle>`;
|
||
})
|
||
.join('');
|
||
|
||
const labelStep = items.length > 12 ? Math.ceil(items.length / 6) : 1;
|
||
const labels = items
|
||
.map((item, index) => {
|
||
if (index % labelStep !== 0 && index !== items.length - 1) {
|
||
return '';
|
||
}
|
||
const point = pointFor(values[index], index);
|
||
return `<text x="${point.x}" y="${height - 10}" class="analytics-axis-label" text-anchor="middle">${escapeHtml(item.label)}</text>`;
|
||
})
|
||
.join('');
|
||
|
||
container.innerHTML = `
|
||
<div class="analytics-chart-meta">
|
||
<strong>${escapeHtml(metric === 'volume' ? 'Volume' : metric)}</strong>
|
||
<span>${escapeHtml(formatAnalyticsMetric(metric, values[values.length - 1] || 0))} в последней точке</span>
|
||
</div>
|
||
<svg viewBox="0 0 ${width} ${height}" class="analytics-chart-svg" role="img" aria-label="Analytics trend chart">
|
||
${grid}
|
||
<polyline points="${polyline}" class="analytics-line"></polyline>
|
||
${markers}
|
||
${labels}
|
||
</svg>
|
||
`;
|
||
}
|
||
|
||
function renderAnalyticsEmptyState() {
|
||
const box = $('analyticsEmptyState');
|
||
if (state.analytics.error) {
|
||
box.hidden = false;
|
||
box.textContent = `Не удалось загрузить аналитику: ${state.analytics.error}`;
|
||
return;
|
||
}
|
||
const total = Number(state.analytics.overview?.volume?.total || 0);
|
||
if (!state.analytics.loading && total === 0) {
|
||
box.hidden = false;
|
||
box.textContent = 'За выбранный период данных пока нет. Фильтры сохранены, а заглушки остаются видимыми.';
|
||
return;
|
||
}
|
||
box.hidden = true;
|
||
box.textContent = '';
|
||
}
|
||
|
||
function renderAnalyticsDashboard(rangeMeta = analyticsRangeFromControls()) {
|
||
$('analyticsRefreshBtn').disabled = state.analytics.loading;
|
||
renderAnalyticsQueueOptions();
|
||
updateAnalyticsRangeHint(rangeMeta);
|
||
renderAnalyticsOperationsStrip();
|
||
renderAnalyticsOverview();
|
||
renderAnalyticsTrendChart();
|
||
renderAnalyticsChannelTable();
|
||
renderAnalyticsQueueTable();
|
||
renderAnalyticsCoverage();
|
||
renderAnalyticsEmptyState();
|
||
}
|
||
|
||
async function loadAnalyticsDashboard(silent = false) {
|
||
syncAnalyticsStateFromControls();
|
||
const rangeMeta = analyticsRangeFromControls();
|
||
const requestId = state.analytics.requestId + 1;
|
||
state.analytics.requestId = requestId;
|
||
state.analytics.loading = true;
|
||
state.analytics.error = '';
|
||
renderAnalyticsDashboard(rangeMeta);
|
||
|
||
try {
|
||
const queuePromise = loadAnalyticsQueueOptions();
|
||
const [current, previous, coverage, realtime, liveCalls, queues] = await Promise.all([
|
||
fetchAnalyticsKpi(rangeMeta.current),
|
||
fetchAnalyticsKpi(rangeMeta.previous).catch(() => emptyKpiEnvelope()),
|
||
api('reporting', 'reports/coverage').catch(() => ({ implemented_metrics: [], dimensions: [], supported_filters: [] })),
|
||
api('supervisor', 'supervisor/realtime').catch(() => ({ agents: { total: 0, by_state: {} }, queues: [] })),
|
||
api('supervisor', 'supervisor/live-calls?limit=20').catch(() => ({ items: [] })),
|
||
queuePromise,
|
||
]);
|
||
|
||
if (requestId !== state.analytics.requestId) {
|
||
return;
|
||
}
|
||
|
||
state.analytics.overview = current || emptyKpiEnvelope(rangeMeta.current.from.toISOString(), rangeMeta.current.to.toISOString());
|
||
state.analytics.compare = previous || emptyKpiEnvelope(rangeMeta.previous.from.toISOString(), rangeMeta.previous.to.toISOString());
|
||
state.analytics.coverage = coverage || { implemented_metrics: [], dimensions: [], supported_filters: [] };
|
||
state.analytics.realtime = realtime || { agents: { total: 0, by_state: {} }, queues: [] };
|
||
state.analytics.liveCallsCount = Array.isArray(liveCalls?.items) ? liveCalls.items.length : 0;
|
||
|
||
const byChannel = current?.breakdowns?.by_channel || {};
|
||
state.analytics.channelRows = Object.entries(byChannel)
|
||
.map(([channel, payload]) => ({
|
||
channel,
|
||
total: Number(payload?.total || 0),
|
||
answered: Number(payload?.answered || 0),
|
||
abandoned: Number(payload?.abandoned || 0),
|
||
}))
|
||
.sort((a, b) => b.total - a.total || a.channel.localeCompare(b.channel));
|
||
|
||
const [trend, queueRows] = await Promise.all([
|
||
loadAnalyticsTrend(rangeMeta, requestId),
|
||
loadAnalyticsQueueRows(rangeMeta, queues, requestId),
|
||
]);
|
||
|
||
if (requestId !== state.analytics.requestId) {
|
||
return;
|
||
}
|
||
|
||
state.analytics.trend = trend;
|
||
state.analytics.queueRows = queueRows;
|
||
state.analytics.loading = false;
|
||
renderAnalyticsDashboard(rangeMeta);
|
||
|
||
if (!silent) {
|
||
log('Analytics dashboard updated', {
|
||
total: current?.volume?.total || 0,
|
||
queues: queueRows.length,
|
||
trend_points: trend.length,
|
||
});
|
||
}
|
||
} catch (err) {
|
||
if (requestId !== state.analytics.requestId) {
|
||
return;
|
||
}
|
||
state.analytics.loading = false;
|
||
state.analytics.error = err.message;
|
||
state.analytics.overview = emptyKpiEnvelope();
|
||
state.analytics.compare = emptyKpiEnvelope();
|
||
state.analytics.trend = [];
|
||
state.analytics.channelRows = [];
|
||
state.analytics.queueRows = [];
|
||
renderAnalyticsDashboard(rangeMeta);
|
||
if (!silent) {
|
||
log('Не удалось обновить аналитическую витрину', { error: err.message });
|
||
}
|
||
}
|
||
}
|
||
|
||
function handleAnalyticsQueueClick(event) {
|
||
const button = event.target.closest('[data-analytics-queue-id]');
|
||
if (!button) {
|
||
return;
|
||
}
|
||
const queueId = button.dataset.analyticsQueueId || 'all';
|
||
state.analytics.queueId = queueId;
|
||
$('analyticsQueue').value = queueId;
|
||
$('queueMetricId').value = queueId;
|
||
void loadAnalyticsDashboard();
|
||
}
|
||
|
||
function formatTime(ts) {
|
||
if (!ts) {
|
||
return '-';
|
||
}
|
||
const date = new Date(ts);
|
||
if (Number.isNaN(date.getTime())) {
|
||
return ts;
|
||
}
|
||
return date.toLocaleString();
|
||
}
|
||
|
||
function renderLiveCalls(items) {
|
||
if (!items.length) {
|
||
$('liveCallsList').innerHTML = '<li class="empty-state">Новых звонков от Asterisk пока нет.</li>';
|
||
return;
|
||
}
|
||
|
||
$('liveCallsList').innerHTML = items
|
||
.map(
|
||
(item) => `
|
||
<li>
|
||
<strong>${item.call_id}</strong><br/>
|
||
обращение: ${item.interaction_id || '-'}<br/>
|
||
статус: ${item.status}<br/>
|
||
очередь: ${item.queue_id || '-'}<br/>
|
||
начат: ${formatTime(item.started_at)}<br/>
|
||
завершён: ${formatTime(item.ended_at)}<br/>
|
||
запись: ${item.has_recording ? 'да' : 'нет'}
|
||
</li>
|
||
`,
|
||
)
|
||
.join('');
|
||
}
|
||
|
||
function kbCategoryName(categoryId) {
|
||
const category = state.knowledge.categories.find((item) => item.category_id === categoryId);
|
||
return category?.name || categoryId || 'Без категории';
|
||
}
|
||
|
||
function kbLanguageLabel(language) {
|
||
return String(language || 'ru').toUpperCase();
|
||
}
|
||
|
||
function renderKnowledgeSummary() {
|
||
const ruCount = state.knowledge.articles.filter((item) => item.language === 'ru').length;
|
||
const kzCount = state.knowledge.articles.filter((item) => item.language === 'kz').length;
|
||
$('kbSummary').innerHTML = [
|
||
renderSummaryCard('Категорий', state.knowledge.categories.length, 'Структура базы знаний'),
|
||
renderSummaryCard('Статей', state.knowledge.articles.length, 'Текущий результат поиска'),
|
||
renderSummaryCard('RU', ruCount, 'Русские статьи в текущем срезе'),
|
||
renderSummaryCard('KZ', kzCount, 'Казахские статьи в текущем срезе'),
|
||
].join('');
|
||
}
|
||
|
||
function syncKnowledgeCategoryOptions(preferredCategoryId = '') {
|
||
const select = $('kbArticleCategory');
|
||
const currentValue = preferredCategoryId || select.value;
|
||
if (!state.knowledge.categories.length) {
|
||
select.innerHTML = '<option value="">Сначала создайте категорию</option>';
|
||
select.value = '';
|
||
return;
|
||
}
|
||
select.innerHTML = state.knowledge.categories
|
||
.map((item) => `<option value="${escapeHtml(item.category_id)}">${escapeHtml(item.name)}</option>`)
|
||
.join('');
|
||
const nextValue = state.knowledge.categories.some((item) => item.category_id === currentValue)
|
||
? currentValue
|
||
: state.knowledge.categories[0].category_id;
|
||
select.value = nextValue;
|
||
}
|
||
|
||
function renderKnowledgeCategories() {
|
||
if (!state.knowledge.categories.length) {
|
||
$('kbCategoriesList').innerHTML = '<li class="empty-state">Категории базы знаний пока не созданы.</li>';
|
||
return;
|
||
}
|
||
$('kbCategoriesList').innerHTML = state.knowledge.categories
|
||
.map(
|
||
(item) => `
|
||
<li>
|
||
<strong>${escapeHtml(item.name)}</strong><br/>
|
||
ID: ${escapeHtml(item.category_id)}<br/>
|
||
описание: ${escapeHtml(item.description || '—')}
|
||
</li>
|
||
`,
|
||
)
|
||
.join('');
|
||
}
|
||
|
||
function renderKnowledgeArticlePreview(article = null) {
|
||
$('kbArticlePreview').textContent = article ? JSON.stringify(article, null, 2) : 'Выберите статью из списка ниже.';
|
||
}
|
||
|
||
function resetKnowledgeArticleForm() {
|
||
state.knowledge.selectedArticleId = '';
|
||
$('kbArticleGroupId').value = '';
|
||
$('kbArticleLanguage').value = 'ru';
|
||
$('kbArticleTitle').value = '';
|
||
$('kbArticleTags').value = '';
|
||
$('kbArticleBody').value = '';
|
||
$('saveKbArticleBtn').textContent = 'Сохранить статью';
|
||
$('kbFormHint').textContent = 'Создайте новую статью или выберите существующую для редактирования.';
|
||
syncKnowledgeCategoryOptions();
|
||
renderKnowledgeArticlePreview(null);
|
||
}
|
||
|
||
function fillKnowledgeArticleForm(article) {
|
||
if (!article) {
|
||
resetKnowledgeArticleForm();
|
||
return;
|
||
}
|
||
state.knowledge.selectedArticleId = article.article_id;
|
||
$('kbArticleGroupId').value = article.article_group_id || '';
|
||
$('kbArticleLanguage').value = article.language || 'ru';
|
||
$('kbArticleTitle').value = article.title || '';
|
||
$('kbArticleTags').value = Array.isArray(article.tags) ? article.tags.join(', ') : '';
|
||
$('kbArticleBody').value = article.body || '';
|
||
syncKnowledgeCategoryOptions(article.category_id || '');
|
||
$('saveKbArticleBtn').textContent = 'Обновить статью';
|
||
$('kbFormHint').textContent = `Редактирование статьи: ${article.title || article.article_id}`;
|
||
renderKnowledgeArticlePreview(article);
|
||
}
|
||
|
||
function renderKnowledgeArticles() {
|
||
renderKnowledgeSummary();
|
||
if (!state.knowledge.articles.length) {
|
||
$('kbArticlesList').innerHTML = '<li class="empty-state">Статьи базы знаний не найдены.</li>';
|
||
return;
|
||
}
|
||
$('kbArticlesList').innerHTML = state.knowledge.articles
|
||
.map((item) => {
|
||
const tags = Array.isArray(item.tags) && item.tags.length ? item.tags.join(', ') : '—';
|
||
return `
|
||
<li>
|
||
<strong>${escapeHtml(item.title)}</strong><br/>
|
||
категория: ${escapeHtml(kbCategoryName(item.category_id))}<br/>
|
||
язык: ${escapeHtml(kbLanguageLabel(item.language))}<br/>
|
||
ID группы: ${escapeHtml(item.article_group_id || item.article_id)}<br/>
|
||
теги: ${escapeHtml(tags)}<br/>
|
||
<button class="btn ghost" type="button" data-kb-article-id="${escapeHtml(item.article_id)}">Открыть</button>
|
||
</li>
|
||
`;
|
||
})
|
||
.join('');
|
||
}
|
||
|
||
function knowledgeArticlePayloadFromForm() {
|
||
return {
|
||
category_id: $('kbArticleCategory').value.trim(),
|
||
article_group_id: $('kbArticleGroupId').value.trim() || null,
|
||
language: $('kbArticleLanguage').value.trim() || 'ru',
|
||
title: $('kbArticleTitle').value.trim(),
|
||
body: $('kbArticleBody').value.trim(),
|
||
tags: $('kbArticleTags').value
|
||
.split(',')
|
||
.map((item) => item.trim())
|
||
.filter(Boolean),
|
||
};
|
||
}
|
||
|
||
async function loadKnowledgeCategories(silent = false) {
|
||
const data = await api('kb', 'knowledge/categories');
|
||
state.knowledge.categories = Array.isArray(data) ? data : [];
|
||
syncKnowledgeCategoryOptions();
|
||
renderKnowledgeCategories();
|
||
renderKnowledgeSummary();
|
||
if (!silent) {
|
||
log('Категории базы знаний обновлены', { count: state.knowledge.categories.length });
|
||
}
|
||
}
|
||
|
||
async function loadKnowledgeArticles(silent = false) {
|
||
const query = $('kbSearchQuery').value.trim();
|
||
const language = $('kbSearchLanguage') ? $('kbSearchLanguage').value.trim() : '';
|
||
const params = new URLSearchParams({ q: query, limit: '100' });
|
||
if (language) {
|
||
params.set('language', language);
|
||
}
|
||
const data = await api('kb', `knowledge/search?${params.toString()}`);
|
||
state.knowledge.articles = Array.isArray(data) ? data : [];
|
||
state.knowledge.lastQuery = query;
|
||
renderKnowledgeArticles();
|
||
if (state.knowledge.selectedArticleId) {
|
||
const selected = state.knowledge.articles.find((item) => item.article_id === state.knowledge.selectedArticleId);
|
||
if (selected) {
|
||
renderKnowledgeArticlePreview(selected);
|
||
}
|
||
}
|
||
if (!silent) {
|
||
log('Статьи базы знаний обновлены', { count: state.knowledge.articles.length, query });
|
||
}
|
||
}
|
||
|
||
async function refreshKnowledgeBase(silent = false) {
|
||
try {
|
||
await loadKnowledgeCategories(true);
|
||
await loadKnowledgeArticles(true);
|
||
if (!silent) {
|
||
log('База знаний обновлена', {
|
||
categories: state.knowledge.categories.length,
|
||
articles: state.knowledge.articles.length,
|
||
});
|
||
}
|
||
} catch (err) {
|
||
$('kbCategoriesList').innerHTML = '<li class="empty-state">Не удалось загрузить категории базы знаний.</li>';
|
||
$('kbArticlesList').innerHTML = '<li class="empty-state">Не удалось загрузить статьи базы знаний.</li>';
|
||
$('kbArticlePreview').textContent = `Ошибка базы знаний: ${err.message}`;
|
||
if (!silent) {
|
||
log('Не удалось обновить базу знаний', { error: err.message });
|
||
}
|
||
}
|
||
}
|
||
|
||
async function createKnowledgeCategory() {
|
||
const name = $('kbCategoryName').value.trim();
|
||
const description = $('kbCategoryDescription').value.trim();
|
||
if (!name) {
|
||
$('kbFormHint').textContent = 'Укажите название категории перед сохранением.';
|
||
return;
|
||
}
|
||
try {
|
||
const created = await api('kb', 'knowledge/categories', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ name, description }),
|
||
});
|
||
$('kbCategoryName').value = '';
|
||
$('kbCategoryDescription').value = '';
|
||
await loadKnowledgeCategories(true);
|
||
syncKnowledgeCategoryOptions(created.category_id);
|
||
renderKnowledgeCategories();
|
||
renderKnowledgeSummary();
|
||
$('kbFormHint').textContent = `Категория создана: ${created.name}`;
|
||
log('Категория базы знаний создана', { category_id: created.category_id, name: created.name });
|
||
} catch (err) {
|
||
$('kbFormHint').textContent = `Не удалось создать категорию: ${err.message}`;
|
||
log('Не удалось создать категорию базы знаний', { error: err.message });
|
||
}
|
||
}
|
||
|
||
async function saveKnowledgeArticle() {
|
||
const payload = knowledgeArticlePayloadFromForm();
|
||
if (!payload.category_id) {
|
||
$('kbFormHint').textContent = 'Сначала создайте или выберите категорию.';
|
||
return;
|
||
}
|
||
if (!payload.title || !payload.body) {
|
||
$('kbFormHint').textContent = 'Заполните заголовок и текст статьи.';
|
||
return;
|
||
}
|
||
try {
|
||
const articleId = state.knowledge.selectedArticleId;
|
||
const data = articleId
|
||
? await api('kb', `knowledge/articles/${encodeURIComponent(articleId)}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify({
|
||
article_group_id: payload.article_group_id,
|
||
language: payload.language,
|
||
title: payload.title,
|
||
body: payload.body,
|
||
tags: payload.tags,
|
||
}),
|
||
})
|
||
: await api('kb', 'knowledge/articles', {
|
||
method: 'POST',
|
||
body: JSON.stringify(payload),
|
||
});
|
||
await loadKnowledgeArticles(true);
|
||
fillKnowledgeArticleForm(data);
|
||
$('kbFormHint').textContent = articleId ? `Статья обновлена: ${data.title}` : `Статья создана: ${data.title}`;
|
||
log(articleId ? 'Статья базы знаний обновлена' : 'Статья базы знаний создана', { article_id: data.article_id, title: data.title });
|
||
} catch (err) {
|
||
$('kbFormHint').textContent = `Не удалось сохранить статью: ${err.message}`;
|
||
log('Не удалось сохранить статью базы знаний', { error: err.message });
|
||
}
|
||
}
|
||
|
||
function openKnowledgeArticleFromList(event) {
|
||
const button = event.target.closest('[data-kb-article-id]');
|
||
if (!button) {
|
||
return;
|
||
}
|
||
const articleId = button.dataset.kbArticleId || '';
|
||
const article = state.knowledge.articles.find((item) => item.article_id === articleId);
|
||
if (!article) {
|
||
return;
|
||
}
|
||
fillKnowledgeArticleForm(article);
|
||
}
|
||
|
||
async function loadLiveCalls(silent = false) {
|
||
try {
|
||
const data = await api('supervisor', 'supervisor/live-calls?limit=20');
|
||
const items = Array.isArray(data?.items) ? data.items : [];
|
||
renderLiveCalls(items);
|
||
if (!silent) {
|
||
log('Лента звонков обновлена', { count: items.length });
|
||
}
|
||
} catch (err) {
|
||
$('liveCallsList').innerHTML = '<li class="empty-state">Не удалось загрузить звонки.</li>';
|
||
if (!silent) {
|
||
log('Не удалось обновить ленту звонков', { error: err.message });
|
||
}
|
||
}
|
||
}
|
||
|
||
async function loadRealtime() {
|
||
try {
|
||
const data = await api('supervisor', 'supervisor/realtime');
|
||
renderRealtime(data);
|
||
log('Оперативный срез обновлён', { agents: data?.agents?.total || 0 });
|
||
} catch (err) {
|
||
$('realtimeOutput').textContent = err.message;
|
||
log('Не удалось обновить оперативный срез', { error: err.message });
|
||
}
|
||
}
|
||
|
||
async function loadKpi() {
|
||
try {
|
||
const queueId = encodeURIComponent($('queueMetricId').value.trim() || 'line2');
|
||
const data = await api('reporting', `reports/kpi?queue_id=${queueId}`);
|
||
renderKpi(data);
|
||
log('KPI обновлены', data?.kpi || {});
|
||
} catch (err) {
|
||
$('kpiOutput').textContent = err.message;
|
||
log('Не удалось обновить KPI', { error: err.message });
|
||
}
|
||
}
|
||
|
||
async function loadAgents() {
|
||
try {
|
||
const data = await api('supervisor', 'supervisor/agents');
|
||
if (!data.length) {
|
||
$('agentsList').innerHTML = '<li class="empty-state">Агенты пока не зафиксированы.</li>';
|
||
return;
|
||
}
|
||
$('agentsList').innerHTML = data
|
||
.map((item) => `<li><strong>${item.agent_id}</strong><br/>статус: ${item.state}<br/>очередь: ${item.queue_id || 'не указана'}</li>`)
|
||
.join('');
|
||
log('Список агентов обновлён', { count: data.length });
|
||
} catch (err) {
|
||
$('agentsList').innerHTML = '<li class="empty-state">Не удалось загрузить агентов.</li>';
|
||
log('Не удалось загрузить агентов', { error: err.message });
|
||
}
|
||
}
|
||
|
||
async function setAgentState() {
|
||
try {
|
||
const data = await api('supervisor', 'supervisor/agent-states', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
agent_id: $('agentId').value.trim() || 'supervisor_demo_agent',
|
||
state: $('agentState').value,
|
||
queue_id: $('agentQueueId').value.trim() || null,
|
||
}),
|
||
});
|
||
log('Статус агента обновлён', { agent_id: data.agent_id, state: data.state });
|
||
await Promise.all([loadAgents(), loadRealtime()]);
|
||
} catch (err) {
|
||
log('Не удалось обновить статус агента', { error: err.message });
|
||
}
|
||
}
|
||
|
||
async function saveQueueMetrics() {
|
||
try {
|
||
const queueId = encodeURIComponent($('queueMetricId').value.trim() || 'line2');
|
||
const inQueue = Number($('queueInQueue').value || 0);
|
||
const avgWait = Number($('queueAvgWait').value || 0);
|
||
const data = await api(
|
||
'supervisor',
|
||
`supervisor/queue-metrics?queue_id=${queueId}&in_queue=${inQueue}&avg_wait_seconds=${avgWait}`,
|
||
{ method: 'POST' },
|
||
);
|
||
$('queueMetricsOutput').textContent = JSON.stringify(data, null, 2);
|
||
log('Показатели очереди обновлены', { queue_id: data.queue_id, in_queue: data.in_queue });
|
||
await Promise.all([loadRealtime(), loadKpi()]);
|
||
} catch (err) {
|
||
$('queueMetricsOutput').textContent = err.message;
|
||
log('Не удалось обновить очередь', { error: err.message });
|
||
}
|
||
}
|
||
|
||
function setRecordingPreviewEmpty(message) {
|
||
$('recordingMetaOutput').textContent = message || '';
|
||
$('recordingSelectionHint').textContent = message || 'Выберите запись для предпросмотра.';
|
||
$('recordingPlayer').removeAttribute('src');
|
||
$('recordingPlayer').load();
|
||
$('downloadRecordingBtn').disabled = true;
|
||
state.selectedRecordingId = null;
|
||
if (state.recordingPreviewUrl) {
|
||
URL.revokeObjectURL(state.recordingPreviewUrl);
|
||
state.recordingPreviewUrl = null;
|
||
}
|
||
}
|
||
|
||
function renderRecordingsList(data) {
|
||
if (!data.length) {
|
||
$('recordingsList').innerHTML = '<li class="empty-state">Записи не найдены.</li>';
|
||
setRecordingPreviewEmpty('По текущим фильтрам записи не найдены.');
|
||
return;
|
||
}
|
||
|
||
$('recordingsList').innerHTML = data
|
||
.map(
|
||
(item) => `
|
||
<li>
|
||
<strong>${item.recording_id}</strong><br/>
|
||
звонок: ${item.call_id}<br/>
|
||
файл: ${item.file_name}<br/>
|
||
статус: ${item.status}
|
||
<div class="actions compact-actions">
|
||
<button type="button" class="btn ghost recording-open-btn" data-recording-id="${item.recording_id}">Открыть</button>
|
||
</div>
|
||
</li>
|
||
`,
|
||
)
|
||
.join('');
|
||
|
||
document.querySelectorAll('.recording-open-btn').forEach((button) => {
|
||
button.addEventListener('click', () => {
|
||
void openRecording(button.dataset.recordingId || '');
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderRecordingMeta(data) {
|
||
$('recordingMetaOutput').textContent = JSON.stringify(data, null, 2);
|
||
$('recordingSelectionHint').textContent = `Выбрано: ${data.recording_id} | статус: ${data.status}`;
|
||
$('downloadRecordingBtn').disabled = false;
|
||
}
|
||
|
||
async function loadRecordings() {
|
||
try {
|
||
const params = new URLSearchParams();
|
||
const callId = $('recordingCallId').value.trim();
|
||
const interactionId = $('recordingInteractionId').value.trim();
|
||
const status = $('recordingStatus').value;
|
||
if (callId) {
|
||
params.set('call_id', callId);
|
||
}
|
||
if (interactionId) {
|
||
params.set('interaction_id', interactionId);
|
||
}
|
||
if (status) {
|
||
params.set('status', status);
|
||
}
|
||
params.set('limit', '50');
|
||
|
||
const suffix = params.toString() ? `?${params.toString()}` : '';
|
||
const data = await api('recording', `recordings${suffix}`);
|
||
renderRecordingsList(data);
|
||
log('Список записей обновлён', { count: data.length });
|
||
} catch (err) {
|
||
$('recordingsList').innerHTML = '<li class="empty-state">Не удалось загрузить записи.</li>';
|
||
setRecordingPreviewEmpty(`Ошибка: ${err.message}`);
|
||
log('Не удалось загрузить записи', { error: err.message });
|
||
}
|
||
}
|
||
|
||
async function loadRecordingPreview(recordingId) {
|
||
const response = await fetchBinary('recording', `recordings/${encodeURIComponent(recordingId)}/content?disposition=inline`);
|
||
const blob = await response.blob();
|
||
if (state.recordingPreviewUrl) {
|
||
URL.revokeObjectURL(state.recordingPreviewUrl);
|
||
}
|
||
state.recordingPreviewUrl = URL.createObjectURL(blob);
|
||
$('recordingPlayer').src = state.recordingPreviewUrl;
|
||
$('recordingPlayer').load();
|
||
}
|
||
|
||
async function openRecording(recordingId) {
|
||
if (!recordingId) {
|
||
return;
|
||
}
|
||
try {
|
||
const data = await api('recording', `recordings/${encodeURIComponent(recordingId)}`);
|
||
state.selectedRecordingId = recordingId;
|
||
renderRecordingMeta(data);
|
||
await loadRecordingPreview(recordingId);
|
||
log('Запись открыта', { recording_id: recordingId });
|
||
} catch (err) {
|
||
setRecordingPreviewEmpty(`Не удалось открыть запись: ${err.message}`);
|
||
log('Не удалось открыть запись', { error: err.message });
|
||
}
|
||
}
|
||
|
||
async function resolveRecordingEvent(rawValue) {
|
||
const value = (rawValue || '').trim();
|
||
if (!value) {
|
||
return null;
|
||
}
|
||
|
||
const events = await api('voice', 'integrations/voice/events?limit=300');
|
||
if (value.startsWith('vev_')) {
|
||
const direct = events.find((item) => item.event_id === value) || null;
|
||
return direct ? { eventId: value, event: direct } : { eventId: value, event: null };
|
||
}
|
||
|
||
const recordingEvents = events.filter((item) => item.event_type === 'recording.ready');
|
||
for (let i = recordingEvents.length - 1; i >= 0; i -= 1) {
|
||
const item = recordingEvents[i];
|
||
if (item.interaction_id === value || item.call_id === value) {
|
||
return { eventId: item.event_id, event: item };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function resolveVoiceContext(rawValue) {
|
||
const value = (rawValue || '').trim();
|
||
if (!value) {
|
||
return null;
|
||
}
|
||
const events = await api('voice', 'integrations/voice/events?limit=300');
|
||
const matches = events.filter(
|
||
(item) => item.event_id === value || item.interaction_id === value || item.call_id === value,
|
||
);
|
||
if (!matches.length) {
|
||
return null;
|
||
}
|
||
return matches[matches.length - 1];
|
||
}
|
||
|
||
async function importRecording() {
|
||
const rawInput = $('recordingEventId').value.trim();
|
||
if (!rawInput) {
|
||
setRecordingPreviewEmpty('Укажите ID voice_event, обращения или звонка перед импортом.');
|
||
return;
|
||
}
|
||
try {
|
||
const resolved = await resolveRecordingEvent(rawInput);
|
||
if (!resolved?.eventId) {
|
||
const ctx = await resolveVoiceContext(rawInput);
|
||
const callId = ctx?.call_id || '';
|
||
const interactionId = ctx?.interaction_id || '';
|
||
if (callId) {
|
||
$('recordingCallId').value = callId;
|
||
}
|
||
if (interactionId) {
|
||
$('recordingInteractionId').value = interactionId;
|
||
}
|
||
|
||
if (callId) {
|
||
const existing = await api('recording', `recordings?call_id=${encodeURIComponent(callId)}&limit=10`);
|
||
if (Array.isArray(existing) && existing.length) {
|
||
await loadRecordings();
|
||
await openRecording(existing[0].recording_id);
|
||
log('Запись уже импортирована автоматически', { call_id: callId, recording_id: existing[0].recording_id });
|
||
return;
|
||
}
|
||
}
|
||
|
||
const reason = ctx?.event_type === 'call.started'
|
||
? 'Звонок ещё активен или только что завершился. Подождите 5-15 секунд до события recording.ready.'
|
||
: `Событие recording.ready для "${rawInput}" не найдено.`;
|
||
setRecordingPreviewEmpty(reason);
|
||
log('Импорт записи не выполнен', { error: reason, input: rawInput });
|
||
return;
|
||
}
|
||
const { eventId, event } = resolved;
|
||
if (eventId !== rawInput) {
|
||
$('recordingEventId').value = eventId;
|
||
log('Событие записи найдено автоматически', { input: rawInput, event_id: eventId });
|
||
}
|
||
|
||
if (event?.payload?.source === 'asterisk' && !event?.payload?.source_path) {
|
||
$('recordingCallId').value = event.call_id || '';
|
||
$('recordingInteractionId').value = event.interaction_id || '';
|
||
await loadRecordings();
|
||
setRecordingPreviewEmpty(
|
||
'Записи Asterisk импортируются автоматически. Используйте список ниже и откройте последнюю запись.',
|
||
);
|
||
log('Ручной импорт пропущен для события Asterisk', { event_id: eventId, call_id: event.call_id });
|
||
return;
|
||
}
|
||
|
||
const data = await api('recording', `recordings/import-from-voice-event/${encodeURIComponent(eventId)}`, {
|
||
method: 'POST',
|
||
});
|
||
$('recordingCallId').value = data.call_id;
|
||
$('recordingInteractionId').value = data.interaction_id || '';
|
||
await loadRecordings();
|
||
await openRecording(data.recording_id);
|
||
log('Запись импортирована', { recording_id: data.recording_id, event_id: eventId });
|
||
} catch (err) {
|
||
setRecordingPreviewEmpty(`Импорт не выполнен: ${err.message}`);
|
||
log('Импорт записи не выполнен', { error: err.message });
|
||
}
|
||
}
|
||
|
||
async function archiveRecording() {
|
||
if (!state.selectedRecordingId) {
|
||
setRecordingPreviewEmpty('Сначала откройте запись.');
|
||
return;
|
||
}
|
||
try {
|
||
const data = await api('recording', `recordings/${encodeURIComponent(state.selectedRecordingId)}/archive`, {
|
||
method: 'POST',
|
||
});
|
||
renderRecordingMeta(data);
|
||
await loadRecordings();
|
||
log('Запись отправлена в архив', { recording_id: data.recording_id });
|
||
} catch (err) {
|
||
log('Не удалось отправить запись в архив', { error: err.message });
|
||
}
|
||
}
|
||
|
||
async function downloadRecording() {
|
||
if (!state.selectedRecordingId) {
|
||
setRecordingPreviewEmpty('Сначала откройте запись.');
|
||
return;
|
||
}
|
||
try {
|
||
const response = await fetchBinary(
|
||
'recording',
|
||
`recordings/${encodeURIComponent(state.selectedRecordingId)}/content?disposition=attachment`,
|
||
);
|
||
const blob = await response.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
const contentDisposition = response.headers.get('Content-Disposition') || '';
|
||
const fileNameMatch = /filename=\"?([^\";]+)\"?/i.exec(contentDisposition);
|
||
link.href = url;
|
||
link.download = fileNameMatch?.[1] || `${state.selectedRecordingId}.wav`;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
URL.revokeObjectURL(url);
|
||
log('Запись скачана', { recording_id: state.selectedRecordingId });
|
||
} catch (err) {
|
||
log('Не удалось скачать запись', { error: err.message });
|
||
}
|
||
}
|
||
|
||
function wire() {
|
||
$('loginBtn').addEventListener('click', login);
|
||
$('corporateLoginBtn').addEventListener('click', startCorporateLogin);
|
||
$('refreshBtn').addEventListener('click', async () => {
|
||
await Promise.all([loadRealtime(), loadKpi(), loadLiveCalls(), loadAgents(), loadRecordings(), refreshKnowledgeBase(true)]);
|
||
log('Данные супервизора обновлены');
|
||
});
|
||
$('loadRealtimeBtn').addEventListener('click', loadRealtime);
|
||
$('loadKpiBtn').addEventListener('click', loadKpi);
|
||
$('loadLiveCallsBtn').addEventListener('click', () => loadLiveCalls());
|
||
$('loadAgentsBtn').addEventListener('click', loadAgents);
|
||
$('setAgentBtn').addEventListener('click', setAgentState);
|
||
$('saveQueueMetricsBtn').addEventListener('click', saveQueueMetrics);
|
||
$('createKbCategoryBtn').addEventListener('click', createKnowledgeCategory);
|
||
$('loadKbBtn').addEventListener('click', () => refreshKnowledgeBase());
|
||
$('searchKbBtn').addEventListener('click', () => loadKnowledgeArticles());
|
||
$('saveKbArticleBtn').addEventListener('click', saveKnowledgeArticle);
|
||
$('resetKbArticleBtn').addEventListener('click', resetKnowledgeArticleForm);
|
||
$('kbArticlesList').addEventListener('click', openKnowledgeArticleFromList);
|
||
$('importRecordingBtn').addEventListener('click', importRecording);
|
||
$('loadRecordingsBtn').addEventListener('click', loadRecordings);
|
||
$('archiveRecordingBtn').addEventListener('click', archiveRecording);
|
||
$('downloadRecordingBtn').addEventListener('click', downloadRecording);
|
||
$('logoutBtn').addEventListener('click', logout);
|
||
window.addEventListener('message', handleOidcMessage);
|
||
}
|
||
|
||
async function init() {
|
||
if (!restoreStoredSession()) {
|
||
window.location.href = '/';
|
||
return;
|
||
}
|
||
if (!ensurePageAccess()) {
|
||
return;
|
||
}
|
||
wire();
|
||
updateSessionInfo();
|
||
setRecordingPreviewEmpty('');
|
||
resetKnowledgeArticleForm();
|
||
await Promise.all([
|
||
checkGateway(),
|
||
loadOidcConfig(),
|
||
loadRealtime(),
|
||
loadKpi(),
|
||
loadLiveCalls(true),
|
||
loadAgents(),
|
||
loadRecordings(),
|
||
refreshKnowledgeBase(true),
|
||
]);
|
||
window.setInterval(() => {
|
||
void loadLiveCalls(true);
|
||
}, 5000);
|
||
log('Пульт супервизора готов');
|
||
}
|
||
|
||
init();
|