const state = { user: 'analyst', role: 'analyst', token: null, authSource: 'local', fullName: null, analytics: { preset: '7d', fromTs: '', toTs: '', queueId: 'all', channel: 'all', compareMode: 'previous', trendMetric: 'volume', voiceNameTrendMetric: 'scenario_calls', aiTrendMetric: 'containment_rate', agentTrendMetric: 'interactions_per_agent', overview: null, compare: null, trend: [], voiceNameOverview: null, voiceNameCompare: null, voiceNameTrend: [], aiOverview: null, aiCompare: null, aiTrend: [], agentOverview: null, agentCompare: null, agentTrend: [], channelRows: [], queueRows: [], agentRows: [], coverage: null, queueOptions: [], savedViews: [], activeViewId: '', lastRangeMeta: null, trendInterval: 'day', agentTrendInterval: 'day', statusMessage: '', mockData: null, mockDataError: '', loading: false, error: '', voiceNameError: '', voiceNameTrendError: '', aiError: '', agentError: '', agentTrendError: '', queueAccessError: '', requestId: 0, }, drilldown: { open: false, mode: 'interaction', sourceType: '', sourceLabel: '', sourceValue: '', metric: '', filters: null, coverage: null, metricNote: '', items: [], total: 0, limit: 12, offset: 0, selectedInteractionId: '', selectedInteraction: null, selectedLinkedInteraction: null, selectedTimeline: [], loading: false, detailLoading: false, exporting: false, notice: '', error: '', }, oidc: { enabled: false, loginPath: '/auth/oidc/start?return_mode=popup', providerLabel: 'Keycloak', }, }; const $ = (id) => document.getElementById(id); const SESSION_STORAGE_KEY = 'cc_session'; const DRILLDOWN_PAGE_SIZE = 12; const DRILLDOWN_EXPORT_BATCH_SIZE = 100; const DRILLDOWN_EXPORT_SOFT_CAP = 1000; const ANALYTICS_TREND_OPTIONS = ['volume', 'SL', 'ASA', 'Abandon']; const VOICE_NAME_TREND_OPTIONS = [ 'scenario_calls', 'start_capture_rate', 'downstream_rescue_rate', 'handoff_unconfirmed_rate', 'manual_correction_rate', ]; const AI_ANALYTICS_TREND_OPTIONS = [ 'containment_rate', 'handoff_rate', 'human_touched_rate', 'ai_latency_avg_ms', 'closed_without_operator_rate', ]; const AGENT_ANALYTICS_TREND_OPTIONS = [ 'interactions_per_agent', 'agents_with_activity', 'avg_handle_seconds', 'fcr_rate', ]; const ENABLE_ANALYTICS_MOCK_DATA = true; const ANALYTICS_MOCK_DATA_URL = '/analyst/assets/mock-analytics.json'; const ANALYTICS_DEEP_LINK_KEYS = [ 'preset', 'from', 'to', 'queue', 'channel', 'compare', 'trend', 'voice_name_trend', 'ai_trend', 'agent_trend', 'view', 'dd', 'dd_mode', 'dd_source', 'dd_value', 'dd_metric', 'dd_slice', 'dd_reason', 'dd_status', 'dd_q', 'dd_sort_by', 'dd_sort_dir', 'dd_offset', 'dd_selected', 'dd_agent', ]; let analyticsDrilldownSearchDebounce = 0; let analyticsDrilldownReturnFocus = null; let analyticsUrlSyncSuspended = false; 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: 'Email' }, ]; const ANALYTICS_PRESET_LABELS = { today: 'Сегодня', '7d': 'Последние 7 дней', '30d': 'Последние 30 дней', }; const ANALYTICS_COMPARE_MODE_LABELS = { previous: 'С предыдущим окном', off: 'Только текущий срез', }; const ANALYTICS_TREND_LABELS = { volume: 'Обращения', SL: 'SLA', ASA: 'Среднее ожидание', Abandon: 'Потери', }; const VOICE_NAME_TREND_LABELS = { scenario_calls: 'Звонки в сценарии', start_capture_rate: 'Имя взято сразу', downstream_rescue_rate: 'Имя добрал AI после follow-up', handoff_unconfirmed_rate: 'Передача без подтверждённого имени', manual_correction_rate: 'Ручное исправление', }; const VOICE_NAME_METRIC_META = { scenario_calls: { unit: 'count', better: 'up', note: 'Все voice-звонки, прошедшие через сценарий сбора имени.' }, start_capture_rate: { unit: 'pct', better: 'up', note: 'Доля звонков, где имя удалось взять сразу на стартовом этапе.' }, downstream_rescue_rate: { unit: 'pct', better: 'up', note: 'Доля кейсов, где AI успешно добрал имя после стартового follow-up.' }, handoff_unconfirmed_rate: { unit: 'pct', better: 'down', note: 'Доля звонков, переданных оператору без подтверждённого имени.' }, manual_correction_rate: { unit: 'pct', better: 'down', note: 'Как часто оператору пришлось исправлять имя вручную.' }, }; const VOICE_NAME_FUNNEL_LABELS = { scenario_calls: 'Звонки в сценарии', start_obtained: 'Имя взято сразу', needed_downstream: 'Потребовался AI после старта', downstream_ai_obtained: 'Имя добрал AI после follow-up', handoff_confirmed_name: 'Передача с подтверждённым именем', handoff_unconfirmed_name: 'Передача без подтверждённого имени', }; const VOICE_NAME_HANDOFF_LABELS = { confirmed_name: 'Передача с подтверждённым именем', unconfirmed_name: 'Передача без подтверждённого имени', }; 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: 'Объём обращений в интервале' }, }; const AI_ANALYTICS_TREND_LABELS = { containment_rate: 'Закрыто AI', handoff_rate: 'Передано оператору', ai_latency_avg_ms: 'Задержка AI', closed_without_operator_rate: 'Закрыто без оператора', human_touched_rate: 'С участием оператора', }; const AI_ANALYTICS_METRIC_META = { containment_rate: { unit: 'pct', better: 'up', note: 'Доля AI-сессий, закрытых самим AI без передачи оператору и без назначения исполнителя.', }, handoff_rate: { unit: 'pct', better: 'down', note: 'Доля AI-сессий, которые завершились передачей оператору или ушли в ручное ведение.', }, ai_latency_avg_ms: { unit: 'ms', better: 'down', note: 'Средняя задержка AI-ответа по ответам модели.', }, closed_without_operator_rate: { unit: 'pct', better: 'up', note: 'Среди закрытых AI-связанных обращений доля кейсов без назначения оператора.', }, human_touched_rate: { unit: 'pct', better: 'down', note: 'Доля сессий, где участвовал оператор: через назначение или ручной takeover.', }, }; 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', 'analyst']; 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 syncSessionInputs() { const sessionUser = $('sessionUser'); const sessionRole = $('sessionRole'); if (sessionUser) { sessionUser.value = state.user; } if (sessionRole) { sessionRole.value = state.role; } } 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; syncSessionInputs(); updateProfileMeta(); applyRoleNavigation(); return true; } catch { clearStoredSession(); return false; } } function logout() { clearStoredSession(); window.location.href = '/'; } function syncSessionFromInputs() { const sessionUser = $('sessionUser'); const sessionRole = $('sessionRole'); state.user = sessionUser?.value.trim() || state.user; state.role = sessionRole?.value || state.role; } 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 = '') { const box = $('sessionInfo'); if (!box) { return; } if (!state.token) { box.textContent = 'Токен: отсутствует'; return; } const source = state.authSource === 'oidc' ? 'корпоративный' : 'локальный'; box.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; syncSessionInputs(); updateSessionInfo(data.provider ? `провайдер: ${data.provider}` : ''); updateProfileMeta(); persistSession(); applyRoleNavigation(); ensurePageAccess(); } function buildProxyHeaders(extra = {}) { syncSessionFromInputs(); const headers = { 'X-User': state.user, 'X-Role': state.role, ...extra, }; if (state.token) { headers.Authorization = `Bearer ${state.token}`; } return headers; } async function api(service, path, options = {}) { const headers = buildProxyHeaders({ 'Content-Type': 'application/json', ...(options.headers || {}), }); 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; } function contentDispositionFilename(value) { if (!value) { return ''; } const match = /filename=\"?([^\";]+)\"?/i.exec(value); return match ? decodeURIComponent(match[1]) : ''; } async function downloadProxyFile(service, path, fallbackFilename) { const response = await fetch(`/proxy/${service}/${path}`, { headers: buildProxyHeaders(), }); if (!response.ok) { let detail = ''; try { detail = JSON.stringify(await response.json()); } catch { detail = await response.text(); } throw new Error(`${response.status}: ${detail}`); } const blob = await response.blob(); const filename = contentDispositionFilename(response.headers.get('content-disposition')) || fallbackFilename; const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.append(link); link.click(); link.remove(); URL.revokeObjectURL(url); return filename; } async function checkGateway() { const pill = $('gatewayStatus'); if (!pill) { return; } try { const response = await fetch('/health'); const data = await response.json(); pill.textContent = `Данные: ${data.status === 'ok' ? 'готовы' : data.status}`; } catch { pill.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'; } catch { state.oidc.enabled = false; } } function handleOidcMessage(event) { if (!event?.data || typeof event.data !== 'object') { return; } if (event.data.type === 'oidc-login' && event.data.access_token) { applyAuthenticatedSession(event.data); return; } if (event.data.type === 'oidc-error') { updateSessionInfo(`ошибка SSO: ${event.data.message || 'неизвестно'}`); } } 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: {}, }, coverage: { metric_status: {}, supported_channels: {}, exact_rows: 0, total_rows: 0, note: null, metric_details: {}, }, filters: {}, }; } function emptyAiAnalyticsOverview(fromTs = null, toTs = null, channel = 'all', queueId = null) { return { window: { from_ts: fromTs, to_ts: toTs, }, filters: { from_ts: fromTs, to_ts: toTs, queue_id: queueId, channel, }, totals: { sessions_started: 0, sessions_contained: 0, sessions_handoff: 0, sessions_closed: 0, sessions_closed_without_operator: 0, assistant_turns: 0, }, metrics: { containment_rate: 0, handoff_rate: 0, ai_latency_avg_ms: null, ai_latency_p95_ms: null, closed_without_operator_rate: 0, human_touched_rate: 0, }, breakdowns: { by_channel: [], by_outcome: [], by_handoff_reason: [], }, coverage: { sessions_with_interaction_id: 0, sessions_with_queue_id: 0, sessions_with_latency_turns: 0, sessions_with_terminal_state: 0, sessions_with_handoff_reason: 0, }, }; } function emptyAiAnalyticsTimeseries(metric = 'containment_rate', interval = 'day', fromTs = null, toTs = null, channel = 'all', queueId = null) { return { metric, interval, filters: { from_ts: fromTs, to_ts: toTs, queue_id: queueId, channel, }, points: [], }; } function emptyAgentAnalyticsOverview(fromTs = null, toTs = null, channel = 'all', queueId = null) { return { window: { from_ts: fromTs, to_ts: toTs, }, filters: { from_ts: fromTs, to_ts: toTs, queue_id: queueId, channel, sort_by: 'interactions_total', sort_dir: 'desc', limit: 25, }, totals: { agents_total: 0, agents_with_activity: 0, interactions_total: 0, answered_total: 0, closed_total: 0, ready_now: 0, busy_now: 0, break_now: 0, offline_now: 0, avg_interactions_per_agent: 0, avg_handle_seconds: null, avg_fcr_rate: null, }, state_snapshot: { by_state: {}, updated_at: null, }, breakdowns: { by_team: [], by_shift: [], }, items: [], }; } function emptyAgentAnalyticsTimeseries(metric = 'interactions_per_agent', interval = 'day') { return { metric, interval, filters: { from_ts: null, to_ts: null, queue_id: null, channel: 'all', metric, interval, }, points: [], }; } function emptyVoiceNameAnalyticsOverview(fromTs = null, toTs = null, queueId = null, language = null) { return { window: { from_ts: fromTs, to_ts: toTs, }, filters: { from_ts: fromTs, to_ts: toTs, queue_id: queueId, language, }, totals: { scenario_calls: 0, start_obtained: 0, downstream_ai_obtained: 0, followup_required: 0, name_not_obtained: 0, manual_corrected: 0, handoff_confirmed_name: 0, handoff_unconfirmed_name: 0, needed_downstream: 0, }, metrics: { start_capture_rate: 0, downstream_rescue_rate: 0, handoff_unconfirmed_rate: 0, manual_correction_rate: 0, }, breakdowns: { funnel: [], by_language: [], by_queue: [], handoff: [], }, coverage: { sessions_with_start_decision: 0, sessions_with_final_ai_state: 0, sessions_with_manual_overlay: 0, note: null, }, }; } function emptyVoiceNameAnalyticsTimeseries(metric = 'scenario_calls', interval = 'day', fromTs = null, toTs = null, queueId = null, language = null) { return { metric, interval, filters: { from_ts: fromTs, to_ts: toTs, queue_id: queueId, language, }, points: [], }; } function emptyAnalyticsMetricCoverage() { return { status: 'unavailable', supported_channels: [], exact_rows: 0, total_rows: 0, note: null, }; } function emptyDrilldownState() { return { open: false, mode: 'interaction', sourceType: '', sourceLabel: '', sourceValue: '', metric: '', filters: null, coverage: null, metricNote: '', items: [], total: 0, limit: DRILLDOWN_PAGE_SIZE, offset: 0, selectedInteractionId: '', selectedInteraction: null, selectedLinkedInteraction: null, selectedTimeline: [], loading: false, detailLoading: false, exporting: false, notice: '', error: '', }; } 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 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.compareMode = $('analyticsCompareMode').value || 'previous'; state.analytics.trendMetric = $('analyticsTrendMetric').value || 'volume'; state.analytics.voiceNameTrendMetric = $('voiceNameAnalyticsTrendMetric')?.value || 'scenario_calls'; state.analytics.aiTrendMetric = $('aiAnalyticsTrendMetric')?.value || 'containment_rate'; state.analytics.agentTrendMetric = $('agentAnalyticsTrendMetric')?.value || 'interactions_per_agent'; } function syncAnalyticsControlsFromState() { $('analyticsPreset').value = state.analytics.preset || '7d'; $('analyticsFrom').value = state.analytics.fromTs || ''; $('analyticsTo').value = state.analytics.toTs || ''; $('analyticsChannel').value = state.analytics.channel || 'all'; $('analyticsCompareMode').value = state.analytics.compareMode || 'previous'; $('analyticsTrendMetric').value = state.analytics.trendMetric || 'volume'; if ($('voiceNameAnalyticsTrendMetric')) { $('voiceNameAnalyticsTrendMetric').value = state.analytics.voiceNameTrendMetric || 'scenario_calls'; } if ($('aiAnalyticsTrendMetric')) { $('aiAnalyticsTrendMetric').value = state.analytics.aiTrendMetric || 'containment_rate'; } if ($('agentAnalyticsTrendMetric')) { $('agentAnalyticsTrendMetric').value = state.analytics.agentTrendMetric || 'interactions_per_agent'; } renderAnalyticsQueueOptions(); renderSavedAnalyticsViews(); } function resetAnalyticsFilters() { state.analytics.preset = '7d'; state.analytics.fromTs = ''; state.analytics.toTs = ''; state.analytics.queueId = 'all'; state.analytics.channel = 'all'; state.analytics.compareMode = 'previous'; state.analytics.trendMetric = 'volume'; state.analytics.voiceNameTrendMetric = 'scenario_calls'; state.analytics.aiTrendMetric = 'containment_rate'; state.analytics.agentTrendMetric = 'interactions_per_agent'; state.analytics.activeViewId = ''; state.analytics.trendInterval = 'day'; state.analytics.agentTrendInterval = 'day'; state.analytics.statusMessage = ''; resetAnalyticsDrilldown(); syncAnalyticsControlsFromState(); syncAnalyticsUrlState(); } function analyticsCurrentSnapshot() { return { preset: state.analytics.preset || '7d', fromTs: state.analytics.fromTs || '', toTs: state.analytics.toTs || '', queueId: state.analytics.queueId || 'all', channel: state.analytics.channel || 'all', compareMode: state.analytics.compareMode || 'previous', trendMetric: state.analytics.trendMetric || 'volume', voiceNameTrendMetric: state.analytics.voiceNameTrendMetric || 'scenario_calls', aiTrendMetric: state.analytics.aiTrendMetric || 'containment_rate', agentTrendMetric: state.analytics.agentTrendMetric || 'interactions_per_agent', }; } function analyticsMockEnabled() { return ENABLE_ANALYTICS_MOCK_DATA === true; } function analyticsMockData() { return state.analytics.mockData || {}; } async function ensureAnalyticsMockDataLoaded() { if (!analyticsMockEnabled()) { return null; } if (state.analytics.mockData) { return state.analytics.mockData; } try { const response = await fetch(ANALYTICS_MOCK_DATA_URL, { cache: 'no-store' }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const payload = await response.json(); state.analytics.mockData = payload && typeof payload === 'object' ? payload : {}; state.analytics.mockDataError = ''; return state.analytics.mockData; } catch (error) { state.analytics.mockData = {}; state.analytics.mockDataError = error?.message || 'Не удалось загрузить mock-analytics.json'; return state.analytics.mockData; } } function mockAnalyticsScale(range) { if (!range?.from || !range?.to) { return 1; } const dayMs = 24 * 60 * 60 * 1000; const duration = Math.max(range.to.getTime() - range.from.getTime(), dayMs); return Math.max(0.6, duration / (7 * dayMs)); } function mockAnalyticsQueueOptions() { const queueOptions = Array.isArray(analyticsMockData().queue_options) ? analyticsMockData().queue_options : []; return queueOptions.map((item) => ({ ...item })); } function mockAnalyticsAgents() { return Array.isArray(analyticsMockData().agents) ? analyticsMockData().agents.slice() : []; } function mockAnalyticsQueueFactor(queueId = state.analytics.queueId) { const factors = analyticsMockData().queue_factors || {}; if (!queueId || queueId === 'all') { return 1; } return factors[queueId] || 0.84; } function mockAnalyticsChannelBase() { return analyticsMockData().channel_base || {}; } function mockAiReasonLabel(reasonKey) { const labels = analyticsMockData().ai_reason_labels || {}; return labels[reasonKey] || reasonKey || 'Другая причина'; } function mockInteractionStatus(index = 0) { const statuses = Array.isArray(analyticsMockData().interaction_statuses) ? analyticsMockData().interaction_statuses : []; if (!statuses.length) { return 'closed'; } return statuses[index % statuses.length]; } function mockAnalyticsCoverage(totalRows, note = analyticsMockData().coverage_note || 'Показаны демонстрационные данные для временного наполнения витрины.') { const metricNames = ['total', 'answered', 'SL', 'ASA', 'AHT', 'Abandon', 'FCR', 'DigitalShare']; const metricStatus = {}; const supportedChannels = {}; const metricDetails = {}; metricNames.forEach((metric) => { metricStatus[metric] = 'exact'; supportedChannels[metric] = ['voice', 'telegram', 'whatsapp', 'webchat', 'email']; metricDetails[metric] = { status: 'exact', supported_channels: supportedChannels[metric], exact_rows: totalRows, total_rows: totalRows, note, }; }); return { metric_status: metricStatus, supported_channels: supportedChannels, exact_rows: totalRows, total_rows: totalRows, note, metric_details: metricDetails, }; } function buildMockAnalyticsOverview(rangeMeta, options = {}) { const previous = Boolean(options.previous); const queueId = options.queueId ?? state.analytics.queueId; const channel = options.channel ?? state.analytics.channel; const scale = mockAnalyticsScale(previous ? rangeMeta.previous : rangeMeta.current) * mockAnalyticsQueueFactor(queueId) * (previous ? 0.89 : 1); const baseByChannel = mockAnalyticsChannelBase(); const channelKeys = channel && channel !== 'all' ? [channel] : Object.keys(baseByChannel); const byChannel = {}; channelKeys.forEach((key) => { const base = baseByChannel[key] || { total: 0, answered: 0, abandoned: 0 }; byChannel[key] = { total: Math.round(base.total * scale), answered: Math.round(base.answered * scale), abandoned: Math.round(base.abandoned * scale), }; }); const totals = Object.values(byChannel).reduce((acc, item) => ({ total: acc.total + Number(item.total || 0), answered: acc.answered + Number(item.answered || 0), abandoned: acc.abandoned + Number(item.abandoned || 0), }), { total: 0, answered: 0, abandoned: 0 }); const digitalTotal = Object.entries(byChannel) .filter(([key]) => key !== 'voice') .reduce((acc, [, item]) => acc + Number(item.total || 0), 0); const answerRate = totals.total ? (totals.answered / totals.total) * 100 : 0; const abandonRate = totals.total ? (totals.abandoned / totals.total) * 100 : 0; const sl = Math.max(58, Math.min(96, 84 - (queueId === 'vip_kz' ? 4 : 0) - (channel === 'email' ? 9 : 0) + (previous ? -3 : 0))); const asa = Math.max(10, 22 + (queueId === 'support_kz' ? 6 : 0) + (channel === 'email' ? 12 : channel === 'voice' ? 3 : 0) + (previous ? 4 : 0)); const aht = Math.max(120, 332 + (queueId === 'vip_kz' ? 86 : 0) + (channel === 'email' ? 112 : channel === 'voice' ? 38 : 0) + (previous ? 24 : 0)); const fcr = Math.max(48, Math.min(92, 73 - (queueId === 'support_kz' ? 5 : 0) + (channel === 'telegram' ? 4 : 0) + (previous ? -2 : 0))); return { window: { from: (previous ? rangeMeta.previous.from : rangeMeta.current.from).toISOString(), to: (previous ? rangeMeta.previous.to : rangeMeta.current.to).toISOString(), }, volume: totals, kpi: { SL: Number(sl.toFixed(2)), ASA: Number(asa.toFixed(2)), AHT: Number(aht.toFixed(2)), Abandon: Number(abandonRate.toFixed(2)), FCR: Number(fcr.toFixed(2)), AnswerRate: Number(answerRate.toFixed(2)), WaitP95: Number((asa * 2.6).toFixed(2)), HandleP95: Number((aht * 1.9).toFixed(2)), Occupancy: Number((66 + (queueId === 'support_kz' ? 8 : 0) + (previous ? -3 : 0)).toFixed(2)), DigitalShare: totals.total ? Number(((digitalTotal / totals.total) * 100).toFixed(2)) : 0, }, breakdowns: { by_channel: byChannel, }, coverage: mockAnalyticsCoverage(totals.total), filters: { queue_id: queueId === 'all' ? null : queueId, channel, }, }; } function buildMockTrendPayloads(rangeMeta, metric) { const overview = buildMockAnalyticsOverview(rangeMeta); const baseValue = metricValueFromPayload(metric, overview); const buckets = buildTrendBuckets(rangeMeta.current, rangeMeta.preset, rangeMeta.custom); const count = Math.max(buckets.length, 1); return buckets.map((bucket, index) => { const wave = 0.88 + (((index % 5) - 2) * 0.06) + (index / Math.max(count - 1, 1)) * 0.12; const nextValue = Math.max(0, baseValue * wave); return { label: bucket.label, range: bucket.range, payload: metric === 'volume' || metric === 'total' ? { ...emptyKpiEnvelope(bucket.range.from.toISOString(), bucket.range.to.toISOString()), volume: { total: Math.round(nextValue), answered: Math.round(nextValue * 0.92), abandoned: Math.round(nextValue * 0.08), }, kpi: { ...emptyKpiEnvelope().kpi, SL: 82 + (index % 4), ASA: 16 + index, AHT: 320 + index * 8, Abandon: 6 + (index % 3), FCR: 72 + (index % 5), DigitalShare: 44 + (index % 4), }, } : { ...emptyKpiEnvelope(bucket.range.from.toISOString(), bucket.range.to.toISOString()), volume: { total: Math.round(overview.volume.total / count), answered: Math.round((overview.volume.answered || 0) / count), abandoned: Math.round((overview.volume.abandoned || 0) / count), }, kpi: { ...emptyKpiEnvelope().kpi, [metric]: Number(nextValue.toFixed(2)), }, }, }; }); } function buildMockQueueRows(queueOptions, rangeMeta) { const overview = buildMockAnalyticsOverview(rangeMeta); const channel = state.analytics.channel; return queueOptions.map((queue, index) => { const factor = mockAnalyticsQueueFactor(queue.queue_id) * (channel === 'all' ? 1 : 0.82); const total = Math.round(overview.volume.total * factor * (0.34 + index * 0.11)); const answered = Math.round(total * (0.91 - index * 0.03)); return { queue_id: queue.queue_id, name: queue.name, total, answered, SL: Number((84 - index * 4).toFixed(2)), Abandon: Number((6 + index * 1.4).toFixed(2)), }; }).sort((a, b) => b.total - a.total); } function buildMockAgentOverview(rangeMeta, options = {}) { const previous = Boolean(options.previous); const queueId = options.queueId ?? state.analytics.queueId; const queueOptions = mockAnalyticsQueueOptions(); const agents = mockAnalyticsAgents(); const stateCycle = Array.isArray(analyticsMockData().agent_state_cycle) ? analyticsMockData().agent_state_cycle : []; const rows = agents.map((agentId, index) => { const queue = queueOptions[index % Math.max(queueOptions.length, 1)] || { queue_id: 'sales_kz', name: 'Продажи KZ' }; const dominantQueue = queueId !== 'all' ? queueId : queue.queue_id; const interactionsTotal = Math.round((24 - index * 2) * (previous ? 0.88 : 1)); const answeredTotal = Math.max(0, Math.round(interactionsTotal * (0.88 - index * 0.015))); return { agent_id: agentId, interactions_total: interactionsTotal, answered_total: answeredTotal, closed_total: Math.max(0, answeredTotal - (index % 3)), avg_handle_seconds: Number((290 + index * 24 + (previous ? 18 : 0)).toFixed(2)), fcr_rate: Number((78 - index * 3 + (previous ? -2 : 0)).toFixed(2)), current_state: stateCycle.length ? stateCycle[index % stateCycle.length] : 'READY', current_queue_id: dominantQueue, dominant_queue_id: dominantQueue, last_activity_at: new Date(rangeMeta.current.to.getTime() - index * 37 * 60 * 1000).toISOString(), }; }).filter((item) => queueId === 'all' || item.current_queue_id === queueId); const agentsWithActivity = rows.filter((item) => item.interactions_total > 0).length; const interactionsTotal = rows.reduce((acc, item) => acc + item.interactions_total, 0); const answeredTotal = rows.reduce((acc, item) => acc + item.answered_total, 0); const readyNow = rows.filter((item) => item.current_state === 'READY').length; const busyNow = rows.filter((item) => item.current_state === 'BUSY').length; const breakNow = rows.filter((item) => item.current_state === 'BREAK').length; const offlineNow = rows.filter((item) => item.current_state === 'OFFLINE').length; const byTeam = mockAnalyticsQueueOptions() .filter((queue) => queueId === 'all' || queue.queue_id === queueId) .map((queue, index) => { const teamRows = rows.filter((item) => item.current_queue_id === queue.queue_id); const teamInteractions = teamRows.reduce((acc, item) => acc + item.interactions_total, 0); return { team_key: queue.queue_id, label: queue.name, agents_total: teamRows.length, agents_with_activity: teamRows.filter((item) => item.interactions_total > 0).length, interactions_total: teamInteractions, answered_total: teamRows.reduce((acc, item) => acc + item.answered_total, 0), avg_handle_seconds: teamRows.length ? Number((312 + index * 22).toFixed(2)) : null, fcr_rate: teamRows.length ? Number((76 - index * 4).toFixed(2)) : null, ready_now: teamRows.filter((item) => item.current_state === 'READY').length, busy_now: teamRows.filter((item) => item.current_state === 'BUSY').length, break_now: teamRows.filter((item) => item.current_state === 'BREAK').length, offline_now: teamRows.filter((item) => item.current_state === 'OFFLINE').length, }; }) .filter((item) => item.agents_total > 0); const shiftSeeds = Array.isArray(analyticsMockData().agent_shift_rows) ? analyticsMockData().agent_shift_rows : []; const byShift = shiftSeeds.map((seed) => ({ shift_key: seed.shift_key, label: seed.label, agents_with_activity: Math.max(1, Math.round(Number(seed.agents_with_activity || 0) * (previous ? 0.92 : 1))), interactions_total: Math.max(0, Math.round(Number(seed.interactions_total || 0) * (previous ? 0.9 : 1))), answered_total: Math.max(0, Math.round(Number(seed.answered_total || 0) * (previous ? 0.9 : 1))), avg_handle_seconds: Number((Number(seed.avg_handle_seconds || 0) + (previous ? 18 : 0)).toFixed(2)), fcr_rate: Number((Number(seed.fcr_rate || 0) + (previous ? -2 : 0)).toFixed(2)), })); return { window: { from_ts: (previous ? rangeMeta.previous.from : rangeMeta.current.from).toISOString(), to_ts: (previous ? rangeMeta.previous.to : rangeMeta.current.to).toISOString(), }, filters: { from_ts: (previous ? rangeMeta.previous.from : rangeMeta.current.from).toISOString(), to_ts: (previous ? rangeMeta.previous.to : rangeMeta.current.to).toISOString(), queue_id: queueId === 'all' ? null : queueId, channel: state.analytics.channel, sort_by: 'interactions_total', sort_dir: 'desc', limit: 25, }, totals: { agents_total: rows.length, agents_with_activity: agentsWithActivity, interactions_total: interactionsTotal, answered_total: answeredTotal, closed_total: rows.reduce((acc, item) => acc + item.closed_total, 0), ready_now: readyNow, busy_now: busyNow, break_now: breakNow, offline_now: offlineNow, avg_interactions_per_agent: rows.length ? Number((interactionsTotal / rows.length).toFixed(2)) : 0, avg_handle_seconds: rows.length ? Number((rows.reduce((acc, item) => acc + Number(item.avg_handle_seconds || 0), 0) / rows.length).toFixed(2)) : null, avg_fcr_rate: rows.length ? Number((rows.reduce((acc, item) => acc + Number(item.fcr_rate || 0), 0) / rows.length).toFixed(2)) : null, }, state_snapshot: { by_state: { READY: readyNow, BUSY: busyNow, BREAK: breakNow, OFFLINE: offlineNow, }, updated_at: rangeMeta.current.to.toISOString(), }, breakdowns: { by_team: byTeam, by_shift: byShift, }, items: rows, }; } function buildMockAgentTrend(rangeMeta, metric) { const interval = analyticsTimeseriesIntervalForRange(rangeMeta); const points = []; let cursor = new Date(interval === 'hour' ? rangeMeta.current.from.getTime() : startOfDay(rangeMeta.current.from).getTime()); while (cursor < rangeMeta.current.to) { const index = points.length; let value = 0; if (metric === 'agents_with_activity') { value = 5 + (index % 4); } else if (metric === 'avg_handle_seconds') { value = 296 + index * 8; } else if (metric === 'fcr_rate') { value = 69 + (index % 5) * 2.2; } else { value = 3.4 + (index % 4) * 0.42; } points.push({ ts: cursor.toISOString(), value: Number(value.toFixed(2)), agents_with_activity: 5 + (index % 3), interactions_total: 18 + index * 2, }); cursor = new Date(cursor.getTime() + (interval === 'hour' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000)); } return { metric, interval, filters: { from_ts: rangeMeta.current.from.toISOString(), to_ts: rangeMeta.current.to.toISOString(), queue_id: state.analytics.queueId === 'all' ? null : state.analytics.queueId, channel: state.analytics.channel, metric, interval, }, points, }; } function buildMockAiOverview(rangeMeta, options = {}) { const previous = Boolean(options.previous); const channel = options.channel ?? state.analytics.channel; if (!aiAnalyticsSupportedChannel(channel)) { return emptyAiAnalyticsOverview( (previous ? rangeMeta.previous.from : rangeMeta.current.from).toISOString(), (previous ? rangeMeta.previous.to : rangeMeta.current.to).toISOString(), channel, state.analytics.queueId === 'all' ? null : state.analytics.queueId, ); } const scale = mockAnalyticsScale(previous ? rangeMeta.previous : rangeMeta.current) * (previous ? 0.91 : 1); const aiChannelRows = Array.isArray(analyticsMockData().ai_channel_rows) ? analyticsMockData().ai_channel_rows : []; const baseRows = aiChannelRows.map((item) => ({ ...item, sessions_started: Math.round(Number(item.sessions_started || 0) * scale), ai_only_sessions: Math.round(Number(item.ai_only_sessions || 0) * scale), human_touched_sessions: Math.round(Number(item.human_touched_sessions || 0) * scale), })).filter((item) => channel === 'all' || item.channel === channel); const sessionsStarted = baseRows.reduce((acc, item) => acc + item.sessions_started, 0); const sessionsContained = Math.round(baseRows.reduce((acc, item) => acc + item.sessions_started * (item.containment_rate / 100), 0)); const sessionsHandoff = Math.round(baseRows.reduce((acc, item) => acc + item.sessions_started * (item.handoff_rate / 100), 0)); const sessionsClosedWithoutOperator = Math.round(baseRows.reduce((acc, item) => acc + item.sessions_started * (item.closed_without_operator_rate / 100), 0)); const humanTouched = baseRows.reduce((acc, item) => acc + item.human_touched_sessions, 0); const byOutcome = [ { outcome: 'contained', label: 'Закрыто AI', sessions: sessionsContained, share: sessionsStarted ? (sessionsContained / sessionsStarted) * 100 : 0 }, { outcome: 'handoff', label: 'Передано оператору', sessions: sessionsHandoff, share: sessionsStarted ? (sessionsHandoff / sessionsStarted) * 100 : 0 }, { outcome: 'human_touched', label: 'С участием оператора', sessions: humanTouched, share: sessionsStarted ? (humanTouched / sessionsStarted) * 100 : 0 }, { outcome: 'closed_without_operator', label: 'Закрыто без оператора', sessions: sessionsClosedWithoutOperator, share: sessionsStarted ? (sessionsClosedWithoutOperator / sessionsStarted) * 100 : 0 }, { outcome: 'active', label: 'Активные', sessions: Math.max(8, Math.round(sessionsStarted * 0.06)), share: 6.1 }, { outcome: 'error', label: 'Ошибки', sessions: Math.max(3, Math.round(sessionsStarted * 0.02)), share: 1.9 }, ]; const byReason = [ 'requested_human', 'knowledge_or_tool_gap', 'policy_or_sensitive', 'delivery_or_runtime_error', 'manual_claim', ].map((reasonKey, index) => ({ reason_key: reasonKey, label: mockAiReasonLabel(reasonKey), sessions: Math.max(4, Math.round(sessionsHandoff * (0.34 - index * 0.05))), share: Math.max(6, Number((32 - index * 5.2).toFixed(2))), })); return { window: { from_ts: (previous ? rangeMeta.previous.from : rangeMeta.current.from).toISOString(), to_ts: (previous ? rangeMeta.previous.to : rangeMeta.current.to).toISOString(), }, filters: { from_ts: (previous ? rangeMeta.previous.from : rangeMeta.current.from).toISOString(), to_ts: (previous ? rangeMeta.previous.to : rangeMeta.current.to).toISOString(), queue_id: state.analytics.queueId === 'all' ? null : state.analytics.queueId, channel, }, totals: { sessions_started: sessionsStarted, sessions_contained: sessionsContained, sessions_handoff: sessionsHandoff, sessions_closed: Math.round(sessionsStarted * 0.86), sessions_closed_without_operator: sessionsClosedWithoutOperator, assistant_turns: Math.round(sessionsStarted * 5.4), }, metrics: { containment_rate: sessionsStarted ? Number(((sessionsContained / sessionsStarted) * 100).toFixed(2)) : 0, handoff_rate: sessionsStarted ? Number(((sessionsHandoff / sessionsStarted) * 100).toFixed(2)) : 0, ai_latency_avg_ms: 1452, ai_latency_p95_ms: 2486, closed_without_operator_rate: sessionsStarted ? Number(((sessionsClosedWithoutOperator / sessionsStarted) * 100).toFixed(2)) : 0, human_touched_rate: sessionsStarted ? Number(((humanTouched / sessionsStarted) * 100).toFixed(2)) : 0, }, breakdowns: { by_channel: baseRows, by_outcome: byOutcome, by_handoff_reason: byReason, }, coverage: { sessions_with_interaction_id: Math.round(sessionsStarted * 0.94), sessions_with_queue_id: Math.round(sessionsStarted * 0.91), sessions_with_latency_turns: Math.round(sessionsStarted * 0.97), sessions_with_terminal_state: Math.round(sessionsStarted * 0.88), sessions_with_handoff_reason: Math.round(sessionsHandoff * 0.82), }, }; } function buildMockAiTrend(rangeMeta, metric) { const interval = aiAnalyticsIntervalForRange(rangeMeta); const points = []; let cursor = new Date(interval === 'hour' ? rangeMeta.current.from.getTime() : startOfDay(rangeMeta.current.from).getTime()); while (cursor < rangeMeta.current.to) { const index = points.length; let value = 0; if (metric === 'handoff_rate') { value = 21 + (index % 5) * 1.8; } else if (metric === 'human_touched_rate') { value = 32 + (index % 4) * 1.6; } else if (metric === 'ai_latency_avg_ms') { value = 1380 + index * 34; } else if (metric === 'closed_without_operator_rate') { value = 47 + (index % 4) * 1.9; } else { value = 54 + (index % 5) * 2.1; } points.push({ ts: cursor.toISOString(), value: Number(value.toFixed(2)), sessions: 24 + index * 3, assistant_turns: 118 + index * 11, }); cursor = new Date(cursor.getTime() + (interval === 'hour' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000)); } return { metric, interval, filters: { from_ts: rangeMeta.current.from.toISOString(), to_ts: rangeMeta.current.to.toISOString(), queue_id: state.analytics.queueId === 'all' ? null : state.analytics.queueId, channel: state.analytics.channel, }, points, }; } function mockVoiceNameSeed() { const seed = analyticsMockData().voice_name_flow; return seed && typeof seed === 'object' ? seed : {}; } function mockVoiceNameLabels() { const labels = analyticsMockData().voice_name_labels; return labels && typeof labels === 'object' ? labels : {}; } function mockVoiceNameLabel(key, fallback) { return mockVoiceNameLabels()[key] || fallback; } function mockVoiceNameProfile(metric, fallback) { const profile = mockVoiceNameSeed().trend_profiles?.[metric]; if (!profile || typeof profile !== 'object') { return fallback; } return { base: Number.isFinite(Number(profile.base)) ? Number(profile.base) : fallback.base, step: Number.isFinite(Number(profile.step)) ? Number(profile.step) : fallback.step, cycle: Math.max(1, Number.isFinite(Number(profile.cycle)) ? Number(profile.cycle) : fallback.cycle), }; } function voiceNameMetricRatesFromTotals(totals) { const scenarioCalls = Number(totals.scenario_calls || 0); const neededDownstream = Number(totals.needed_downstream || Math.max(0, scenarioCalls - Number(totals.start_obtained || 0))); const allHandoffs = Number(totals.handoff_confirmed_name || 0) + Number(totals.handoff_unconfirmed_name || 0); return { start_capture_rate: scenarioCalls ? Number(((Number(totals.start_obtained || 0) / scenarioCalls) * 100).toFixed(2)) : 0, downstream_rescue_rate: neededDownstream ? Number(((Number(totals.downstream_ai_obtained || 0) / neededDownstream) * 100).toFixed(2)) : 0, handoff_unconfirmed_rate: allHandoffs ? Number(((Number(totals.handoff_unconfirmed_name || 0) / allHandoffs) * 100).toFixed(2)) : 0, manual_correction_rate: allHandoffs ? Number(((Number(totals.manual_corrected || 0) / allHandoffs) * 100).toFixed(2)) : 0, }; } function buildMockVoiceNameOverview(rangeMeta, options = {}) { const previous = Boolean(options.previous); const channel = options.channel ?? state.analytics.channel; if (!(channel === 'all' || channel === 'voice')) { return emptyVoiceNameAnalyticsOverview( (previous ? rangeMeta.previous.from : rangeMeta.current.from).toISOString(), (previous ? rangeMeta.previous.to : rangeMeta.current.to).toISOString(), state.analytics.queueId === 'all' ? null : state.analytics.queueId, null, ); } const seed = mockVoiceNameSeed(); const totalSeed = seed.totals || {}; const queueId = options.queueId ?? state.analytics.queueId; const factor = mockAnalyticsScale(previous ? rangeMeta.previous : rangeMeta.current) * (previous ? 0.91 : 1) * (queueId === 'all' ? 1 : Math.max(0.48, mockAnalyticsQueueFactor(queueId))); const baseScenarioCalls = Math.max(Number(totalSeed.scenario_calls || 180), 1); const scenarioCalls = Math.max(0, Math.round(baseScenarioCalls * factor)); const startRatio = Number(totalSeed.start_obtained || 110) / baseScenarioCalls; const downstreamRatio = Number(totalSeed.downstream_ai_obtained || 36) / baseScenarioCalls; const followupRatio = Number(totalSeed.followup_required || 20) / baseScenarioCalls; const handoffConfirmedRatio = Number(totalSeed.handoff_confirmed_name || 12) / baseScenarioCalls; const handoffUnconfirmedRatio = Number(totalSeed.handoff_unconfirmed_name || 26) / baseScenarioCalls; const manualRateSeed = Number(totalSeed.manual_corrected || 10) / Math.max((Number(totalSeed.handoff_confirmed_name || 12) + Number(totalSeed.handoff_unconfirmed_name || 26)), 1); const startObtained = Math.min(scenarioCalls, Math.round(scenarioCalls * startRatio)); const downstreamAiObtained = Math.max(0, Math.round(scenarioCalls * downstreamRatio)); const followupRequired = Math.max(0, Math.round(scenarioCalls * followupRatio)); const nameNotObtained = Math.max(0, scenarioCalls - startObtained - downstreamAiObtained - followupRequired); const neededDownstream = Math.max(0, scenarioCalls - startObtained); const handoffConfirmedName = Math.max(0, Math.round(scenarioCalls * handoffConfirmedRatio)); const handoffUnconfirmedName = Math.max(0, Math.round(scenarioCalls * handoffUnconfirmedRatio)); const allHandoffs = Math.max(0, handoffConfirmedName + handoffUnconfirmedName); const manualCorrected = Math.min(allHandoffs, Math.round(allHandoffs * manualRateSeed)); const totals = { scenario_calls: scenarioCalls, start_obtained: startObtained, downstream_ai_obtained: downstreamAiObtained, followup_required: followupRequired, name_not_obtained: nameNotObtained, manual_corrected: manualCorrected, handoff_confirmed_name: handoffConfirmedName, handoff_unconfirmed_name: handoffUnconfirmedName, needed_downstream: neededDownstream, }; const metrics = voiceNameMetricRatesFromTotals(totals); const languageSeeds = Array.isArray(seed.languages) ? seed.languages : []; const languageRows = languageSeeds.map((item) => { const share = Number(item.share || 0); const calls = Math.max(0, Math.round(scenarioCalls * share)); const startCaptured = Math.min(calls, Math.round(calls * Number(item.start_capture_rate || 0) / 100)); const downstreamNeeded = Math.max(0, calls - startCaptured); const downstreamCaptured = Math.min(downstreamNeeded, Math.round(downstreamNeeded * Number(item.downstream_rescue_rate || 0) / 100)); const unresolved = Math.max(0, calls - startCaptured - downstreamCaptured); const followup = Math.round(unresolved * 0.58); const missing = Math.max(0, unresolved - followup); const handoffs = Math.round(unresolved * 0.72); const unconfirmed = Math.min(handoffs, Math.round(handoffs * Number(item.handoff_unconfirmed_rate || 0) / 100)); const confirmed = Math.max(0, handoffs - unconfirmed); const manual = Math.min(handoffs, Math.round(handoffs * Number(item.manual_correction_rate || 0) / 100)); return { language: item.language || 'unknown', scenario_calls: calls, start_obtained: startCaptured, downstream_ai_obtained: downstreamCaptured, followup_required: followup, name_not_obtained: missing, manual_corrected: manual, handoff_confirmed_name: confirmed, handoff_unconfirmed_name: unconfirmed, ...voiceNameMetricRatesFromTotals({ scenario_calls: calls, start_obtained: startCaptured, downstream_ai_obtained: downstreamCaptured, needed_downstream: downstreamNeeded, handoff_confirmed_name: confirmed, handoff_unconfirmed_name: unconfirmed, manual_corrected: manual, }), }; }).filter((item) => item.scenario_calls > 0); const queueSeeds = Array.isArray(seed.queues) ? seed.queues : []; const queueRows = (queueSeeds.length ? queueSeeds : mockAnalyticsQueueOptions().map((item, index) => ({ queue_id: item.queue_id, share: 0.2 + index * 0.12, start_capture_rate: 69 - index * 4, downstream_rescue_rate: 45 - index * 5, handoff_unconfirmed_rate: 38 + index * 5, manual_correction_rate: 17 + index * 3, }))) .filter((item) => queueId === 'all' || item.queue_id === queueId) .map((item) => { const calls = Math.max(0, Math.round(scenarioCalls * Number(item.share || 0))); const startCaptured = Math.min(calls, Math.round(calls * Number(item.start_capture_rate || 0) / 100)); const downstreamNeeded = Math.max(0, calls - startCaptured); const downstreamCaptured = Math.min(downstreamNeeded, Math.round(downstreamNeeded * Number(item.downstream_rescue_rate || 0) / 100)); const unresolved = Math.max(0, calls - startCaptured - downstreamCaptured); const followup = Math.round(unresolved * 0.58); const missing = Math.max(0, unresolved - followup); const handoffs = Math.round(unresolved * 0.72); const unconfirmed = Math.min(handoffs, Math.round(handoffs * Number(item.handoff_unconfirmed_rate || 0) / 100)); const confirmed = Math.max(0, handoffs - unconfirmed); const manual = Math.min(handoffs, Math.round(handoffs * Number(item.manual_correction_rate || 0) / 100)); return { queue_id: item.queue_id, scenario_calls: calls, start_obtained: startCaptured, downstream_ai_obtained: downstreamCaptured, followup_required: followup, name_not_obtained: missing, manual_corrected: manual, handoff_confirmed_name: confirmed, handoff_unconfirmed_name: unconfirmed, ...voiceNameMetricRatesFromTotals({ scenario_calls: calls, start_obtained: startCaptured, downstream_ai_obtained: downstreamCaptured, needed_downstream: downstreamNeeded, handoff_confirmed_name: confirmed, handoff_unconfirmed_name: unconfirmed, manual_corrected: manual, }), }; }) .filter((item) => item.scenario_calls > 0); const coverageSeed = seed.coverage || {}; const coverage = { sessions_with_start_decision: Math.min( scenarioCalls, Math.round(scenarioCalls * (Number(coverageSeed.sessions_with_start_decision || baseScenarioCalls) / baseScenarioCalls)), ), sessions_with_final_ai_state: Math.min( scenarioCalls, Math.round(scenarioCalls * (Number(coverageSeed.sessions_with_final_ai_state || baseScenarioCalls) / baseScenarioCalls)), ), sessions_with_manual_overlay: Math.min( scenarioCalls, Math.round(scenarioCalls * (Number(coverageSeed.sessions_with_manual_overlay || manualCorrected) / baseScenarioCalls)), ), note: coverageSeed.note || analyticsMockData().coverage_note || 'Показаны демонстрационные данные по name-flow.', }; return { window: { from_ts: (previous ? rangeMeta.previous.from : rangeMeta.current.from).toISOString(), to_ts: (previous ? rangeMeta.previous.to : rangeMeta.current.to).toISOString(), }, filters: { from_ts: (previous ? rangeMeta.previous.from : rangeMeta.current.from).toISOString(), to_ts: (previous ? rangeMeta.previous.to : rangeMeta.current.to).toISOString(), queue_id: queueId === 'all' ? null : queueId, language: null, }, totals, metrics, breakdowns: { funnel: [ { stage: 'scenario_calls', label: mockVoiceNameLabel('scenario_calls', 'Звонки в сценарии'), sessions: scenarioCalls, share: 100 }, { stage: 'start_obtained', label: mockVoiceNameLabel('start_obtained', 'Имя взято сразу'), sessions: startObtained, share: scenarioCalls ? Number(((startObtained / scenarioCalls) * 100).toFixed(2)) : 0 }, { stage: 'needed_downstream', label: mockVoiceNameLabel('needed_downstream', 'Потребовался AI после старта'), sessions: neededDownstream, share: scenarioCalls ? Number(((neededDownstream / scenarioCalls) * 100).toFixed(2)) : 0 }, { stage: 'downstream_ai_obtained', label: mockVoiceNameLabel('downstream_ai_obtained', 'Имя добрал AI после follow-up'), sessions: downstreamAiObtained, share: scenarioCalls ? Number(((downstreamAiObtained / scenarioCalls) * 100).toFixed(2)) : 0 }, { stage: 'handoff_confirmed_name', label: mockVoiceNameLabel('handoff_confirmed_name', 'Передача с подтверждённым именем'), sessions: handoffConfirmedName, share: scenarioCalls ? Number(((handoffConfirmedName / scenarioCalls) * 100).toFixed(2)) : 0 }, { stage: 'handoff_unconfirmed_name', label: mockVoiceNameLabel('handoff_unconfirmed_name', 'Передача без подтверждённого имени'), sessions: handoffUnconfirmedName, share: scenarioCalls ? Number(((handoffUnconfirmedName / scenarioCalls) * 100).toFixed(2)) : 0 }, ], by_language: languageRows, by_queue: queueRows, handoff: [ { outcome: 'confirmed_name', label: mockVoiceNameLabel('confirmed_name', 'Передача с подтверждённым именем'), sessions: handoffConfirmedName, share: allHandoffs ? Number(((handoffConfirmedName / allHandoffs) * 100).toFixed(2)) : 0 }, { outcome: 'unconfirmed_name', label: mockVoiceNameLabel('unconfirmed_name', 'Передача без подтверждённого имени'), sessions: handoffUnconfirmedName, share: allHandoffs ? Number(((handoffUnconfirmedName / allHandoffs) * 100).toFixed(2)) : 0 }, ], }, coverage, }; } function buildMockVoiceNameTrend(rangeMeta, metric) { const interval = analyticsTimeseriesIntervalForRange(rangeMeta); const scenarioProfile = mockVoiceNameProfile('scenario_calls', { base: 24, step: 2.4, cycle: 4 }); const metricProfile = metric === 'scenario_calls' ? scenarioProfile : mockVoiceNameProfile(metric, { base: 60, step: 2.1, cycle: 4 }); const queueFactor = Math.max(0.48, mockAnalyticsQueueFactor(state.analytics.queueId === 'all' ? 'sales_kz' : state.analytics.queueId)); const points = []; let cursor = new Date(interval === 'hour' ? rangeMeta.current.from.getTime() : startOfDay(rangeMeta.current.from).getTime()); while (cursor < rangeMeta.current.to) { const index = points.length; const scenarioCalls = Math.max( 4, Math.round((scenarioProfile.base + (index % scenarioProfile.cycle) * scenarioProfile.step) * queueFactor), ); const neededDownstream = Math.max(1, Math.round(scenarioCalls * 0.35)); const handoffs = Math.max(1, Math.round(scenarioCalls * 0.18)); let value = 0; let denominator = scenarioCalls; if (metric === 'scenario_calls') { value = scenarioCalls; denominator = scenarioCalls; } else if (metric === 'downstream_rescue_rate') { value = metricProfile.base + (index % metricProfile.cycle) * metricProfile.step; denominator = neededDownstream; } else if (metric === 'handoff_unconfirmed_rate' || metric === 'manual_correction_rate') { value = metricProfile.base + (index % metricProfile.cycle) * metricProfile.step; denominator = handoffs; } else { value = metricProfile.base + (index % metricProfile.cycle) * metricProfile.step; denominator = scenarioCalls; } points.push({ ts: cursor.toISOString(), value: Number(value.toFixed(metric === 'scenario_calls' ? 0 : 2)), scenario_calls: scenarioCalls, denominator, }); cursor = new Date(cursor.getTime() + (interval === 'hour' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000)); } return { metric, interval, filters: { from_ts: rangeMeta.current.from.toISOString(), to_ts: rangeMeta.current.to.toISOString(), queue_id: state.analytics.queueId === 'all' ? null : state.analytics.queueId, language: null, }, points, }; } function buildMockInteractionDrilldownData(filters, limit, offset, mode) { const normalized = normalizeAnalyticsDrilldownFilters(filters); const queueOptions = mockAnalyticsQueueOptions(); const agents = mockAnalyticsAgents(); const total = 18; const rows = Array.from({ length: total }, (_, index) => { const queueId = normalized.queue_id || queueOptions[index % Math.max(queueOptions.length, 1)]?.queue_id || 'sales_kz'; let channel = normalized.channel || ['voice', 'telegram', 'whatsapp', 'webchat', 'email'][index % 5]; if (mode === 'metric' && normalized.metric === 'DigitalShare') { channel = ['telegram', 'whatsapp', 'webchat', 'email'][index % 4]; } const answered = normalized.metric === 'Abandon' ? false : index % 5 !== 0; const abandoned = normalized.metric === 'Abandon' ? true : index % 6 === 0; const withinSla = normalized.metric === 'SL' ? true : answered ? index % 4 !== 0 : null; const resolvedFirstContact = normalized.metric === 'FCR' ? true : answered ? index % 3 === 0 : null; const createdAt = new Date(Date.now() - (index + 1) * 3 * 60 * 60 * 1000); return { interaction_id: `mock-int-${index + 1}`, subject: `${mode === 'metric' ? 'KPI' : 'Обращение'} ${index + 1}: ${analyticsChannelLabel(channel)}`, channel, status: abandoned ? 'closed' : mockInteractionStatus(index), queue_id: queueId, assigned_to: normalized.agent_id || (abandoned ? null : (agents[index % Math.max(agents.length, 1)] || null)), created_at: createdAt.toISOString(), updated_at: new Date(createdAt.getTime() + 42 * 60 * 1000).toISOString(), answered, abandoned, wait_seconds: abandoned ? 44 + index : 12 + index * 2, handle_seconds: answered ? 210 + index * 14 : 0, within_sla: withinSla, resolved_first_contact: resolvedFirstContact, }; }).filter((item) => { if (normalized.channel && item.channel !== normalized.channel) { return false; } if (normalized.queue_id && item.queue_id !== normalized.queue_id) { return false; } if (normalized.agent_id && item.assigned_to !== normalized.agent_id) { return false; } return true; }); const paged = rows.slice(offset, offset + limit); return { items: paged, total: rows.length, limit, offset, filters: normalized, metric: normalized.metric || '', coverage: mockAnalyticsCoverage(rows.length), }; } function buildMockInteractionDetail(interactionId) { const queueOptions = mockAnalyticsQueueOptions(); const agents = mockAnalyticsAgents(); const interaction = { interaction_id: interactionId, subject: `Карточка ${interactionId}`, channel: ['voice', 'telegram', 'whatsapp'][interactionId.length % 3], status: 'closed', queue_id: queueOptions[interactionId.length % Math.max(queueOptions.length, 1)]?.queue_id || 'sales_kz', assigned_to: agents[interactionId.length % Math.max(agents.length, 1)] || null, created_at: new Date(Date.now() - 8 * 60 * 60 * 1000).toISOString(), updated_at: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), wait_seconds: 18, handle_seconds: 324, within_sla: true, resolved_first_contact: true, abandoned: false, }; const timeline = { events: [ { action: 'Создано обращение', timestamp: interaction.created_at, metadata: { channel: interaction.channel, queue_id: interaction.queue_id } }, { action: 'Назначено оператору', timestamp: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(), metadata: { assigned_to: interaction.assigned_to } }, { action: 'Закрыто', timestamp: interaction.updated_at, metadata: { resolved_first_contact: true, within_sla: true } }, ], }; return { detail: interaction, timeline }; } function buildMockAiDrilldownData(filters, limit, offset) { const normalized = normalizeAnalyticsDrilldownFilters(filters); const queueOptions = mockAnalyticsQueueOptions(); const agents = mockAnalyticsAgents(); const total = 14; const allItems = Array.from({ length: total }, (_, index) => { const queue = queueOptions[index % Math.max(queueOptions.length, 1)] || { queue_id: 'sales_kz' }; const channel = normalized.channel && normalized.channel !== 'all' ? normalized.channel : ['telegram', 'whatsapp'][index % 2]; const slice = normalized.slice || 'all'; const reasonKey = normalized.reason_key || ['requested_human', 'knowledge_or_tool_gap', 'manual_claim'][index % 3]; const handoff = slice === 'handoff' || slice === 'human_touched' || index % 4 === 0; const contained = slice === 'contained' || (!handoff && index % 2 === 0); const humanTouched = slice === 'human_touched' || handoff; const closedWithoutOperator = slice === 'closed_without_operator' || (contained && index % 3 !== 0); const createdAt = new Date(Date.now() - (index + 2) * 2 * 60 * 60 * 1000); return { session_id: `mock-ai-${index + 1}`, interaction_id: `mock-int-${index + 1}`, thread_id: `thread-${index + 1}`, channel, queue_id: queue.queue_id, status: handoff ? 'human_owned' : contained ? 'closed' : 'active', reason_key: handoff ? reasonKey : '', reason_label: handoff ? mockAiReasonLabel(reasonKey) : '', raw_handoff_reason: handoff ? `raw:${reasonKey}` : '', assigned_to: handoff ? (agents[index % Math.max(agents.length, 1)] || null) : null, claimed_by_user: handoff ? `sup-${(index % 3) + 1}` : null, assistant_turns: 6 + index, user_turns: 3 + (index % 4), tool_turns: 1 + (index % 2), ai_latency_avg_ms: 1280 + index * 44, ai_latency_p95_ms: 2010 + index * 61, created_at: createdAt.toISOString(), updated_at: new Date(createdAt.getTime() + 55 * 60 * 1000).toISOString(), closed_at: contained || handoff ? new Date(createdAt.getTime() + 94 * 60 * 1000).toISOString() : null, contained, handoff, human_touched: humanTouched, closed_without_operator: closedWithoutOperator, }; }).filter((item) => { if (normalized.channel && normalized.channel !== 'all' && item.channel !== normalized.channel) { return false; } if (normalized.queue_id && item.queue_id !== normalized.queue_id) { return false; } if (normalized.reason_key && item.reason_key !== normalized.reason_key) { return false; } if (normalized.slice === 'contained' && !item.contained) { return false; } if (normalized.slice === 'handoff' && !item.handoff) { return false; } if (normalized.slice === 'human_touched' && !item.human_touched) { return false; } if (normalized.slice === 'closed_without_operator' && !item.closed_without_operator) { return false; } if (normalized.slice === 'active' && item.status !== 'active') { return false; } if (normalized.status && item.status !== normalized.status) { return false; } return true; }); return { items: allItems.slice(offset, offset + limit), total: allItems.length, limit, offset, filters: normalized, coverage: { sessions_with_interaction_id: allItems.length, sessions_with_queue_id: allItems.length, sessions_with_latency_turns: allItems.length, sessions_with_terminal_state: Math.round(allItems.length * 0.86), sessions_with_handoff_reason: allItems.filter((item) => item.reason_key).length, }, }; } function buildMockAiDetail(sessionId) { const session = buildMockAiDrilldownData({ slice: 'all' }, 20, 0).items.find((item) => item.session_id === sessionId) || buildMockAiDrilldownData({ slice: 'all' }, 1, 0).items[0]; return { session, interaction: { interaction_id: session.interaction_id, channel: session.channel, queue_id: session.queue_id, status: session.handoff ? 'in_progress' : 'closed', assigned_to: session.assigned_to, subject: `Связанное обращение ${session.interaction_id}`, updated_at: session.updated_at, }, timeline: [ { label: 'Создана AI-сессия', event_type: 'session.created', ts: session.created_at, metadata: { channel: session.channel, queue_id: session.queue_id } }, { label: 'Ответ AI', event_type: 'assistant.turn', ts: new Date(new Date(session.created_at).getTime() + 12 * 60 * 1000).toISOString(), metadata: { assistant_turns: session.assistant_turns } }, { label: session.handoff ? 'Передано оператору' : 'Сессия закрыта', event_type: session.handoff ? 'session.handoff' : 'session.closed', ts: session.updated_at, metadata: { reason_key: session.reason_key || null, assigned_to: session.assigned_to || null } }, ], }; } function buildMockAnalyticsDashboard(rangeMeta) { const queueOptions = mockAnalyticsQueueOptions(); const overview = buildMockAnalyticsOverview(rangeMeta); return { queueOptions, overview, compare: buildMockAnalyticsOverview(rangeMeta, { previous: true }), coverage: { implemented_metrics: Array.isArray(analyticsMockData().dashboard_coverage?.implemented_metrics) ? analyticsMockData().dashboard_coverage.implemented_metrics : [], dimensions: Array.isArray(analyticsMockData().dashboard_coverage?.dimensions) ? analyticsMockData().dashboard_coverage.dimensions : [], supported_filters: Array.isArray(analyticsMockData().dashboard_coverage?.supported_filters) ? analyticsMockData().dashboard_coverage.supported_filters : [], }, voiceNameOverview: buildMockVoiceNameOverview(rangeMeta), voiceNameCompare: buildMockVoiceNameOverview(rangeMeta, { previous: true }), aiOverview: buildMockAiOverview(rangeMeta), aiCompare: buildMockAiOverview(rangeMeta, { previous: true }), agentOverview: buildMockAgentOverview(rangeMeta), agentCompare: buildMockAgentOverview(rangeMeta, { previous: true }), trend: buildMockTrendPayloads(rangeMeta, state.analytics.trendMetric || 'volume'), voiceNameTrend: buildMockVoiceNameTrend(rangeMeta, state.analytics.voiceNameTrendMetric || 'scenario_calls'), aiTrend: buildMockAiTrend(rangeMeta, state.analytics.aiTrendMetric || 'containment_rate'), agentTrend: buildMockAgentTrend(rangeMeta, state.analytics.agentTrendMetric || 'interactions_per_agent'), queueRows: buildMockQueueRows(queueOptions, rangeMeta), channelRows: Object.entries(overview.breakdowns.by_channel).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), }; } function applyAnalyticsSnapshot(snapshot = {}) { state.analytics.preset = snapshot.preset || '7d'; state.analytics.fromTs = snapshot.fromTs || ''; state.analytics.toTs = snapshot.toTs || ''; state.analytics.queueId = snapshot.queueId || 'all'; state.analytics.channel = snapshot.channel || 'all'; state.analytics.compareMode = snapshot.compareMode || 'previous'; state.analytics.trendMetric = snapshot.trendMetric || 'volume'; state.analytics.voiceNameTrendMetric = snapshot.voiceNameTrendMetric || 'scenario_calls'; state.analytics.aiTrendMetric = snapshot.aiTrendMetric || 'containment_rate'; state.analytics.agentTrendMetric = snapshot.agentTrendMetric || 'interactions_per_agent'; } function withAnalyticsUrlSyncSuspended(callback) { analyticsUrlSyncSuspended = true; try { return callback(); } finally { analyticsUrlSyncSuspended = false; } } function sanitizeAnalyticsSnapshot(snapshot = {}) { const validChannel = ANALYTICS_CHANNEL_OPTIONS.some((item) => item.value === snapshot.channel); return { preset: Object.prototype.hasOwnProperty.call(ANALYTICS_PRESET_LABELS, snapshot.preset) ? snapshot.preset : '7d', fromTs: snapshot.fromTs || '', toTs: snapshot.toTs || '', queueId: snapshot.queueId || 'all', channel: validChannel ? snapshot.channel : 'all', compareMode: snapshot.compareMode === 'off' ? 'off' : 'previous', trendMetric: ANALYTICS_TREND_OPTIONS.includes(snapshot.trendMetric) ? snapshot.trendMetric : 'volume', voiceNameTrendMetric: VOICE_NAME_TREND_OPTIONS.includes(snapshot.voiceNameTrendMetric) ? snapshot.voiceNameTrendMetric : 'scenario_calls', aiTrendMetric: AI_ANALYTICS_TREND_OPTIONS.includes(snapshot.aiTrendMetric) ? snapshot.aiTrendMetric : 'containment_rate', agentTrendMetric: AGENT_ANALYTICS_TREND_OPTIONS.includes(snapshot.agentTrendMetric) ? snapshot.agentTrendMetric : 'interactions_per_agent', }; } function analyticsDeepLinkDrilldownState() { if (!state.drilldown.open || !state.drilldown.filters) { return null; } return { mode: state.drilldown.mode || 'interaction', sourceType: state.drilldown.sourceType || '', sourceValue: state.drilldown.sourceValue || '', metric: state.drilldown.metric || '', filters: normalizeAnalyticsDrilldownFilters(state.drilldown.filters), offset: Number(state.drilldown.offset || 0), selectedId: state.drilldown.selectedInteractionId || '', }; } function syncAnalyticsUrlState() { if (analyticsUrlSyncSuspended) { return; } const url = new URL(window.location.href); ANALYTICS_DEEP_LINK_KEYS.forEach((key) => { url.searchParams.delete(key); }); const snapshot = sanitizeAnalyticsSnapshot(analyticsCurrentSnapshot()); url.searchParams.set('preset', snapshot.preset); if (snapshot.fromTs) { url.searchParams.set('from', snapshot.fromTs); } if (snapshot.toTs) { url.searchParams.set('to', snapshot.toTs); } if (snapshot.queueId && snapshot.queueId !== 'all') { url.searchParams.set('queue', snapshot.queueId); } if (snapshot.channel && snapshot.channel !== 'all') { url.searchParams.set('channel', snapshot.channel); } if (snapshot.compareMode !== 'previous') { url.searchParams.set('compare', snapshot.compareMode); } if (snapshot.trendMetric !== 'volume') { url.searchParams.set('trend', snapshot.trendMetric); } if (snapshot.voiceNameTrendMetric !== 'scenario_calls') { url.searchParams.set('voice_name_trend', snapshot.voiceNameTrendMetric); } if (snapshot.aiTrendMetric !== 'containment_rate') { url.searchParams.set('ai_trend', snapshot.aiTrendMetric); } if (snapshot.agentTrendMetric !== 'interactions_per_agent') { url.searchParams.set('agent_trend', snapshot.agentTrendMetric); } if (state.analytics.activeViewId) { url.searchParams.set('view', state.analytics.activeViewId); } const drilldown = analyticsDeepLinkDrilldownState(); if (drilldown) { url.searchParams.set('dd', '1'); url.searchParams.set('dd_mode', drilldown.mode); url.searchParams.set('dd_source', drilldown.sourceType || 'overview'); if (drilldown.sourceValue) { url.searchParams.set('dd_value', drilldown.sourceValue); } if (drilldown.metric) { url.searchParams.set('dd_metric', drilldown.metric); } if (drilldown.filters.slice && drilldown.filters.slice !== 'all') { url.searchParams.set('dd_slice', drilldown.filters.slice); } if (drilldown.filters.reason_key) { url.searchParams.set('dd_reason', drilldown.filters.reason_key); } if (drilldown.filters.status) { url.searchParams.set('dd_status', drilldown.filters.status); } if (drilldown.filters.q) { url.searchParams.set('dd_q', drilldown.filters.q); } if (drilldown.filters.sort_by && drilldown.filters.sort_by !== 'created_at') { url.searchParams.set('dd_sort_by', drilldown.filters.sort_by); } if (drilldown.filters.sort_dir && drilldown.filters.sort_dir !== 'desc') { url.searchParams.set('dd_sort_dir', drilldown.filters.sort_dir); } if (drilldown.filters.agent_id) { url.searchParams.set('dd_agent', drilldown.filters.agent_id); } if (drilldown.offset > 0) { url.searchParams.set('dd_offset', String(drilldown.offset)); } if (drilldown.selectedId) { url.searchParams.set('dd_selected', drilldown.selectedId); } } const nextUrl = `${url.pathname}${url.search}${url.hash}`; const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`; if (nextUrl !== currentUrl) { window.history.replaceState(null, '', nextUrl); } } function parseAnalyticsDeepLinkState() { const params = new URLSearchParams(window.location.search); const hasSnapshot = [ 'preset', 'from', 'to', 'queue', 'channel', 'compare', 'trend', 'voice_name_trend', 'ai_trend', 'agent_trend', ].some((key) => params.has(key)); const snapshot = sanitizeAnalyticsSnapshot({ preset: params.get('preset') || '7d', fromTs: params.get('from') || '', toTs: params.get('to') || '', queueId: params.get('queue') || 'all', channel: params.get('channel') || 'all', compareMode: params.get('compare') || 'previous', trendMetric: params.get('trend') || 'volume', voiceNameTrendMetric: params.get('voice_name_trend') || 'scenario_calls', aiTrendMetric: params.get('ai_trend') || 'containment_rate', agentTrendMetric: params.get('agent_trend') || 'interactions_per_agent', }); let drilldown = null; if (params.get('dd') === '1') { const mode = ['interaction', 'metric', 'ai'].includes(params.get('dd_mode')) ? params.get('dd_mode') : 'interaction'; drilldown = { mode, sourceType: params.get('dd_source') || (mode === 'metric' ? 'metric' : mode === 'ai' ? 'ai-overview' : 'overview'), sourceValue: params.get('dd_value') || '', metric: params.get('dd_metric') || '', offset: Math.max(0, Number(params.get('dd_offset') || 0)), selectedId: params.get('dd_selected') || '', filters: normalizeAnalyticsDrilldownFilters({ metric: params.get('dd_metric') || '', slice: params.get('dd_slice') || 'all', reason_key: params.get('dd_reason') || '', agent_id: params.get('dd_agent') || '', status: params.get('dd_status') || '', q: params.get('dd_q') || '', sort_by: params.get('dd_sort_by') || 'created_at', sort_dir: params.get('dd_sort_dir') || 'desc', }), }; } return { hasSnapshot, snapshot, activeViewId: params.get('view') || '', drilldown, }; } function restoreAnalyticsDrilldownFromUrlState(drilldownState) { if (!drilldownState) { return; } const mode = ['interaction', 'metric', 'ai'].includes(drilldownState.mode) ? drilldownState.mode : 'interaction'; const sourceType = drilldownState.sourceType || (mode === 'metric' ? 'metric' : mode === 'ai' ? 'ai-overview' : 'overview'); const sourceValue = drilldownState.sourceValue || ''; const filters = { ...analyticsDrilldownBaseFilters(state.analytics.lastRangeMeta || analyticsRangeFromControls()), ...normalizeAnalyticsDrilldownFilters(drilldownState.filters || {}), }; if (mode === 'metric') { filters.metric = drilldownState.metric || filters.metric || sourceValue; filters.status = ''; filters.q = ''; } if (mode === 'ai') { filters.slice = filters.slice || 'all'; if (sourceType === 'ai-overview' && sourceValue) { filters.slice = sourceValue; } if (sourceType === 'ai-channel' && sourceValue) { filters.channel = sourceValue; } if (sourceType === 'ai-reason' && sourceValue) { filters.slice = 'handoff'; filters.reason_key = sourceValue; } } if (sourceType === 'channel' && sourceValue) { filters.channel = sourceValue; } if (sourceType === 'queue' && sourceValue) { filters.queue_id = sourceValue; } if (sourceType === 'agent' && sourceValue) { filters.agent_id = sourceValue; } state.drilldown = { ...emptyDrilldownState(), open: true, mode, sourceType, sourceLabel: analyticsDrilldownSourceLabel(sourceType, sourceValue), sourceValue, metric: mode === 'metric' ? (drilldownState.metric || filters.metric || sourceValue) : '', filters: normalizeAnalyticsDrilldownFilters(filters), offset: Math.max(0, Number(drilldownState.offset || 0)), selectedInteractionId: drilldownState.selectedId || '', coverage: mode === 'metric' ? analyticsMetricCoverage(drilldownState.metric || filters.metric || sourceValue) : null, metricNote: mode === 'metric' ? analyticsMetricDrilldownNote(drilldownState.metric || filters.metric || sourceValue) : mode === 'ai' ? aiAnalyticsDrilldownNote(filters) : '', }; renderAnalyticsDrilldown(); void loadAnalyticsDrilldownPage({ preserveSelection: Boolean(drilldownState.selectedId) }); } function analyticsCompareModeLabel(mode) { return ANALYTICS_COMPARE_MODE_LABELS[mode] || mode || 'Сравнение'; } function suggestAnalyticsViewName() { const parts = [analyticsPresetLabel(state.analytics.preset || '7d')]; if (state.analytics.channel && state.analytics.channel !== 'all') { parts.push(analyticsChannelLabel(state.analytics.channel)); } if (state.analytics.queueId && state.analytics.queueId !== 'all') { const queue = state.analytics.queueOptions.find((item) => item.queue_id === state.analytics.queueId); parts.push(queue?.name || state.analytics.queueId); } return parts.join(' · '); } function normalizeSavedAnalyticsView(item = {}) { return { id: String(item.id || `view-${Date.now()}`), name: String(item.name || 'Сохранённый вид'), snapshot: { preset: item.snapshot?.preset || '7d', fromTs: item.snapshot?.fromTs || '', toTs: item.snapshot?.toTs || '', queueId: item.snapshot?.queueId || 'all', channel: item.snapshot?.channel || 'all', compareMode: item.snapshot?.compareMode || 'previous', trendMetric: item.snapshot?.trendMetric || 'volume', voiceNameTrendMetric: item.snapshot?.voiceNameTrendMetric || 'scenario_calls', aiTrendMetric: item.snapshot?.aiTrendMetric || 'containment_rate', agentTrendMetric: item.snapshot?.agentTrendMetric || 'interactions_per_agent', }, createdAt: item.created_at || item.createdAt || item.updated_at || item.updatedAt || new Date().toISOString(), updatedAt: item.updated_at || item.updatedAt || item.created_at || item.createdAt || new Date().toISOString(), }; } function renderSavedAnalyticsViews() { const select = $('analyticsSavedViewSelect'); const input = $('analyticsSavedViewName'); const meta = $('analyticsSavedViewMeta'); const deleteBtn = $('analyticsDeleteViewBtn'); if (!select || !input || !meta || !deleteBtn) { return; } const activeView = state.analytics.savedViews.find((item) => item.id === state.analytics.activeViewId) || null; select.innerHTML = [ '', ...state.analytics.savedViews.map( (item) => ``, ), ].join(''); select.value = activeView ? activeView.id : ''; if (activeView) { input.value = activeView.name; } input.placeholder = suggestAnalyticsViewName(); deleteBtn.disabled = !activeView; const parts = []; parts.push( state.analytics.savedViews.length ? `${state.analytics.savedViews.length} сохранённых ${state.analytics.savedViews.length === 1 ? 'вид' : 'вида'}` : 'Сохранённых видов пока нет', ); if (activeView) { parts.push(`активен: ${activeView.name}`); } if (state.analytics.statusMessage) { parts.push(state.analytics.statusMessage); } meta.textContent = parts.join(' · '); } function detachActiveAnalyticsView() { if (!state.analytics.activeViewId) { return; } state.analytics.activeViewId = ''; state.analytics.statusMessage = 'Фильтры изменены. Можно сохранить новый вид.'; } 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)}`); } async function fetchAgentAnalyticsOverview(range, overrides = {}) { return api('reporting', `reports/agents/overview?${analyticsQuery(range, overrides)}`); } async function fetchAgentAnalyticsTimeseries(range, metric, interval, overrides = {}) { const params = new URLSearchParams(analyticsQuery(range, overrides)); params.set('metric', metric || state.analytics.agentTrendMetric || 'interactions_per_agent'); params.set('interval', interval || 'day'); return api('reporting', `reports/agents/timeseries?${params.toString()}`); } function voiceNameAnalyticsSupportedChannel(channel = state.analytics.channel) { return channel === 'all' || channel === 'voice'; } function voiceNameAnalyticsQuery(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 language = overrides.language ?? null; if (queueId && queueId !== 'all') { params.set('queue_id', queueId); } if (language) { params.set('language', language); } return params.toString(); } async function fetchVoiceNameAnalyticsOverview(range, overrides = {}) { return api('ai', `ai/analytics/voice-name-flow/overview?${voiceNameAnalyticsQuery(range, overrides)}`); } async function fetchVoiceNameAnalyticsTimeseries(range, metric, overrides = {}) { const params = new URLSearchParams(voiceNameAnalyticsQuery(range, overrides)); params.set('metric', metric || state.analytics.voiceNameTrendMetric || 'scenario_calls'); return api('ai', `ai/analytics/voice-name-flow/timeseries?${params.toString()}`); } async function fetchAnalyticsMetricDrilldown(filters, limit, offset) { return api('reporting', `reports/drilldown?${analyticsMetricDrilldownQuery(filters, limit, offset)}`); } function aiAnalyticsSupportedChannel(channel = state.analytics.channel) { return channel === 'all' || channel === 'telegram' || channel === 'whatsapp'; } function aiAnalyticsQuery(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) { params.set('channel', channel); } return params.toString(); } async function fetchAiAnalyticsOverview(range, overrides = {}) { return api('ai', `ai/analytics/overview?${aiAnalyticsQuery(range, overrides)}`); } async function fetchAiAnalyticsTimeseries(range, metric, interval, overrides = {}) { const params = new URLSearchParams(aiAnalyticsQuery(range, overrides)); params.set('metric', metric || state.analytics.aiTrendMetric || 'containment_rate'); params.set('interval', interval || 'day'); return api('ai', `ai/analytics/timeseries?${params.toString()}`); } async function fetchAiAnalyticsDrilldown(filters, limit, offset) { return api('ai', `ai/analytics/drilldown?${aiAnalyticsDrilldownQuery(filters, limit, offset)}`); } async function fetchAiAnalyticsSessionDetail(sessionId) { return api('ai', `ai/analytics/sessions/${encodeURIComponent(sessionId)}`); } function renderAnalyticsQueueOptions() { const select = $('analyticsQueue'); if (!select) { return; } const selected = state.analytics.queueId || 'all'; const options = ['']; if (selected !== 'all' && !state.analytics.queueOptions.some((item) => item.queue_id === selected)) { options.push(``); } options.push( ...state.analytics.queueOptions.map( (item) => ``, ), ); select.innerHTML = options.join(''); select.value = options.some((item) => item.includes(`value="${selected}"`)) ? selected : 'all'; } async function loadAnalyticsQueueOptions() { try { const data = await api('routing', 'queues'); state.analytics.queueOptions = Array.isArray(data) ? data : []; state.analytics.queueAccessError = ''; } catch (err) { state.analytics.queueOptions = []; state.analytics.queueAccessError = err.message; } renderAnalyticsQueueOptions(); return state.analytics.queueOptions; } function formatAnalyticsNumber(value, digits = 0) { return new Intl.NumberFormat('ru-RU', { minimumFractionDigits: digits, maximumFractionDigits: digits, }).format(Number(value || 0)); } function analyticsChannelLabel(channel) { const meta = ANALYTICS_CHANNEL_OPTIONS.find((item) => item.value === channel); return meta ? meta.label : String(channel || 'неизвестно'); } function metricValueFromPayload(metric, payload) { if (metric === 'volume' || metric === 'total') { return Number(payload?.volume?.total || 0); } if (metric === 'answered') { return Number(payload?.volume?.answered || 0); } return Number(payload?.kpi?.[metric] || 0); } function formatAnalyticsMetric(metric, value) { const meta = ANALYTICS_METRIC_META[metric] || { unit: 'count' }; if (meta.unit === 'pct') { return `${formatAnalyticsNumber(value, 2)}%`; } if (meta.unit === 'seconds') { return `${formatAnalyticsNumber(value, 2)} с`; } return formatAnalyticsNumber(value, 0); } function analyticsMetricCoverage(metric, payload = state.analytics.overview || emptyKpiEnvelope()) { const detail = payload?.coverage?.metric_details?.[metric]; if (detail && typeof detail === 'object') { return { ...emptyAnalyticsMetricCoverage(), ...detail, supported_channels: Array.isArray(detail.supported_channels) ? detail.supported_channels : [], exact_rows: Number(detail.exact_rows || 0), total_rows: Number(detail.total_rows || 0), }; } return { ...emptyAnalyticsMetricCoverage(), status: payload?.coverage?.metric_status?.[metric] || 'unavailable', supported_channels: Array.isArray(payload?.coverage?.supported_channels?.[metric]) ? payload.coverage.supported_channels[metric] : [], }; } function analyticsMetricCanDrilldown(metric, payload = state.analytics.overview || emptyKpiEnvelope()) { return analyticsMetricCoverage(metric, payload).status === 'exact'; } function analyticsMetricCoverageTone(metric, payload = state.analytics.overview || emptyKpiEnvelope()) { const coverage = analyticsMetricCoverage(metric, payload); if (coverage.status === 'exact') { return 'exact'; } if (coverage.status === 'partial') { return 'partial'; } return 'unavailable'; } function analyticsMetricCoverageLabel(metric, payload = state.analytics.overview || emptyKpiEnvelope()) { const coverage = analyticsMetricCoverage(metric, payload); if (coverage.status === 'exact') { return 'Точно'; } if ( coverage.status === 'partial' && coverage.supported_channels.length === 1 && coverage.supported_channels[0] === 'voice' ) { return 'Только голос'; } if (coverage.status === 'partial') { return 'Частично'; } return 'Нет точного покрытия'; } function analyticsMetricCoverageHint(metric, payload = state.analytics.overview || emptyKpiEnvelope()) { const coverage = analyticsMetricCoverage(metric, payload); if (coverage.status === 'exact') { return 'Открыть точный KPI-срез по ID обращения.'; } if ( coverage.supported_channels.length === 1 && coverage.supported_channels[0] === 'voice' && coverage.status === 'partial' ) { return 'Точная детализация сейчас доступна только для голосовой части выбранного окна.'; } if (!coverage.total_rows) { return 'Для выбранного окна пока нет точных записей фактов для этой метрики.'; } if (!coverage.supported_channels.length) { return 'Для этой метрики точная детализация в текущем срезе пока недоступна.'; } return 'Точная детализация пока доступна только для части среза, покрытой слоем KPI-фактов.'; } function analyticsMetricCoverageBadge(metric, payload = state.analytics.overview || emptyKpiEnvelope()) { const tone = analyticsMetricCoverageTone(metric, payload); const label = analyticsMetricCoverageLabel(metric, payload); return `${escapeHtml(label)}`; } function aiAnalyticsMetricValue(metric, payload) { return payload?.metrics?.[metric] ?? null; } function aiAnalyticsMetricSlice(metric) { if (metric === 'containment_rate') { return 'contained'; } if (metric === 'handoff_rate') { return 'handoff'; } if (metric === 'human_touched_rate') { return 'human_touched'; } if (metric === 'closed_without_operator_rate') { return 'closed_without_operator'; } return 'all'; } function aiAnalyticsStatusLabel(status) { const labels = { active: 'Активна', closed: 'Закрыта', handoff_required: 'Требуется передача', human_owned: 'У оператора', error: 'Ошибка', }; return labels[status] || status || 'Неизвестно'; } function aiAnalyticsSliceLabel(slice) { const labels = { all: 'Все AI-сессии', contained: 'Закрыто AI', handoff: 'Передано оператору', human_touched: 'С участием оператора', closed_without_operator: 'Закрыто без оператора', active: 'Активные', error: 'С ошибкой', }; return labels[slice] || slice || 'AI-срез'; } function aiAnalyticsSortLabel(filters = state.drilldown.filters) { const normalized = normalizeAnalyticsDrilldownFilters(filters); const fieldLabels = { created_at: 'созданию', updated_at: 'обновлению', ai_latency_avg_ms: 'задержке', status: 'статусу', }; const dirLabel = normalized.sort_dir === 'asc' ? 'по возрастанию' : 'по убыванию'; return `Сортировка: по ${fieldLabels[normalized.sort_by] || normalized.sort_by}, ${dirLabel}`; } function aiAnalyticsDrilldownNote(filters = state.drilldown.filters) { const normalized = normalizeAnalyticsDrilldownFilters(filters); const parts = [ 'Только метаданные по AI-сессиям: без текстов сообщений, AI-сводок, записей и транскриптов.', ]; if (normalized.reason_key) { parts.push(`Причина: ${normalized.reason_key}`); } return parts.join(' '); } function formatAiAnalyticsMetric(metric, value) { const meta = AI_ANALYTICS_METRIC_META[metric] || { unit: 'count' }; if (value === null || value === undefined || Number.isNaN(Number(value))) { return '—'; } if (meta.unit === 'pct') { return `${formatAnalyticsNumber(value, 2)}%`; } if (meta.unit === 'ms') { return `${formatAnalyticsNumber(value, 0)} ms`; } 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 voiceNameAnalyticsMetricLabel(metric) { return VOICE_NAME_TREND_LABELS[metric] || metric; } function voiceNameAnalyticsMetricValue(metric, payload = state.analytics.voiceNameOverview || emptyVoiceNameAnalyticsOverview()) { if (metric === 'scenario_calls') { return Number(payload?.totals?.scenario_calls || 0); } return Number(payload?.metrics?.[metric] || 0); } function formatVoiceNameAnalyticsMetric(metric, value) { const meta = VOICE_NAME_METRIC_META[metric] || { unit: 'count' }; if (value === null || value === undefined || Number.isNaN(Number(value))) { return '—'; } if (meta.unit === 'pct') { return `${formatAnalyticsNumber(value, 2)}%`; } return formatAnalyticsNumber(value, 0); } function formatVoiceNameAnalyticsAxisValue(metric, value) { const meta = VOICE_NAME_METRIC_META[metric] || { unit: 'count' }; return formatAnalyticsNumber(value, meta.unit === 'pct' ? 1 : 0); } function voiceNameAnalyticsDelta(metric, currentPayload, previousPayload) { const current = voiceNameAnalyticsMetricValue(metric, currentPayload); const previous = voiceNameAnalyticsMetricValue(metric, previousPayload); const diff = Number(current || 0) - Number(previous || 0); const meta = VOICE_NAME_METRIC_META[metric] || { unit: 'count', better: 'neutral' }; let text = signedDelta(diff, meta.unit === 'pct' ? 1 : 0); if (meta.unit === 'pct') { 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 aiAnalyticsMetricLabel(metric) { return AI_ANALYTICS_TREND_LABELS[metric] || metric; } function aiAnalyticsDelta(metric, currentPayload, previousPayload) { const current = aiAnalyticsMetricValue(metric, currentPayload); const previous = aiAnalyticsMetricValue(metric, previousPayload); const meta = AI_ANALYTICS_METRIC_META[metric] || { unit: 'count', better: 'neutral' }; if ( current === null || current === undefined || previous === null || previous === undefined || Number.isNaN(Number(current)) || Number.isNaN(Number(previous)) ) { return { text: '-', tone: 'neutral' }; } const diff = Number(current) - Number(previous); let text = signedDelta(diff, meta.unit === 'pct' ? 1 : 0); if (meta.unit === 'pct') { text = `${text} п.п.`; } else if (meta.unit === 'ms') { text = `${text} ms`; } 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, options = {}) { const value = metricValueFromPayload(metric, payload); const showCompare = state.analytics.compareMode === 'previous'; const delta = showCompare ? analyticsDelta(metric, payload, previousPayload) : null; const note = ANALYTICS_METRIC_META[metric]?.note || ''; const metricDrilldown = Boolean(options.metricDrilldown); const drilldownEnabled = metricDrilldown ? analyticsMetricCanDrilldown(metric, payload) : Boolean(options.drilldown); const coverageBadge = metricDrilldown ? analyticsMetricCoverageBadge(metric, payload) : ''; const classes = [ 'summary-card', 'analytics-card', drilldownEnabled ? 'analytics-card-button' : 'analytics-card-readonly', metricDrilldown ? `analytics-card-coverage-${analyticsMetricCoverageTone(metric, payload)}` : '', ].join(' '); const attributes = drilldownEnabled ? `type="button" data-analytics-drilldown-source="${escapeHtml(options.sourceType || 'overview')}" data-analytics-drilldown-label="${escapeHtml(options.sourceLabel || label)}"${metricDrilldown ? ` data-analytics-drilldown-metric="${escapeHtml(metric)}"` : ''}` : ''; const hint = metricDrilldown ? analyticsMetricCoverageHint(metric, payload) : drilldownEnabled ? 'Открыть детализацию по обращениям' : 'Точная детализация появится после связи KPI с interaction_id.'; const tag = drilldownEnabled ? 'button' : 'div'; return ` <${tag} class="${classes}" ${attributes}>
${label}
${coverageBadge} ${showCompare ? `${delta.text}` : ''}
${formatAnalyticsMetric(metric, value)}
${note ? `
${note}
` : ''}
${hint}
`; } function formatTime(ts) { if (!ts) { return '-'; } const date = new Date(ts); if (Number.isNaN(date.getTime())) { return ts; } return date.toLocaleString(); } function analyticsPresetLabel(preset) { return ANALYTICS_PRESET_LABELS[preset] || preset; } function analyticsMetricLabel(metric) { return ANALYTICS_TREND_LABELS[metric] || metric; } function analyticsQueueName(queueId) { if (queueId === 'all') { return 'Все очереди'; } if (!queueId) { return 'Без очереди'; } const queue = state.analytics.queueOptions.find((item) => item.queue_id === queueId); return queue?.name || queueId; } function interactionStatusLabel(status) { const labels = { new: 'Новая', in_progress: 'В работе', escalated: 'Эскалация', closed: 'Закрыта', abandoned: 'Потеряна', }; return labels[status] || status || 'Не указан'; } function analyticsMetricDrilldownNote(metric, payload = state.analytics.overview || emptyKpiEnvelope()) { const metricNote = ANALYTICS_METRIC_META[metric]?.note || ''; const coverageHint = analyticsMetricCoverageHint(metric, payload); return [metricNote, coverageHint] .filter(Boolean) .join(' '); } function analyticsDrilldownFactBadges(item) { if (!item || typeof item !== 'object') { return []; } const badges = []; if (item.wait_seconds !== null && item.wait_seconds !== undefined) { badges.push({ tone: 'wait', label: `Ожидание ${formatAnalyticsNumber(item.wait_seconds, 0)} с` }); } if (item.handle_seconds !== null && item.handle_seconds !== undefined) { badges.push({ tone: 'handle', label: `Обработка ${formatAnalyticsNumber(item.handle_seconds, 0)} с` }); } if (item.within_sla === true) { badges.push({ tone: 'sla', label: 'В SLA' }); } else if (item.within_sla === false) { badges.push({ tone: 'sla-miss', label: 'Вне SLA' }); } if (item.resolved_first_contact === true) { badges.push({ tone: 'fcr', label: 'FCR' }); } if (item.abandoned === true) { badges.push({ tone: 'abandoned', label: 'Потеря' }); } return badges; } function renderAnalyticsDrilldownFactBadges(item) { const badges = analyticsDrilldownFactBadges(item); if (!badges.length) { return ''; } return `
${badges .map( (badge) => `${escapeHtml(badge.label)}`, ) .join('')}
`; } function clearAnalyticsDrilldownSearchDebounce() { if (analyticsDrilldownSearchDebounce) { window.clearTimeout(analyticsDrilldownSearchDebounce); analyticsDrilldownSearchDebounce = 0; } } function normalizeAnalyticsDrilldownFilters(filters = {}) { return { from_ts: filters.from_ts || '', to_ts: filters.to_ts || '', metric: filters.metric || '', slice: filters.slice || 'all', reason_key: filters.reason_key || '', queue_id: filters.queue_id || '', channel: filters.channel || '', agent_id: filters.agent_id || '', sl_threshold_seconds: Number(filters.sl_threshold_seconds || 30), status: filters.status || '', q: filters.q || '', sort_by: filters.sort_by || 'created_at', sort_dir: filters.sort_dir || 'desc', }; } function analyticsDrilldownSortValue(filters = state.drilldown.filters) { const normalized = normalizeAnalyticsDrilldownFilters(filters); return `${normalized.sort_by}:${normalized.sort_dir}`; } function parseAnalyticsDrilldownSort(value) { const [sortByRaw = 'created_at', sortDirRaw = 'desc'] = String(value || 'created_at:desc').split(':'); const allowedFields = state.drilldown.mode === 'ai' ? ['created_at', 'updated_at', 'ai_latency_avg_ms', 'status'] : ['created_at', 'updated_at', 'subject']; const sortBy = allowedFields.includes(sortByRaw) ? sortByRaw : 'created_at'; const sortDir = sortDirRaw === 'asc' ? 'asc' : 'desc'; return { sort_by: sortBy, sort_dir: sortDir }; } function analyticsDrilldownSortLabel(filters = state.drilldown.filters) { if (state.drilldown.mode === 'ai') { return aiAnalyticsSortLabel(filters); } const normalized = normalizeAnalyticsDrilldownFilters(filters); const fieldLabels = { created_at: 'созданию', updated_at: 'обновлению', subject: 'теме', }; const dirLabel = normalized.sort_dir === 'asc' ? 'по возрастанию' : 'по убыванию'; return `Сортировка: по ${fieldLabels[normalized.sort_by] || normalized.sort_by}, ${dirLabel}`; } function analyticsDrilldownStatusClass(status) { return String(status || 'unknown').toLowerCase().replace(/[^a-z0-9]+/g, '-'); } function analyticsDrilldownFilenameSource() { if (state.drilldown.mode === 'ai') { const slice = state.drilldown.filters?.slice || 'all'; if (state.drilldown.filters?.reason_key) { return `ai-${slice}-${String(state.drilldown.filters.reason_key).toLowerCase()}`; } if (state.drilldown.filters?.channel) { return `ai-${String(state.drilldown.filters.channel).toLowerCase()}-${slice}`; } return `ai-${slice}`; } if (state.drilldown.sourceType === 'metric' && state.drilldown.metric) { return `metric-${String(state.drilldown.metric).toLowerCase()}`; } if (state.drilldown.sourceType === 'channel' && state.drilldown.filters?.channel) { return `channel-${String(state.drilldown.filters.channel).toLowerCase()}`; } if (state.drilldown.sourceType === 'queue' && state.drilldown.filters?.queue_id) { return `queue-${String(state.drilldown.filters.queue_id).toLowerCase().replace(/[^a-z0-9_-]+/g, '-')}`; } if (state.drilldown.sourceType === 'agent' && state.drilldown.filters?.agent_id) { return `agent-${String(state.drilldown.filters.agent_id).toLowerCase().replace(/[^a-z0-9._-]+/g, '-')}`; } return state.drilldown.sourceType === 'overview' ? 'all-interactions' : String(state.drilldown.sourceType || 'slice'); } function sanitizeAnalyticsTimelineMetadata(metadata) { if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) { return {}; } const blockedKeyParts = ['message', 'messages', 'text', 'body', 'summary', 'transcript', 'recording', 'audio', 'media', 'content']; const safe = {}; Object.entries(metadata).forEach(([key, value]) => { const normalizedKey = String(key || '').toLowerCase(); if (blockedKeyParts.some((part) => normalizedKey.includes(part))) { return; } if (typeof value === 'string') { safe[key] = value.length > 120 ? `[hidden string ${value.length} chars]` : value; return; } if (Array.isArray(value)) { safe[key] = `[items: ${value.length}]`; return; } if (value && typeof value === 'object') { safe[key] = '[object]'; return; } safe[key] = value; }); return safe; } function rememberAnalyticsDrilldownReturnFocus() { const active = document.activeElement; analyticsDrilldownReturnFocus = active instanceof HTMLElement ? active : null; } function restoreAnalyticsDrilldownReturnFocus() { if (analyticsDrilldownReturnFocus instanceof HTMLElement && document.contains(analyticsDrilldownReturnFocus)) { analyticsDrilldownReturnFocus.focus(); } analyticsDrilldownReturnFocus = null; } function analyticsDrilldownFocusableElements() { const drawer = $('analyticsDrilldownDrawer'); if (!drawer || !state.drilldown.open) { return []; } return Array.from( drawer.querySelectorAll( 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', ), ).filter( (element) => !element.hasAttribute('hidden') && element.getAttribute('aria-hidden') !== 'true' && element.offsetParent !== null, ); } function focusAnalyticsDrilldownPrimaryControl(selectText = false) { const searchInput = $('analyticsDrilldownSearch'); if (searchInput && !searchInput.disabled) { searchInput.focus(); if (selectText && typeof searchInput.select === 'function') { searchInput.select(); } return; } $('analyticsDrilldownDrawer')?.focus(); } function handleAnalyticsDrilldownWindowKeydown(event) { if (!state.drilldown.open) { return; } if (event.key === 'Escape') { event.preventDefault(); closeAnalyticsDrilldown(); return; } if (event.key === '/' && !event.ctrlKey && !event.metaKey && !event.altKey) { const targetTag = document.activeElement?.tagName || ''; if (!['INPUT', 'TEXTAREA', 'SELECT'].includes(targetTag)) { event.preventDefault(); focusAnalyticsDrilldownPrimaryControl(true); } return; } if (event.key !== 'Tab') { return; } const focusable = analyticsDrilldownFocusableElements(); if (!focusable.length) { return; } const first = focusable[0]; const last = focusable[focusable.length - 1]; const active = document.activeElement; if (event.shiftKey && active === first) { event.preventDefault(); last.focus(); return; } if (!event.shiftKey && active === last) { event.preventDefault(); first.focus(); } } function maybeScrollAnalyticsDrilldownDetailIntoView() { if (!window.matchMedia('(max-width: 960px)').matches) { return; } window.requestAnimationFrame(() => { $('analyticsDrilldownDetail')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); } function syncAnalyticsDrilldownControls() { const searchInput = $('analyticsDrilldownSearch'); const statusSelect = $('analyticsDrilldownStatus'); const sortSelect = $('analyticsDrilldownSort'); const clearBtn = $('analyticsDrilldownClearBtn'); const exportBtn = $('analyticsDrilldownExportBtn'); const isOpen = state.drilldown.open; const localFiltersEnabled = isOpen && state.drilldown.mode === 'interaction'; const filters = normalizeAnalyticsDrilldownFilters(state.drilldown.filters || {}); if (searchInput) { searchInput.value = isOpen ? filters.q : ''; searchInput.disabled = !localFiltersEnabled || state.drilldown.loading || state.drilldown.exporting; searchInput.placeholder = state.drilldown.mode === 'metric' ? 'Поиск доступен только для среза обращений' : 'ID, тема или назначенный'; } if (statusSelect) { statusSelect.value = isOpen ? (filters.status || 'all') : 'all'; statusSelect.disabled = !localFiltersEnabled || state.drilldown.loading || state.drilldown.exporting; } if (sortSelect) { sortSelect.value = analyticsDrilldownSortValue(filters); sortSelect.disabled = !localFiltersEnabled || state.drilldown.loading || state.drilldown.exporting; } if (clearBtn) { clearBtn.disabled = !localFiltersEnabled || state.drilldown.loading || state.drilldown.exporting; } if (exportBtn) { exportBtn.disabled = !isOpen || state.drilldown.loading || state.drilldown.exporting || !state.drilldown.total; exportBtn.textContent = state.drilldown.exporting ? 'Готовим CSV...' : 'Экспорт CSV'; } } function resetAnalyticsDrilldown() { clearAnalyticsDrilldownSearchDebounce(); state.drilldown = emptyDrilldownState(); } function closeAnalyticsDrilldown(options = {}) { if (!state.drilldown.open && !state.drilldown.loading && !state.drilldown.error) { return; } resetAnalyticsDrilldown(); renderAnalyticsDrilldown(); if (!options.skipUrlSync) { syncAnalyticsUrlState(); } window.requestAnimationFrame(() => { restoreAnalyticsDrilldownReturnFocus(); }); } function analyticsDrilldownBaseFilters(rangeMeta = state.analytics.lastRangeMeta || analyticsRangeFromControls()) { return normalizeAnalyticsDrilldownFilters({ from_ts: rangeMeta.current.from.toISOString(), to_ts: rangeMeta.current.to.toISOString(), metric: '', slice: 'all', reason_key: '', queue_id: state.analytics.queueId !== 'all' ? state.analytics.queueId : '', channel: state.analytics.channel !== 'all' ? state.analytics.channel : '', agent_id: '', sl_threshold_seconds: 30, status: '', q: '', sort_by: 'created_at', sort_dir: 'desc', }); } function analyticsDrilldownSourceLabel(sourceType, sourceValue = '') { if (sourceType === 'agent') { return `Агент: ${sourceValue}`; } if (sourceType === 'ai-overview') { return aiAnalyticsSliceLabel(sourceValue || 'all'); } if (sourceType === 'ai-channel') { return `AI-канал: ${analyticsChannelLabel(sourceValue)}`; } if (sourceType === 'ai-reason') { return `Причина передачи: ${sourceValue}`; } if (sourceType === 'metric') { return `KPI: ${sourceValue}`; } if (sourceType === 'channel') { return `Канал: ${analyticsChannelLabel(sourceValue)}`; } if (sourceType === 'queue') { return `Очередь: ${analyticsQueueName(sourceValue)}`; } return 'Все обращения'; } function analyticsDrilldownQuery(filters, limit, offset) { const normalized = normalizeAnalyticsDrilldownFilters(filters); const params = new URLSearchParams(); params.set('from_ts', normalized.from_ts); params.set('to_ts', normalized.to_ts); params.set('limit', String(limit)); params.set('offset', String(offset)); params.set('sort_by', normalized.sort_by || 'created_at'); params.set('sort_dir', normalized.sort_dir || 'desc'); if (normalized.queue_id) { params.set('queue_id', normalized.queue_id); } if (normalized.channel) { params.set('channel', normalized.channel); } if (normalized.agent_id) { params.set('agent_id', normalized.agent_id); } if (normalized.status) { params.set('status', normalized.status); } if (normalized.q) { params.set('q', normalized.q); } return params.toString(); } function aiAnalyticsDrilldownQuery(filters, limit, offset) { const normalized = normalizeAnalyticsDrilldownFilters(filters); const params = new URLSearchParams(); params.set('from_ts', normalized.from_ts); params.set('to_ts', normalized.to_ts); params.set('slice', normalized.slice || 'all'); params.set('limit', String(limit)); params.set('offset', String(offset)); params.set('sort_by', normalized.sort_by || 'created_at'); params.set('sort_dir', normalized.sort_dir || 'desc'); if (normalized.queue_id) { params.set('queue_id', normalized.queue_id); } if (normalized.channel) { params.set('channel', normalized.channel); } if (normalized.reason_key) { params.set('reason_key', normalized.reason_key); } if (normalized.status) { params.set('status', normalized.status); } if (normalized.q) { params.set('q', normalized.q); } return params.toString(); } function analyticsMetricDrilldownQuery(filters, limit, offset) { const normalized = normalizeAnalyticsDrilldownFilters(filters); const params = new URLSearchParams(); params.set('from_ts', normalized.from_ts); params.set('to_ts', normalized.to_ts); params.set('metric', normalized.metric); params.set('limit', String(limit)); params.set('offset', String(offset)); params.set('sl_threshold_seconds', String(normalized.sl_threshold_seconds || 30)); if (normalized.queue_id) { params.set('queue_id', normalized.queue_id); } if (normalized.channel) { params.set('channel', normalized.channel); } return params.toString(); } function analyticsDrilldownChips() { if (!state.drilldown.filters) { return []; } const drilldownFilters = normalizeAnalyticsDrilldownFilters(state.drilldown.filters); const chips = []; chips.push(state.drilldown.sourceLabel || 'Детализация'); chips.push( `${formatTime(drilldownFilters.from_ts)} - ${formatTime(drilldownFilters.to_ts)}`, ); if (drilldownFilters.channel) { chips.push(analyticsChannelLabel(drilldownFilters.channel)); } if (drilldownFilters.queue_id) { chips.push(analyticsQueueName(drilldownFilters.queue_id)); } if (drilldownFilters.agent_id) { chips.push(`Агент: ${drilldownFilters.agent_id}`); } if (drilldownFilters.status) { chips.push(`Статус: ${interactionStatusLabel(drilldownFilters.status)}`); } if (drilldownFilters.q) { chips.push(`Поиск: ${drilldownFilters.q}`); } if (state.drilldown.mode === 'metric' && state.drilldown.metric) { chips.push(analyticsMetricCoverageLabel(state.drilldown.metric)); } else { chips.push(analyticsDrilldownSortLabel(drilldownFilters)); } return chips; } function updateAnalyticsRangeHint(range) { const box = $('analyticsRangeHint'); if (!box) { return; } const filters = []; if (state.analytics.queueId !== 'all') { const queue = state.analytics.queueOptions.find((item) => item.queue_id === state.analytics.queueId); filters.push(`очередь: ${queue?.name || state.analytics.queueId}`); } if (state.analytics.channel !== 'all') { filters.push(`канал: ${analyticsChannelLabel(state.analytics.channel)}`); } filters.push( state.analytics.compareMode === 'previous' ? 'сравнение с предыдущим окном' : 'только текущий срез', ); const activeView = state.analytics.savedViews.find((item) => item.id === state.analytics.activeViewId); if (activeView) { filters.push(`вид: ${activeView.name}`); } const suffix = filters.length ? ` | ${filters.join(' | ')}` : ''; box.textContent = range.custom ? `Окно анализа: ${formatTime(range.current.from.toISOString())} - ${formatTime(range.current.to.toISOString())}${suffix}` : `Период: ${analyticsPresetLabel(range.preset)}${suffix}`; } function renderAnalyticsNarrative(rangeMeta) { const box = $('analyticsNarrative'); if (!box) { return; } const current = state.analytics.overview || emptyKpiEnvelope(); const previous = state.analytics.compare || emptyKpiEnvelope(); const total = Number(current?.volume?.total || 0); const sl = Number(current?.kpi?.SL || 0); const abandon = Number(current?.kpi?.Abandon || 0); const digitalShare = Number(current?.kpi?.DigitalShare || 0); const delta = analyticsDelta('total', current, previous); const filters = []; if (state.analytics.channel !== 'all') { filters.push(analyticsChannelLabel(state.analytics.channel)); } if (state.analytics.queueId !== 'all') { const queue = state.analytics.queueOptions.find((item) => item.queue_id === state.analytics.queueId); filters.push(queue?.name || state.analytics.queueId); } const activeView = state.analytics.savedViews.find((item) => item.id === state.analytics.activeViewId); if (activeView) { filters.push(activeView.name); } const chips = [ analyticsPresetLabel(rangeMeta.preset), analyticsCompareModeLabel(state.analytics.compareMode), ...filters, ]; const deltaBadge = state.analytics.compareMode === 'previous' ? `${delta.text} к предыдущему окну` : 'Сравнение отключено'; box.innerHTML = `
${formatAnalyticsNumber(total, 0)} обращений

За выбранный период ${formatAnalyticsNumber(sl, 2)}% потока уложилось в SLA, потери составили ${formatAnalyticsNumber(abandon, 2)}%, а цифровая доля достигла ${formatAnalyticsNumber(digitalShare, 2)}%.

${deltaBadge}
${chips.map((chip) => `${escapeHtml(chip)}`).join('')}
`; } function renderAnalyticsCompareCard(label, metric, currentPayload, previousPayload) { const currentValue = metricValueFromPayload(metric, currentPayload); const previousValue = metricValueFromPayload(metric, previousPayload); const delta = analyticsDelta(metric, currentPayload, previousPayload); const note = ANALYTICS_METRIC_META[metric]?.note || ''; const coverageBadge = analyticsMetricCoverageBadge(metric, currentPayload); return `
${label}
${coverageBadge} ${delta.text}
Текущее окно ${formatAnalyticsMetric(metric, currentValue)}
Предыдущее окно ${formatAnalyticsMetric(metric, previousValue)}

${note}

`; } function renderAnalyticsComparePanel() { const box = $('analyticsComparePanel'); if (!box) { return; } if (state.analytics.compareMode !== 'previous') { box.hidden = true; box.innerHTML = ''; return; } const current = state.analytics.overview || emptyKpiEnvelope(); const previous = state.analytics.compare || emptyKpiEnvelope(); box.hidden = false; box.innerHTML = `

Сравнение периодов

Быстрый разрез по главным изменениям между текущим и предыдущим окном без перехода в отдельный отчёт.

V2 compare mode
${[ renderAnalyticsCompareCard('Обращения', 'total', current, previous), renderAnalyticsCompareCard('SL', 'SL', current, previous), renderAnalyticsCompareCard('Среднее ожидание', 'ASA', current, previous), renderAnalyticsCompareCard('Потери', 'Abandon', current, previous), ].join('')}
`; } function renderAnalyticsOverview() { const current = state.analytics.overview || emptyKpiEnvelope(); const previous = state.analytics.compare || emptyKpiEnvelope(); $('analyticsOverview').innerHTML = [ renderAnalyticsCard('Обращения', 'total', current, previous, { metricDrilldown: true, sourceType: 'metric', sourceLabel: 'Обращения', }), renderAnalyticsCard('Обработано', 'answered', current, previous, { metricDrilldown: true, sourceType: 'metric', sourceLabel: 'Обработано', }), renderAnalyticsCard('SL', 'SL', current, previous, { metricDrilldown: true, sourceType: 'metric', sourceLabel: 'SL', }), renderAnalyticsCard('Среднее ожидание', 'ASA', current, previous, { metricDrilldown: true, sourceType: 'metric', sourceLabel: 'Среднее ожидание', }), renderAnalyticsCard('Среднее время', 'AHT', current, previous, { metricDrilldown: true, sourceType: 'metric', sourceLabel: 'Среднее время', }), renderAnalyticsCard('Потери', 'Abandon', current, previous, { metricDrilldown: true, sourceType: 'metric', sourceLabel: 'Потери', }), renderAnalyticsCard('FCR', 'FCR', current, previous, { metricDrilldown: true, sourceType: 'metric', sourceLabel: 'FCR', }), renderAnalyticsCard('Цифровая доля', 'DigitalShare', current, previous, { metricDrilldown: true, sourceType: 'metric', sourceLabel: 'Цифровая доля', }), ].join(''); } function voiceNameLanguageLabel(language) { if (language === 'ru') { return 'Русский'; } if (language === 'kz') { return 'Казахский'; } if (language === 'unknown' || !language) { return 'Не определён'; } return String(language); } function voiceNameAnalyticsMetricDetail(metric, payload) { const totals = payload?.totals || {}; const allHandoffs = Number(totals.handoff_confirmed_name || 0) + Number(totals.handoff_unconfirmed_name || 0); if (metric === 'scenario_calls') { return `${formatAnalyticsNumber(totals.scenario_calls || 0, 0)} звонков прошли через сценарий`; } if (metric === 'start_capture_rate') { return `${formatAnalyticsNumber(totals.start_obtained || 0, 0)} из ${formatAnalyticsNumber(totals.scenario_calls || 0, 0)} звонков`; } if (metric === 'downstream_rescue_rate') { return `${formatAnalyticsNumber(totals.downstream_ai_obtained || 0, 0)} из ${formatAnalyticsNumber(totals.needed_downstream || 0, 0)} случаев`; } if (metric === 'handoff_unconfirmed_rate') { return `${formatAnalyticsNumber(totals.handoff_unconfirmed_name || 0, 0)} из ${formatAnalyticsNumber(allHandoffs || 0, 0)} передач`; } return `${formatAnalyticsNumber(totals.manual_corrected || 0, 0)} из ${formatAnalyticsNumber(allHandoffs || 0, 0)} передач`; } function renderVoiceNameAnalyticsCard(label, metric, currentPayload, previousPayload) { const value = voiceNameAnalyticsMetricValue(metric, currentPayload); const showCompare = state.analytics.compareMode === 'previous'; const delta = showCompare ? voiceNameAnalyticsDelta(metric, currentPayload, previousPayload) : null; const note = VOICE_NAME_METRIC_META[metric]?.note || ''; const detail = voiceNameAnalyticsMetricDetail(metric, currentPayload); return `
${label}
${showCompare ? `${delta.text}` : ''}
${formatVoiceNameAnalyticsMetric(metric, value)}
${detail ? `
${detail}
` : ''} ${note ? `
${note}
` : ''}
`; } function renderVoiceNameAnalyticsOverview() { const box = $('voiceNameAnalyticsOverview'); if (!box) { return; } if (!voiceNameAnalyticsSupportedChannel() || state.analytics.voiceNameError) { box.innerHTML = ''; return; } const current = state.analytics.voiceNameOverview || emptyVoiceNameAnalyticsOverview(); if (!state.analytics.loading && !Number(current?.totals?.scenario_calls || 0)) { box.innerHTML = ''; return; } const previous = state.analytics.voiceNameCompare || emptyVoiceNameAnalyticsOverview(); box.innerHTML = [ renderVoiceNameAnalyticsCard('Звонки в сценарии', 'scenario_calls', current, previous), renderVoiceNameAnalyticsCard('Имя взято сразу', 'start_capture_rate', current, previous), renderVoiceNameAnalyticsCard('Имя добрал AI после follow-up', 'downstream_rescue_rate', current, previous), renderVoiceNameAnalyticsCard('Передача без подтверждённого имени', 'handoff_unconfirmed_rate', current, previous), renderVoiceNameAnalyticsCard('Ручное исправление оператором', 'manual_correction_rate', current, previous), ].join(''); } function renderVoiceNameAnalyticsTrendChart() { const container = $('voiceNameAnalyticsTrendChart'); if (!container) { return; } if (!voiceNameAnalyticsSupportedChannel()) { container.innerHTML = ''; return; } if (state.analytics.voiceNameTrendError && !state.analytics.voiceNameTrend?.points?.length) { container.innerHTML = `
Не удалось обновить тренд по voice name-flow: ${escapeHtml(state.analytics.voiceNameTrendError)}
`; return; } const metric = state.analytics.voiceNameTrendMetric || 'scenario_calls'; const trend = state.analytics.voiceNameTrend || emptyVoiceNameAnalyticsTimeseries(metric); const items = Array.isArray(trend.points) ? trend.points : []; const current = state.analytics.voiceNameOverview || emptyVoiceNameAnalyticsOverview(); const previous = state.analytics.voiceNameCompare || emptyVoiceNameAnalyticsOverview(); const delta = state.analytics.compareMode === 'previous' ? voiceNameAnalyticsDelta(metric, current, previous) : null; const lastPoint = [...items].reverse().find((item) => item.value !== null && item.value !== undefined) || null; renderTrendChart(container, { title: voiceNameAnalyticsMetricLabel(metric), items, valueAccessor: (item) => item.value, labelAccessor: (item) => formatAnalyticsBucketLabel(item.ts, trend.interval || 'day'), axisFormatter: (value) => formatVoiceNameAnalyticsAxisValue(metric, value), valueFormatter: (value) => formatVoiceNameAnalyticsMetric(metric, value), summaryText: lastPoint ? `${formatVoiceNameAnalyticsMetric(metric, lastPoint.value)} в последней точке` : '—', metaBadge: delta ? `${escapeHtml(delta.text)}` : '', ariaLabel: 'График voice name-flow аналитики', emptyText: 'За выбранный период недостаточно точек для анализа сценария сбора имени.', }); } function renderVoiceNameAnalyticsFunnel() { const container = $('voiceNameAnalyticsFunnel'); if (!container) { return; } if (!voiceNameAnalyticsSupportedChannel() || state.analytics.voiceNameError) { container.innerHTML = ''; return; } const rows = Array.isArray(state.analytics.voiceNameOverview?.breakdowns?.funnel) ? state.analytics.voiceNameOverview.breakdowns.funnel : []; if (!rows.length) { container.innerHTML = '
Нет данных по этапам сценария за выбранный период.
'; return; } container.innerHTML = `
Этап Сессии Доля
${rows.map((item) => `
${escapeHtml(VOICE_NAME_FUNNEL_LABELS[item.stage] || item.label || item.stage)} ${formatAnalyticsNumber(item.sessions || 0, 0)} ${formatAnalyticsNumber(item.share || 0, 2)}%
`).join('')} `; } function renderVoiceNameAnalyticsLanguageTable() { const container = $('voiceNameAnalyticsLanguageTable'); if (!container) { return; } if (!voiceNameAnalyticsSupportedChannel() || state.analytics.voiceNameError) { container.innerHTML = ''; return; } const rows = Array.isArray(state.analytics.voiceNameOverview?.breakdowns?.by_language) ? state.analytics.voiceNameOverview.breakdowns.by_language : []; if (!rows.length) { container.innerHTML = '
По языкам пока нет достаточных данных.
'; return; } container.innerHTML = `
Язык Звонки Старт AI после follow-up Передача без имени Ручное исправление
${rows.map((item) => `
${escapeHtml(voiceNameLanguageLabel(item.language))} ${formatAnalyticsNumber(item.scenario_calls || 0, 0)} ${formatAnalyticsNumber(item.start_capture_rate || 0, 2)}% ${formatAnalyticsNumber(item.downstream_rescue_rate || 0, 2)}% ${formatAnalyticsNumber(item.handoff_unconfirmed_rate || 0, 2)}% ${formatAnalyticsNumber(item.manual_correction_rate || 0, 2)}%
`).join('')} `; } function renderVoiceNameAnalyticsQueueTable() { const container = $('voiceNameAnalyticsQueueTable'); if (!container) { return; } if (!voiceNameAnalyticsSupportedChannel() || state.analytics.voiceNameError) { container.innerHTML = ''; return; } const rows = Array.isArray(state.analytics.voiceNameOverview?.breakdowns?.by_queue) ? state.analytics.voiceNameOverview.breakdowns.by_queue : []; if (!rows.length) { container.innerHTML = '
По очередям пока нет данных по voice name-flow.
'; return; } container.innerHTML = `
Очередь Звонки Старт AI после follow-up Передача без имени Ручное исправление
${rows.map((item) => `
${escapeHtml(analyticsQueueName(item.queue_id))}${escapeHtml(item.queue_id)} ${formatAnalyticsNumber(item.scenario_calls || 0, 0)} ${formatAnalyticsNumber(item.start_capture_rate || 0, 2)}% ${formatAnalyticsNumber(item.downstream_rescue_rate || 0, 2)}% ${formatAnalyticsNumber(item.handoff_unconfirmed_rate || 0, 2)}% ${formatAnalyticsNumber(item.manual_correction_rate || 0, 2)}%
`).join('')} `; } function renderVoiceNameAnalyticsHandoffTable() { const container = $('voiceNameAnalyticsHandoffTable'); if (!container) { return; } if (!voiceNameAnalyticsSupportedChannel() || state.analytics.voiceNameError) { container.innerHTML = ''; return; } const rows = Array.isArray(state.analytics.voiceNameOverview?.breakdowns?.handoff) ? state.analytics.voiceNameOverview.breakdowns.handoff : []; if (!rows.length) { container.innerHTML = '
Разрез по передачам пока пуст для выбранного окна.
'; return; } container.innerHTML = `
Итог передачи Звонки Доля
${rows.map((item) => `
${escapeHtml(VOICE_NAME_HANDOFF_LABELS[item.outcome] || item.label || item.outcome)} ${formatAnalyticsNumber(item.sessions || 0, 0)} ${formatAnalyticsNumber(item.share || 0, 2)}%
`).join('')} `; } function renderVoiceNameAnalyticsEmptyState() { const box = $('voiceNameAnalyticsEmptyState'); if (!box) { return; } if (!voiceNameAnalyticsSupportedChannel()) { box.hidden = false; box.textContent = 'Аналитика voice name-flow доступна только для срезов “Все каналы” и “Голос”.'; return; } if (state.analytics.voiceNameError) { box.hidden = false; box.textContent = `Не удалось обновить voice name-flow аналитику: ${state.analytics.voiceNameError}`; return; } const total = Number(state.analytics.voiceNameOverview?.totals?.scenario_calls || 0); if (!state.analytics.loading && total === 0) { box.hidden = false; box.textContent = mockVoiceNameSeed().empty_note || 'За выбранный период не найдено звонков, прошедших через сценарий сбора имени.'; return; } box.hidden = true; box.textContent = ''; } function aiAnalyticsMetricDetail(metric, payload) { const totals = payload?.totals || {}; if (metric === 'containment_rate') { return `${formatAnalyticsNumber(totals.sessions_contained || 0, 0)} из ${formatAnalyticsNumber(totals.sessions_started || 0, 0)} сессий`; } if (metric === 'handoff_rate') { return `${formatAnalyticsNumber(totals.sessions_handoff || 0, 0)} из ${formatAnalyticsNumber(totals.sessions_started || 0, 0)} сессий`; } if (metric === 'ai_latency_avg_ms') { return `${formatAnalyticsNumber(totals.assistant_turns || 0, 0)} ответов AI`; } if (metric === 'closed_without_operator_rate') { return `${formatAnalyticsNumber(totals.sessions_closed_without_operator || 0, 0)} закрытий без оператора`; } return ''; } function renderAiAnalyticsCard(label, metric, currentPayload, previousPayload) { const value = aiAnalyticsMetricValue(metric, currentPayload); const showCompare = state.analytics.compareMode === 'previous'; const delta = showCompare ? aiAnalyticsDelta(metric, currentPayload, previousPayload) : null; const note = AI_ANALYTICS_METRIC_META[metric]?.note || ''; const detail = aiAnalyticsMetricDetail(metric, currentPayload); return `
${label}
${showCompare ? `${delta.text}` : ''}
${formatAiAnalyticsMetric(metric, value)}
${detail ? `
${detail}
` : ''} ${note ? `
${note}
` : ''}
`; } function renderAiAnalyticsOverview() { const box = $('aiAnalyticsOverview'); if (!box) { return; } if (!aiAnalyticsSupportedChannel() || state.analytics.aiError) { box.innerHTML = ''; return; } const current = state.analytics.aiOverview || emptyAiAnalyticsOverview(); if (!state.analytics.loading && Number(current?.totals?.sessions_started || 0) === 0) { box.innerHTML = ''; return; } const previous = state.analytics.aiCompare || emptyAiAnalyticsOverview(); box.innerHTML = [ renderAiAnalyticsCard('Закрыто AI', 'containment_rate', current, previous), renderAiAnalyticsCard('Передано оператору', 'handoff_rate', current, previous), renderAiAnalyticsCard('Задержка AI', 'ai_latency_avg_ms', current, previous), renderAiAnalyticsCard('Закрыто без оператора', 'closed_without_operator_rate', current, previous), ].join(''); } function renderAiAnalyticsChannelComparison() { const container = $('aiAnalyticsChannelComparison'); const rows = Array.isArray(state.analytics.aiOverview?.breakdowns?.by_channel) ? state.analytics.aiOverview.breakdowns.by_channel : []; if (!container) { return; } if (!aiAnalyticsSupportedChannel() || state.analytics.aiError) { container.innerHTML = ''; return; } if (!rows.length) { container.innerHTML = '
Нет AI-сессий по Telegram и WhatsApp за выбранный период.
'; return; } container.innerHTML = `
Канал Сессии Только AI С оператором Передача Средняя задержка
${rows .map((item) => { const aiOnlyShare = item.sessions_started ? (item.ai_only_sessions / item.sessions_started) * 100 : 0; const humanTouchedShare = item.sessions_started ? (item.human_touched_sessions / item.sessions_started) * 100 : 0; return `
${escapeHtml(analyticsChannelLabel(item.channel))} ${formatAnalyticsNumber(item.sessions_started, 0)} ${formatAnalyticsNumber(aiOnlyShare, 2)}% ${formatAnalyticsNumber(humanTouchedShare, 2)}% ${formatAnalyticsNumber(item.handoff_rate || 0, 2)}% ${formatAiAnalyticsMetric('ai_latency_avg_ms', item.ai_latency_avg_ms)}
`; }) .join('')} `; } function renderAiAnalyticsCoverage() { const container = $('aiAnalyticsCoverage'); const coverage = state.analytics.aiOverview?.coverage || emptyAiAnalyticsOverview().coverage; if (!container) { return; } if (!aiAnalyticsSupportedChannel() || state.analytics.aiError) { container.innerHTML = ''; return; } if (!state.analytics.loading && Number(state.analytics.aiOverview?.totals?.sessions_started || 0) === 0) { container.innerHTML = ''; return; } container.innerHTML = `
${formatAnalyticsNumber(coverage.sessions_with_interaction_id || 0, 0)}

AI-сессий связаны с ID обращения

${formatAnalyticsNumber(coverage.sessions_with_queue_id || 0, 0)}

AI-сессий имеют ID очереди через обращение или диалог

${formatAnalyticsNumber(coverage.sessions_with_latency_turns || 0, 0)}

AI-сессий содержат данные по задержке ответов модели

${formatAnalyticsNumber(coverage.sessions_with_terminal_state || 0, 0)}

AI-сессий дошли до финального состояния

`; } function agentAnalyticsMetricValue(metric, payload = state.analytics.agentOverview || emptyAgentAnalyticsOverview()) { const totals = payload?.totals || {}; if (metric === 'agents_with_activity') { return Number(totals.agents_with_activity || 0); } if (metric === 'avg_interactions_per_agent') { return Number(totals.avg_interactions_per_agent || 0); } if (metric === 'avg_handle_seconds') { return totals.avg_handle_seconds === null || totals.avg_handle_seconds === undefined ? null : Number(totals.avg_handle_seconds); } if (metric === 'avg_fcr_rate') { return totals.avg_fcr_rate === null || totals.avg_fcr_rate === undefined ? null : Number(totals.avg_fcr_rate); } return Number(totals.agents_total || 0); } function formatAgentAnalyticsMetric(metric, value) { if (value === null || value === undefined) { return '—'; } if (metric === 'avg_handle_seconds') { return `${formatAnalyticsNumber(value, 1)} с`; } if (metric === 'avg_fcr_rate') { return `${formatAnalyticsNumber(value, 1)}%`; } if (metric === 'avg_interactions_per_agent') { return formatAnalyticsNumber(value, 1); } return formatAnalyticsNumber(value, 0); } function agentAnalyticsDelta(metric, currentPayload, previousPayload) { const current = agentAnalyticsMetricValue(metric, currentPayload); const previous = agentAnalyticsMetricValue(metric, previousPayload); const normalizedCurrent = current === null || current === undefined ? 0 : Number(current); const normalizedPrevious = previous === null || previous === undefined ? 0 : Number(previous); const diff = normalizedCurrent - normalizedPrevious; const digits = metric === 'agents_total' || metric === 'agents_with_activity' ? 0 : 1; let text = signedDelta(diff, digits); if (metric === 'avg_handle_seconds') { text = `${text} с`; } else if (metric === 'avg_fcr_rate') { text = `${text} п.п.`; } let tone = 'neutral'; if (diff !== 0) { const higherIsBetter = !['avg_handle_seconds'].includes(metric); tone = diff > 0 ? (higherIsBetter ? 'positive' : 'negative') : (higherIsBetter ? 'negative' : 'positive'); } return { text, tone }; } function agentAnalyticsStateLabel(stateValue) { const stateLabels = { READY: 'Готов', BUSY: 'Занят', BREAK: 'Перерыв', OFFLINE: 'Оффлайн', }; return stateLabels[stateValue] || stateValue || '—'; } function renderAgentAnalyticsCard(label, metric, currentPayload, previousPayload) { const value = agentAnalyticsMetricValue(metric, currentPayload); const delta = state.analytics.compareMode === 'previous' ? agentAnalyticsDelta(metric, currentPayload, previousPayload) : null; return `
${label}
${delta ? `${delta.text}` : ''}
${formatAgentAnalyticsMetric(metric, value)}
`; } function renderAgentAnalyticsOverview() { const box = $('agentAnalyticsOverview'); if (!box) { return; } if (state.analytics.agentError) { box.innerHTML = ''; return; } const current = state.analytics.agentOverview || emptyAgentAnalyticsOverview(); const previous = state.analytics.agentCompare || emptyAgentAnalyticsOverview(); const totals = current?.totals || {}; if (!state.analytics.loading && !Number(totals.agents_total || 0) && !Number(totals.agents_with_activity || 0)) { box.innerHTML = ''; return; } box.innerHTML = [ renderAgentAnalyticsCard('Агенты с активностью', 'agents_with_activity', current, previous), renderAgentAnalyticsCard('Средняя нагрузка', 'avg_interactions_per_agent', current, previous), renderAgentAnalyticsCard('Средний AHT', 'avg_handle_seconds', current, previous), renderAgentAnalyticsCard('FCR по агентам', 'avg_fcr_rate', current, previous), ].join(''); } function renderAgentAnalyticsStateStrip() { const box = $('agentAnalyticsStateStrip'); if (!box) { return; } if (state.analytics.agentError) { box.innerHTML = ''; return; } const current = state.analytics.agentOverview || emptyAgentAnalyticsOverview(); const byState = current?.state_snapshot?.by_state || {}; const chips = [ ['READY', Number(byState.READY || 0)], ['BUSY', Number(byState.BUSY || 0)], ['BREAK', Number(byState.BREAK || 0)], ['OFFLINE', Number(byState.OFFLINE || 0)], ]; if (!state.analytics.loading && chips.every(([, value]) => !value)) { box.innerHTML = ''; return; } box.innerHTML = chips .map(([code, value]) => `${agentAnalyticsStateLabel(code)}: ${formatAnalyticsNumber(value, 0)}`) .join(''); } function renderAgentAnalyticsTable() { const container = $('agentAnalyticsTable'); if (!container) { return; } if (state.analytics.agentError) { container.innerHTML = ''; return; } const payload = state.analytics.agentOverview || emptyAgentAnalyticsOverview(); const rows = Array.isArray(payload.items) ? payload.items : []; if (!rows.length) { container.innerHTML = '
За выбранный период пока нет обращений с назначенным агентом.
'; return; } const totalAgents = Number(payload?.totals?.agents_total || rows.length); container.innerHTML = `
Агент Статус Очередь Обращения Обработано AHT FCR
${rows .map((item) => ` `) .join('')} ${totalAgents > rows.length ? `
Показаны ${formatAnalyticsNumber(rows.length, 0)} из ${formatAnalyticsNumber(totalAgents, 0)} агентов по текущему окну.
` : ''} `; } function agentAnalyticsTrendLabel(metric) { const labels = { interactions_per_agent: 'Нагрузка на агента', agents_with_activity: 'Агенты с активностью', avg_handle_seconds: 'Средний AHT', fcr_rate: 'FCR по агентам', }; return labels[metric] || metric || 'Тренд'; } function formatAgentAnalyticsTrendValue(metric, value) { if (value === null || value === undefined) { return '—'; } if (metric === 'avg_handle_seconds') { return `${formatAnalyticsNumber(value, 1)} с`; } if (metric === 'fcr_rate') { return `${formatAnalyticsNumber(value, 1)}%`; } if (metric === 'interactions_per_agent') { return formatAnalyticsNumber(value, 2); } return formatAnalyticsNumber(value, 0); } async function loadVoiceNameAnalyticsTrend(rangeMeta, requestId) { const metric = state.analytics.voiceNameTrendMetric || 'scenario_calls'; const interval = analyticsTimeseriesIntervalForRange(rangeMeta); if (!voiceNameAnalyticsSupportedChannel()) { state.analytics.voiceNameTrendError = ''; return emptyVoiceNameAnalyticsTimeseries( metric, interval, rangeMeta.current.from.toISOString(), rangeMeta.current.to.toISOString(), state.analytics.queueId === 'all' ? null : state.analytics.queueId, null, ); } try { const payload = await fetchVoiceNameAnalyticsTimeseries(rangeMeta.current, metric); if (requestId !== state.analytics.requestId) { return emptyVoiceNameAnalyticsTimeseries(metric, interval); } return payload || emptyVoiceNameAnalyticsTimeseries(metric, interval); } catch (err) { state.analytics.voiceNameTrendError = err.message; return emptyVoiceNameAnalyticsTimeseries( metric, interval, rangeMeta.current.from.toISOString(), rangeMeta.current.to.toISOString(), state.analytics.queueId === 'all' ? null : state.analytics.queueId, null, ); } } async function loadAgentAnalyticsTrend(rangeMeta, requestId) { const metric = state.analytics.agentTrendMetric || 'interactions_per_agent'; const interval = analyticsTimeseriesIntervalForRange(rangeMeta); try { const payload = await fetchAgentAnalyticsTimeseries(rangeMeta.current, metric, interval); if (requestId !== state.analytics.requestId) { return emptyAgentAnalyticsTimeseries(metric, interval); } state.analytics.agentTrendInterval = payload?.interval || interval; return payload || emptyAgentAnalyticsTimeseries(metric, interval); } catch (err) { state.analytics.agentTrendError = err.message; state.analytics.agentTrendInterval = interval; return emptyAgentAnalyticsTimeseries(metric, interval); } } function renderAgentAnalyticsTrendChart() { const container = $('agentAnalyticsTrendChart'); if (!container) { return; } if (state.analytics.agentError) { container.innerHTML = ''; return; } if (state.analytics.agentTrendError && !state.analytics.agentTrend?.points?.length) { container.innerHTML = `
Не удалось обновить тренд по агентам: ${escapeHtml(state.analytics.agentTrendError)}
`; return; } const metric = state.analytics.agentTrendMetric || 'interactions_per_agent'; const trend = state.analytics.agentTrend || emptyAgentAnalyticsTimeseries(metric); const items = Array.isArray(trend.points) ? trend.points : []; const lastPoint = [...items].reverse().find((item) => item.value !== null && item.value !== undefined) || null; renderTrendChart(container, { title: agentAnalyticsTrendLabel(metric), items, valueAccessor: (item) => item.value, labelAccessor: (item) => formatAnalyticsBucketLabel(item.ts, trend.interval || state.analytics.agentTrendInterval || 'day'), axisFormatter: (value) => formatAgentAnalyticsTrendValue(metric, value), valueFormatter: (value) => formatAgentAnalyticsTrendValue(metric, value), summaryText: lastPoint ? `${formatAgentAnalyticsTrendValue(metric, lastPoint.value)} в последней точке` : '—', metaBadge: '', ariaLabel: 'График тренда по агентской аналитике', emptyText: 'За выбранный период недостаточно точек для агентского тренда.', }); } function renderAgentAnalyticsTeamTable() { const container = $('agentAnalyticsTeamTable'); if (!container) { return; } if (state.analytics.agentError) { container.innerHTML = ''; return; } const rows = Array.isArray(state.analytics.agentOverview?.breakdowns?.by_team) ? state.analytics.agentOverview.breakdowns.by_team : []; if (!rows.length) { container.innerHTML = '
По командам пока нет достаточно данных.
'; return; } container.innerHTML = `
Команда Агенты Активны Обращения AHT FCR
${rows .map((item) => `
${escapeHtml(item.label || item.team_key || 'Без очереди')} ${formatAnalyticsNumber(item.agents_total || 0, 0)} ${formatAnalyticsNumber(item.agents_with_activity || 0, 0)} ${formatAnalyticsNumber(item.interactions_total || 0, 0)} ${formatAgentAnalyticsMetric('avg_handle_seconds', item.avg_handle_seconds)} ${formatAgentAnalyticsMetric('avg_fcr_rate', item.fcr_rate)}
`) .join('')} `; } function renderAgentAnalyticsShiftTable() { const container = $('agentAnalyticsShiftTable'); if (!container) { return; } if (state.analytics.agentError) { container.innerHTML = ''; return; } const rows = Array.isArray(state.analytics.agentOverview?.breakdowns?.by_shift) ? state.analytics.agentOverview.breakdowns.by_shift : []; if (!rows.length) { container.innerHTML = '
По сменам пока нет достаточно данных.
'; return; } container.innerHTML = `
Смена Агенты Обращения Обработано AHT FCR
${rows .map((item) => `
${escapeHtml(item.label || item.shift_key)} ${formatAnalyticsNumber(item.agents_with_activity || 0, 0)} ${formatAnalyticsNumber(item.interactions_total || 0, 0)} ${formatAnalyticsNumber(item.answered_total || 0, 0)} ${formatAgentAnalyticsMetric('avg_handle_seconds', item.avg_handle_seconds)} ${formatAgentAnalyticsMetric('avg_fcr_rate', item.fcr_rate)}
`) .join('')} `; } function renderAgentAnalyticsEmptyState() { const box = $('agentAnalyticsEmptyState'); if (!box) { return; } if (state.analytics.agentError) { box.hidden = false; box.textContent = `Не удалось обновить аналитику по агентам: ${state.analytics.agentError}`; return; } const totals = state.analytics.agentOverview?.totals || emptyAgentAnalyticsOverview().totals; if (!state.analytics.loading && !Number(totals.agents_total || 0) && !Number(totals.agents_with_activity || 0)) { box.hidden = false; box.textContent = 'За выбранный период нет агентской активности по текущим фильтрам. Попробуйте расширить окно анализа или снять часть ограничений.'; return; } box.hidden = true; box.textContent = ''; } function renderAiAnalyticsEmptyState() { const box = $('aiAnalyticsEmptyState'); if (!box) { return; } if (!aiAnalyticsSupportedChannel()) { box.hidden = false; box.textContent = 'AI-аналитика доступна только для Telegram и WhatsApp. Снимите фильтр по голосу, веб-чату или электронной почте, чтобы увидеть AI-срез.'; return; } if (state.analytics.aiError) { box.hidden = false; box.textContent = `Не удалось обновить AI-аналитику: ${state.analytics.aiError}`; return; } const total = Number(state.analytics.aiOverview?.totals?.sessions_started || 0); if (!state.analytics.loading && total === 0) { box.hidden = false; box.textContent = 'За выбранный период AI-сессий по Telegram и WhatsApp не найдено. Измените период или снимите часть фильтров.'; return; } box.hidden = true; box.textContent = ''; } function formatAnalyticsAxisValue(metric, value) { const meta = ANALYTICS_METRIC_META[metric] || { unit: 'count' }; if (meta.unit === 'count') { return formatAnalyticsNumber(value, 0); } return formatAnalyticsNumber(value, 1); } function formatAiAnalyticsAxisValue(metric, value) { const meta = AI_ANALYTICS_METRIC_META[metric] || { unit: 'count' }; if (meta.unit === 'pct') { return formatAnalyticsNumber(value, 1); } if (meta.unit === 'ms') { return formatAnalyticsNumber(value, 0); } return formatAnalyticsNumber(value, 0); } function formatAnalyticsBucketLabel(ts, interval = 'day') { if (!ts) { return ''; } const date = new Date(ts); if (Number.isNaN(date.getTime())) { return String(ts); } if (interval === 'hour') { return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); } return date.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' }); } function resetTrendChartHover(container) { if (!container) { return; } const tooltip = container.querySelector('[data-chart-tooltip]'); const summary = container.querySelector('[data-chart-summary]'); if (summary) { summary.textContent = summary.dataset.default || ''; summary.classList.remove('is-hovered'); } if (tooltip) { tooltip.hidden = true; tooltip.innerHTML = ''; } container.querySelectorAll('.analytics-point.is-active').forEach((node) => node.classList.remove('is-active')); } function showTrendChartHover(container, target) { if (!container || !target) { return; } const tooltip = container.querySelector('[data-chart-tooltip]'); const summary = container.querySelector('[data-chart-summary]'); const plot = container.querySelector('.analytics-chart-plot'); if (!tooltip || !summary || !plot) { return; } const label = target.dataset.label || ''; const value = target.dataset.value || ''; const index = target.dataset.index || ''; const x = Number(target.dataset.x || 0); const width = Number(target.dataset.width || 720); const plotWidth = plot.clientWidth || width || 1; const leftPx = Math.max(54, Math.min(plotWidth - 54, (x / width) * plotWidth)); summary.textContent = label && value ? `${label} · ${value}` : value || label || summary.dataset.default || ''; summary.classList.add('is-hovered'); tooltip.hidden = false; tooltip.style.left = `${leftPx}px`; tooltip.innerHTML = ` ${escapeHtml(label)} ${escapeHtml(value)} `; container.querySelectorAll('.analytics-point.is-active').forEach((node) => node.classList.remove('is-active')); const activePoint = container.querySelector(`.analytics-point[data-chart-point="${escapeHtml(index)}"]`); if (activePoint) { activePoint.classList.add('is-active'); } } function bindTrendChartHover(container) { if (!container) { return; } const plot = container.querySelector('.analytics-chart-plot'); if (!plot) { return; } plot.querySelectorAll('.analytics-point-hit').forEach((node) => { node.addEventListener('mouseenter', () => showTrendChartHover(container, node)); node.addEventListener('focus', () => showTrendChartHover(container, node)); }); plot.addEventListener('mouseleave', () => resetTrendChartHover(container)); plot.addEventListener('focusout', (event) => { if (!plot.contains(event.relatedTarget)) { resetTrendChartHover(container); } }); } function renderTrendChart(container, options) { if (!container) { return; } const items = Array.isArray(options.items) ? options.items : []; const points = items.map((item, index) => { const raw = options.valueAccessor(item, index); const value = raw === null || raw === undefined || Number.isNaN(Number(raw)) ? null : Number(raw); return { label: options.labelAccessor(item, index), value, }; }); const numericValues = points .filter((point) => point.value !== null) .map((point) => Number(point.value)); if (!points.length || !numericValues.length) { container.innerHTML = `
${options.emptyText}
`; return; } const maxValue = Math.max(...numericValues, 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 = points.length > 1 ? chartWidth / (points.length - 1) : chartWidth; const pointFor = (value, index) => { const x = paddingLeft + index * step; const y = paddingTop + chartHeight - (value / maxValue) * chartHeight; return { x, y }; }; const grid = [0, 0.25, 0.5, 0.75, 1] .map((ratio) => { const y = paddingTop + chartHeight - ratio * chartHeight; const labelValue = maxValue * ratio; return ` ${escapeHtml(options.axisFormatter(labelValue))} `; }) .join(''); const lineSegments = []; let currentSegment = []; let markers = ''; let hitTargets = ''; points.forEach((point, index) => { if (point.value === null) { if (currentSegment.length > 1) { lineSegments.push(``); } currentSegment = []; return; } const marker = pointFor(point.value, index); currentSegment.push(`${marker.x},${marker.y}`); const formattedValue = options.valueFormatter(point.value); markers += ``; hitTargets += ``; }); if (currentSegment.length > 1) { lineSegments.push(``); } const labelStep = points.length > 12 ? Math.ceil(points.length / 6) : 1; const labels = points .map((point, index) => { if (index % labelStep !== 0 && index !== points.length - 1) { return ''; } const x = paddingLeft + index * step; return `${escapeHtml(point.label)}`; }) .join(''); const summaryText = options.summaryText || ''; const metaBadge = options.metaBadge || ''; container.innerHTML = `
${escapeHtml(options.title)}
${metaBadge} ${escapeHtml(summaryText)}
${grid} ${lineSegments.join('')} ${markers} ${hitTargets} ${labels}
`; resetTrendChartHover(container); bindTrendChartHover(container); } function renderAnalyticsChannelTable() { const container = $('analyticsChannelTable'); const rows = state.analytics.channelRows || []; if (!rows.length) { container.innerHTML = '
Нет данных по каналам за выбранный период.
'; return; } container.innerHTML = `
Канал Всего Обработано Потери Доля ответа
${rows .map((item) => { const answerRate = item.total ? (item.answered / item.total) * 100 : 0; return ` `; }) .join('')} `; } function renderAnalyticsQueueTable() { const container = $('analyticsQueueTable'); const rows = state.analytics.queueRows || []; if (!rows.length) { const reason = state.analytics.queueAccessError ? 'Каталог очередей пока недоступен для этой роли. Можно продолжить анализ без разреза по очередям.' : 'За выбранное окно пока нет данных по очередям. Попробуйте расширить период или снять часть фильтров.'; container.innerHTML = `
${escapeHtml(reason)}
`; return; } container.innerHTML = `
Очередь Всего Обработано SL Потери
${rows .map((item) => ` `) .join('')} `; } function renderAnalyticsCoverage() { const container = $('analyticsCoverage'); if (!container) { return; } 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 : []; container.innerHTML = `

Уже измеряем

${implemented.length ? implemented .map((item) => `${escapeHtml(item.code)} · ${escapeHtml(item.label)}`) .join('') : 'Каталог метрик пока не загружен.'}

Разрезы

${dimensions.length ? dimensions.map((item) => `${escapeHtml(item)}`).join('') : 'Разрезы появятся после загрузки coverage.'}

Фильтры

${filters.length ? filters.map((item) => `${escapeHtml(item)}`).join('') : 'Фильтры появятся после загрузки coverage.'}
Аналитика по агентам

Следующий шаг: персональные метрики по агентам, сменам и командам с детализацией по эффективности.

AI-аналитика

Добавим долю закрытий AI, передачу оператору, задержку ответа и устойчивость AI-сценариев по каналам.

Качество диалогов

Здесь появятся оценки качества речи и диалогов на основе скоринга транскриптов и QA-правил.

${ state.analytics.queueAccessError ? `
Доступ к очередям

${escapeHtml(state.analytics.queueAccessError)}

` : '' }
`; } function renderAnalyticsCoverage() { const container = $('analyticsCoverage'); if (!container) { return; } 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 : []; container.innerHTML = `

Уже измеряем

${implemented.length ? implemented .map((item) => `${escapeHtml(item.code)} · ${escapeHtml(item.label)}`) .join('') : 'Каталог метрик пока не загружен.'}

Разрезы

${dimensions.length ? dimensions.map((item) => `${escapeHtml(item)}`).join('') : 'Разрезы появятся после загрузки coverage.'}

Фильтры

${filters.length ? filters.map((item) => `${escapeHtml(item)}`).join('') : 'Фильтры появятся после загрузки coverage.'}
Аналитика по агентам

Следующий шаг: персональные метрики по агентам, сменам и командам с детализацией по эффективности.

Прогнозы и алерты

Дальше сюда добавим прогноз нагрузки, сигналы по SLA и аномалии по очередям и каналам.

Качество диалогов

Здесь появятся оценки качества речи и диалогов на основе скоринга транскриптов и QA-правил.

${ state.analytics.queueAccessError ? `
Доступ к очередям

${escapeHtml(state.analytics.queueAccessError)}

` : '' }
`; } function csvEscape(value) { const text = String(value ?? ''); if (/[",\n]/.test(text)) { return `"${text.replaceAll('"', '""')}"`; } return text; } function exportRowsToCsv(rows) { const columns = ['section', 'key', 'label', 'current', 'previous', 'delta', 'value', 'note']; return [ columns.join(','), ...rows.map((row) => columns.map((column) => csvEscape(row[column] ?? '')).join(',')), ].join('\n'); } function downloadCsvFile(csv, filename) { const blob = new Blob([`\uFEFF${csv}`], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.append(link); link.click(); link.remove(); URL.revokeObjectURL(url); } function buildAnalyticsExportRows() { const rows = []; const rangeMeta = state.analytics.lastRangeMeta || analyticsRangeFromControls(); const current = state.analytics.overview || emptyKpiEnvelope(); const previous = state.analytics.compare || emptyKpiEnvelope(); const activeView = state.analytics.savedViews.find((item) => item.id === state.analytics.activeViewId) || null; rows.push({ section: 'filters', key: 'period', label: 'Период', value: rangeMeta.custom ? `${formatTime(rangeMeta.current.from.toISOString())} - ${formatTime(rangeMeta.current.to.toISOString())}` : analyticsPresetLabel(rangeMeta.preset), }); rows.push({ section: 'filters', key: 'channel', label: 'Канал', value: state.analytics.channel === 'all' ? 'Все каналы' : analyticsChannelLabel(state.analytics.channel), }); rows.push({ section: 'filters', key: 'queue', label: 'Очередь', value: state.analytics.queueId === 'all' ? 'Все очереди' : state.analytics.queueOptions.find((item) => item.queue_id === state.analytics.queueId)?.name || state.analytics.queueId, }); rows.push({ section: 'filters', key: 'compare_mode', label: 'Сравнение', value: analyticsCompareModeLabel(state.analytics.compareMode), }); if (activeView) { rows.push({ section: 'filters', key: 'saved_view', label: 'Сохранённый вид', value: activeView.name, }); } [ ['total', 'Обращения'], ['answered', 'Обработано'], ['SL', 'SL'], ['ASA', 'Среднее ожидание'], ['AHT', 'Среднее время'], ['Abandon', 'Потери'], ['FCR', 'FCR'], ['DigitalShare', 'Цифровая доля'], ].forEach(([metric, label]) => { const delta = state.analytics.compareMode === 'previous' ? analyticsDelta(metric, current, previous).text : ''; rows.push({ section: 'overview', key: metric, label, current: formatAnalyticsMetric(metric, metricValueFromPayload(metric, current)), previous: state.analytics.compareMode === 'previous' ? formatAnalyticsMetric(metric, metricValueFromPayload(metric, previous)) : '', delta, note: ANALYTICS_METRIC_META[metric]?.note || '', }); }); state.analytics.channelRows.forEach((item) => { const answerRate = item.total ? (item.answered / item.total) * 100 : 0; rows.push({ section: 'channels', key: item.channel, label: analyticsChannelLabel(item.channel), current: formatAnalyticsNumber(item.total, 0), previous: formatAnalyticsNumber(item.answered, 0), delta: formatAnalyticsNumber(item.abandoned, 0), value: `${formatAnalyticsNumber(answerRate, 2)}%`, note: 'current=всего, previous=обработано, delta=потери, value=доля ответа', }); }); state.analytics.queueRows.forEach((item) => { rows.push({ section: 'queues', key: item.queue_id, label: item.name || item.queue_id, current: formatAnalyticsNumber(item.total, 0), previous: formatAnalyticsNumber(item.answered, 0), delta: `${formatAnalyticsNumber(item.SL, 2)}%`, value: `${formatAnalyticsNumber(item.Abandon, 2)}%`, note: 'current=всего, previous=обработано, delta=SL, value=потери', }); }); state.analytics.trend.forEach((item) => { rows.push({ section: 'trend', key: item.label, label: analyticsMetricLabel(state.analytics.trendMetric), value: formatAnalyticsMetric(state.analytics.trendMetric, metricValueFromPayload(state.analytics.trendMetric, item.payload)), note: item.label, }); }); (state.analytics.coverage?.implemented_metrics || []).forEach((item) => { rows.push({ section: 'coverage', key: item.code, label: item.label, value: 'implemented', }); }); return rows; } function exportAnalyticsCsv() { const rows = buildAnalyticsExportRows(); const dateStamp = new Date().toISOString().slice(0, 10); downloadCsvFile(exportRowsToCsv(rows), `konturcc-analytics-${dateStamp}.csv`); state.analytics.statusMessage = `CSV выгружен в ${new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}.`; renderSavedAnalyticsViews(); } function exportDrilldownRowsToCsv(rows) { const columns = [ 'interaction_id', 'subject', 'channel', 'status', 'queue_id', 'assigned_to', 'created_at', 'updated_at', 'answered', 'abandoned', 'wait_seconds', 'handle_seconds', 'within_sla', 'resolved_first_contact', ]; return [ columns.join(','), ...rows.map((row) => columns.map((column) => csvEscape(row[column] ?? '')).join(',')), ].join('\n'); } const exportAnalyticsDrilldownCsvBase = exportAnalyticsDrilldownCsv; const renderAnalyticsDrilldownBase = renderAnalyticsDrilldown; const loadAnalyticsDrilldownDetailsBase = loadAnalyticsDrilldownDetails; const loadAnalyticsDrilldownPageBase = loadAnalyticsDrilldownPage; const openAnalyticsDrilldownBase = openAnalyticsDrilldown; const handleAnalyticsDrilldownListClickBase = handleAnalyticsDrilldownListClick; exportAnalyticsDrilldownCsv = async function exportAnalyticsDrilldownCsv() { if (!state.drilldown.open || !state.drilldown.filters || state.drilldown.exporting) { return; } if (!state.drilldown.total) { state.drilldown.notice = 'Для текущего среза нет строк для экспорта.'; renderAnalyticsDrilldown(); return; } state.drilldown.exporting = true; state.drilldown.notice = ''; state.drilldown.error = ''; renderAnalyticsDrilldown(); try { const exportLimit = Math.min(state.drilldown.total, DRILLDOWN_EXPORT_SOFT_CAP); const rows = []; for (let currentOffset = 0; currentOffset < exportLimit; currentOffset += DRILLDOWN_EXPORT_BATCH_SIZE) { const batchLimit = Math.min(DRILLDOWN_EXPORT_BATCH_SIZE, exportLimit - currentOffset); const payload = state.drilldown.mode === 'metric' ? await fetchAnalyticsMetricDrilldown(state.drilldown.filters, batchLimit, currentOffset) : await api( 'interaction', `interactions/drilldown?${analyticsDrilldownQuery(state.drilldown.filters, batchLimit, currentOffset)}`, ); const batchItems = Array.isArray(payload?.items) ? payload.items : []; rows.push(...batchItems); if (!batchItems.length || batchItems.length < batchLimit) { break; } } const dateStamp = new Date().toISOString().slice(0, 10); downloadCsvFile( exportDrilldownRowsToCsv(rows.slice(0, DRILLDOWN_EXPORT_SOFT_CAP)), `konturcc-drilldown-${analyticsDrilldownFilenameSource()}-${dateStamp}.csv`, ); state.drilldown.notice = state.drilldown.total > DRILLDOWN_EXPORT_SOFT_CAP ? `Экспортирован только первый ${DRILLDOWN_EXPORT_SOFT_CAP} строк из ${formatAnalyticsNumber(state.drilldown.total, 0)}.` : `Экспорт готов: ${formatAnalyticsNumber(rows.length, 0)} строк.`; } catch (err) { state.drilldown.error = err.message; } finally { state.drilldown.exporting = false; renderAnalyticsDrilldown(); } } 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; } function aiAnalyticsIntervalForRange(rangeMeta) { const durationMs = Math.max(0, rangeMeta.current.to.getTime() - rangeMeta.current.from.getTime()); return durationMs <= 36 * 60 * 60 * 1000 ? 'hour' : 'day'; } async function loadAiAnalyticsTrend(rangeMeta, requestId) { const metric = state.analytics.aiTrendMetric || 'containment_rate'; const interval = aiAnalyticsIntervalForRange(rangeMeta); if (!aiAnalyticsSupportedChannel()) { return emptyAiAnalyticsTimeseries( metric, interval, rangeMeta.current.from.toISOString(), rangeMeta.current.to.toISOString(), state.analytics.channel, state.analytics.queueId === 'all' ? null : state.analytics.queueId, ); } try { const payload = await fetchAiAnalyticsTimeseries(rangeMeta.current, metric, interval); if (requestId !== state.analytics.requestId) { return emptyAiAnalyticsTimeseries( metric, interval, rangeMeta.current.from.toISOString(), rangeMeta.current.to.toISOString(), state.analytics.channel, state.analytics.queueId === 'all' ? null : state.analytics.queueId, ); } return payload || emptyAiAnalyticsTimeseries( metric, interval, rangeMeta.current.from.toISOString(), rangeMeta.current.to.toISOString(), state.analytics.channel, state.analytics.queueId === 'all' ? null : state.analytics.queueId, ); } catch (err) { if (requestId === state.analytics.requestId) { state.analytics.aiError = err.message; } return emptyAiAnalyticsTimeseries( metric, interval, rangeMeta.current.from.toISOString(), rangeMeta.current.to.toISOString(), state.analytics.channel, state.analytics.queueId === 'all' ? null : state.analytics.queueId, ); } } async function loadAnalyticsQueueRows(rangeMeta, queues, requestId) { if (!queues.length) { return []; } const rows = await Promise.all( queues.map(async (queue) => { try { const payload = await fetchAnalyticsKpi(rangeMeta.current, { queueId: queue.queue_id, channel: state.analytics.channel, }); return { queue_id: queue.queue_id, name: queue.name || queue.queue_id, total: Number(payload?.volume?.total || 0), answered: Number(payload?.volume?.answered || 0), SL: Number(payload?.kpi?.SL || 0), Abandon: Number(payload?.kpi?.Abandon || 0), }; } catch { return { queue_id: queue.queue_id, name: queue.name || queue.queue_id, total: 0, answered: 0, SL: 0, Abandon: 0, }; } }), ); if (requestId !== state.analytics.requestId) { return []; } return rows.sort((a, b) => b.total - a.total || a.name.localeCompare(b.name)); } function renderAnalyticsTrendChart() { const metric = state.analytics.trendMetric || 'volume'; const items = state.analytics.trend || []; const container = $('analyticsTrendChart'); if (!items.length) { container.innerHTML = '
За выбранный период недостаточно точек для построения тренда. Попробуйте расширить окно анализа.
'; return; } const values = items.map((item) => metricValueFromPayload(metric, item.payload)); const maxValue = Math.max(...values, 1); const width = 720; const height = 220; const paddingLeft = 38; const paddingRight = 16; const paddingTop = 16; const paddingBottom = 36; const chartWidth = width - paddingLeft - paddingRight; const chartHeight = height - paddingTop - paddingBottom; const step = items.length > 1 ? chartWidth / (items.length - 1) : chartWidth; const pointFor = (value, index) => { const x = paddingLeft + index * step; const y = paddingTop + chartHeight - (value / maxValue) * chartHeight; return { x, y }; }; const polyline = values .map((value, index) => { const point = pointFor(value, index); return `${point.x},${point.y}`; }) .join(' '); const grid = [0, 0.25, 0.5, 0.75, 1] .map((ratio) => { const y = paddingTop + chartHeight - ratio * chartHeight; const labelValue = maxValue * ratio; return ` ${escapeHtml(formatAnalyticsNumber(labelValue, metric === 'volume' ? 0 : 1))} `; }) .join(''); const markers = values .map((value, index) => { const point = pointFor(value, index); return ``; }) .join(''); const labelStep = items.length > 12 ? Math.ceil(items.length / 6) : 1; const labels = items .map((item, index) => { if (index % labelStep !== 0 && index !== items.length - 1) { return ''; } const point = pointFor(values[index], index); return `${escapeHtml(item.label)}`; }) .join(''); container.innerHTML = `
${escapeHtml(analyticsMetricLabel(metric))} ${escapeHtml(formatAnalyticsMetric(metric, values[values.length - 1] || 0))} в последней точке
${grid} ${markers} ${labels} `; } function renderAnalyticsTrendChart() { const metric = state.analytics.trendMetric || 'volume'; const items = state.analytics.trend || []; const container = $('analyticsTrendChart'); if (!container) { return; } const lastValue = items.length ? metricValueFromPayload(metric, items[items.length - 1].payload) : null; renderTrendChart(container, { title: analyticsMetricLabel(metric), items, valueAccessor: (item) => metricValueFromPayload(metric, item.payload), labelAccessor: (item) => item.label, axisFormatter: (value) => formatAnalyticsAxisValue(metric, value), valueFormatter: (value) => formatAnalyticsMetric(metric, value), summaryText: lastValue === null || lastValue === undefined ? '—' : `${formatAnalyticsMetric(metric, lastValue)} в последней точке`, ariaLabel: 'Analytics trend chart', emptyText: 'За выбранный период недостаточно точек для построения тренда. Попробуйте расширить окно анализа.', }); } function renderAiAnalyticsTrendChart() { const container = $('aiAnalyticsTrendChart'); if (!container) { return; } if (!aiAnalyticsSupportedChannel()) { container.innerHTML = ''; return; } if (state.analytics.aiError && !state.analytics.aiTrend?.points?.length) { container.innerHTML = `
Не удалось обновить AI-тренд: ${escapeHtml(state.analytics.aiError)}
`; return; } const metric = state.analytics.aiTrendMetric || 'containment_rate'; const trend = state.analytics.aiTrend || emptyAiAnalyticsTimeseries(metric); const items = Array.isArray(trend.points) ? trend.points : []; const current = state.analytics.aiOverview || emptyAiAnalyticsOverview(); const previous = state.analytics.aiCompare || emptyAiAnalyticsOverview(); const delta = state.analytics.compareMode === 'previous' ? aiAnalyticsDelta(metric, current, previous) : null; const lastPoint = [...items].reverse().find((item) => item.value !== null && item.value !== undefined) || null; const emptyText = metric === 'ai_latency_avg_ms' ? 'Для выбранного окна пока нет данных по задержке ответов модели. Попробуйте изменить период или канал.' : 'За выбранный период недостаточно AI-точек для построения тренда.'; renderTrendChart(container, { title: aiAnalyticsMetricLabel(metric), items, valueAccessor: (item) => item.value, labelAccessor: (item) => formatAnalyticsBucketLabel(item.ts, trend.interval || 'day'), axisFormatter: (value) => formatAiAnalyticsAxisValue(metric, value), valueFormatter: (value) => formatAiAnalyticsMetric(metric, value), summaryText: lastPoint ? `${formatAiAnalyticsMetric(metric, lastPoint.value)} в последней точке` : '—', metaBadge: delta ? `${escapeHtml(delta.text)}` : '', ariaLabel: 'График тренда AI-аналитики', emptyText, }); } function analyticsDrilldownFiltersForCurrentSource() { const filters = analyticsDrilldownBaseFilters(); if (state.drilldown.mode === 'metric' && state.drilldown.metric) { filters.metric = state.drilldown.metric; filters.q = ''; filters.status = ''; } if (state.drilldown.sourceType === 'channel' && state.drilldown.sourceValue) { filters.channel = state.drilldown.sourceValue; } if (state.drilldown.sourceType === 'queue' && state.drilldown.sourceValue) { filters.queue_id = state.drilldown.sourceValue; } if (state.drilldown.sourceType === 'agent' && state.drilldown.sourceValue) { filters.agent_id = state.drilldown.sourceValue; } return filters; } function applyAnalyticsDrilldownLocalFilters(nextFilters, options = {}) { if (!state.drilldown.open || state.drilldown.mode !== 'interaction') { return; } state.drilldown.filters = normalizeAnalyticsDrilldownFilters(nextFilters); state.drilldown.offset = 0; state.drilldown.notice = ''; void loadAnalyticsDrilldownPage({ preserveSelection: options.preserveSelection !== false }); } function resetAnalyticsDrilldownLocalFilters() { clearAnalyticsDrilldownSearchDebounce(); if (!state.drilldown.open || state.drilldown.mode !== 'interaction') { return; } state.drilldown.filters = analyticsDrilldownFiltersForCurrentSource(); state.drilldown.offset = 0; state.drilldown.notice = ''; renderAnalyticsDrilldown(); void loadAnalyticsDrilldownPage({ preserveSelection: true }); } function handleAnalyticsDrilldownSearchInput(event) { if (!state.drilldown.open || state.drilldown.mode !== 'interaction') { return; } clearAnalyticsDrilldownSearchDebounce(); const query = event.target?.value || ''; analyticsDrilldownSearchDebounce = window.setTimeout(() => { if (!state.drilldown.open || !state.drilldown.filters) { return; } const normalizedQuery = query.trim(); if ((state.drilldown.filters.q || '') === normalizedQuery) { return; } applyAnalyticsDrilldownLocalFilters({ ...state.drilldown.filters, q: normalizedQuery, }); }, 300); } function handleAnalyticsDrilldownSearchKeydown(event) { if (event.key !== 'Enter' || !state.drilldown.open || !state.drilldown.filters || state.drilldown.mode !== 'interaction') { return; } event.preventDefault(); clearAnalyticsDrilldownSearchDebounce(); const normalizedQuery = (event.target?.value || '').trim(); if ((state.drilldown.filters.q || '') === normalizedQuery) { return; } applyAnalyticsDrilldownLocalFilters({ ...state.drilldown.filters, q: normalizedQuery, }); } function handleAnalyticsDrilldownStatusChange(event) { if (!state.drilldown.open || !state.drilldown.filters || state.drilldown.mode !== 'interaction') { return; } const nextStatus = event.target?.value === 'all' ? '' : String(event.target?.value || ''); if ((state.drilldown.filters.status || '') === nextStatus) { return; } clearAnalyticsDrilldownSearchDebounce(); applyAnalyticsDrilldownLocalFilters({ ...state.drilldown.filters, status: nextStatus, }); } function handleAnalyticsDrilldownSortChange(event) { if (!state.drilldown.open || !state.drilldown.filters || state.drilldown.mode !== 'interaction') { return; } const nextSort = parseAnalyticsDrilldownSort(event.target?.value || 'created_at:desc'); if ( (state.drilldown.filters.sort_by || 'created_at') === nextSort.sort_by && (state.drilldown.filters.sort_dir || 'desc') === nextSort.sort_dir ) { return; } clearAnalyticsDrilldownSearchDebounce(); applyAnalyticsDrilldownLocalFilters({ ...state.drilldown.filters, ...nextSort, }); } function openAnalyticsDrilldown(sourceType, sourceValue = '', options = {}) { const mode = options.mode || (sourceType === 'metric' ? 'metric' : 'interaction'); if (mode !== 'ai') { return openAnalyticsDrilldownBase(sourceType, sourceValue, options); } const filters = analyticsDrilldownBaseFilters(); filters.slice = options.slice || sourceValue || 'all'; filters.reason_key = options.reasonKey || ''; filters.status = ''; filters.q = ''; filters.sort_by = 'created_at'; filters.sort_dir = 'desc'; if (sourceType === 'ai-channel' && sourceValue) { filters.channel = sourceValue; } if (sourceType === 'ai-reason' && sourceValue) { filters.slice = 'handoff'; filters.reason_key = sourceValue; } rememberAnalyticsDrilldownReturnFocus(); state.drilldown = { ...emptyDrilldownState(), open: true, mode: 'ai', sourceType, sourceLabel: options.sourceLabel || analyticsDrilldownSourceLabel(sourceType, sourceValue), sourceValue, filters: normalizeAnalyticsDrilldownFilters(filters), coverage: options.coverage || null, metricNote: options.metricNote || aiAnalyticsDrilldownNote(filters), }; renderAnalyticsDrilldown(); window.requestAnimationFrame(() => { focusAnalyticsDrilldownPrimaryControl(true); }); void loadAnalyticsDrilldownPage(); } async function loadAnalyticsDrilldownPage(options = {}) { if (state.drilldown.mode !== 'ai') { return loadAnalyticsDrilldownPageBase(options); } if (!state.drilldown.open || !state.drilldown.filters) { return; } const preserveSelection = Boolean(options.preserveSelection); state.drilldown.loading = true; state.drilldown.error = ''; renderAnalyticsDrilldown(); try { const data = await fetchAiAnalyticsDrilldown(state.drilldown.filters, state.drilldown.limit, state.drilldown.offset); if (!state.drilldown.open) { return; } state.drilldown.items = Array.isArray(data?.items) ? data.items : []; state.drilldown.total = Number(data?.total || 0); state.drilldown.limit = Number(data?.limit || state.drilldown.limit || DRILLDOWN_PAGE_SIZE); state.drilldown.offset = Number(data?.offset || 0); state.drilldown.filters = normalizeAnalyticsDrilldownFilters(data?.filters || state.drilldown.filters); state.drilldown.coverage = data?.coverage || state.drilldown.coverage; state.drilldown.metricNote = aiAnalyticsDrilldownNote(state.drilldown.filters); const selectedId = state.drilldown.selectedInteractionId || ''; const selectedStillVisible = preserveSelection && selectedId && state.drilldown.items.some((item) => analyticsDrilldownCurrentItemId(item) === selectedId); if (selectedStillVisible) { state.drilldown.selectedInteraction = state.drilldown.items.find( (item) => analyticsDrilldownCurrentItemId(item) === selectedId, ) || state.drilldown.selectedInteraction; void loadAnalyticsDrilldownDetails(selectedId); } else if (state.drilldown.items.length) { state.drilldown.selectedInteractionId = analyticsDrilldownCurrentItemId(state.drilldown.items[0]); state.drilldown.selectedInteraction = state.drilldown.items[0]; state.drilldown.selectedLinkedInteraction = null; state.drilldown.selectedTimeline = []; void loadAnalyticsDrilldownDetails(state.drilldown.selectedInteractionId); } else { state.drilldown.selectedInteractionId = ''; state.drilldown.selectedInteraction = null; state.drilldown.selectedLinkedInteraction = null; state.drilldown.selectedTimeline = []; state.drilldown.detailLoading = false; } } catch (err) { if (!state.drilldown.open) { return; } state.drilldown.items = []; state.drilldown.total = 0; state.drilldown.selectedInteractionId = ''; state.drilldown.selectedInteraction = null; state.drilldown.selectedLinkedInteraction = null; state.drilldown.selectedTimeline = []; state.drilldown.detailLoading = false; state.drilldown.error = err.message; } finally { if (state.drilldown.open) { state.drilldown.loading = false; renderAnalyticsDrilldown(); } } } async function loadAnalyticsDrilldownDetails(interactionId) { if (state.drilldown.mode !== 'ai') { return loadAnalyticsDrilldownDetailsBase(interactionId); } if (!state.drilldown.open || !interactionId) { return; } state.drilldown.selectedInteractionId = interactionId; state.drilldown.selectedInteraction = state.drilldown.items.find((item) => item.session_id === interactionId) || null; state.drilldown.selectedLinkedInteraction = null; state.drilldown.selectedTimeline = []; state.drilldown.error = ''; state.drilldown.detailLoading = true; renderAnalyticsDrilldown(); try { const detail = await fetchAiAnalyticsSessionDetail(interactionId); if (!state.drilldown.open || state.drilldown.selectedInteractionId !== interactionId) { return; } state.drilldown.selectedInteraction = { ...(state.drilldown.selectedInteraction || {}), ...(detail?.session || {}), }; state.drilldown.selectedLinkedInteraction = detail?.interaction || null; state.drilldown.selectedTimeline = Array.isArray(detail?.timeline) ? detail.timeline : []; } catch (err) { if (!state.drilldown.open || state.drilldown.selectedInteractionId !== interactionId) { return; } state.drilldown.error = err.message; } finally { if (state.drilldown.open && state.drilldown.selectedInteractionId === interactionId) { state.drilldown.detailLoading = false; renderAnalyticsDrilldown(); } } } function handleAnalyticsDrilldownListClick(event) { if (state.drilldown.mode !== 'ai') { return handleAnalyticsDrilldownListClickBase(event); } const button = event.target.closest('[data-analytics-row-id]'); if (!button) { return; } maybeScrollAnalyticsDrilldownDetailIntoView(); void loadAnalyticsDrilldownDetails(button.dataset.analyticsRowId || ''); } function handleAiAnalyticsOverviewClick(event) { const button = event.target.closest('[data-ai-drilldown-slice]'); if (!button) { return; } const slice = button.dataset.aiDrilldownSlice || 'all'; openAnalyticsDrilldown('ai-overview', slice, { mode: 'ai', slice, sourceLabel: button.dataset.aiDrilldownLabel || aiAnalyticsSliceLabel(slice), }); } function handleAiAnalyticsChannelClick(event) { const button = event.target.closest('[data-ai-channel]'); if (!button) { return; } const channel = button.dataset.aiChannel || ''; openAnalyticsDrilldown('ai-channel', channel, { mode: 'ai', slice: 'all', sourceLabel: `AI-канал: ${analyticsChannelLabel(channel)}`, }); } function handleAiAnalyticsOutcomeClick(event) { const button = event.target.closest('[data-ai-outcome]'); if (!button) { return; } const slice = button.dataset.aiOutcome || 'all'; openAnalyticsDrilldown('ai-overview', slice, { mode: 'ai', slice, sourceLabel: button.dataset.aiOutcomeLabel || aiAnalyticsSliceLabel(slice), }); } function handleAiAnalyticsReasonClick(event) { const button = event.target.closest('[data-ai-reason-key]'); if (!button) { return; } const reasonKey = button.dataset.aiReasonKey || ''; openAnalyticsDrilldown('ai-reason', reasonKey, { mode: 'ai', slice: 'handoff', reasonKey, sourceLabel: button.dataset.aiReasonLabel || `Причина передачи: ${reasonKey}`, }); } function renderAiAnalyticsDrilldownFactBadges(item) { if (!item || typeof item !== 'object') { return ''; } const badges = []; if (item.contained) { badges.push({ tone: 'sla', label: 'Закрыто AI' }); } if (item.handoff) { badges.push({ tone: 'abandoned', label: 'Передано оператору' }); } if (item.human_touched) { badges.push({ tone: 'handle', label: 'С участием оператора' }); } if (item.closed_without_operator) { badges.push({ tone: 'fcr', label: 'Закрыто без оператора' }); } if (item.ai_latency_avg_ms !== null && item.ai_latency_avg_ms !== undefined) { badges.push({ tone: 'wait', label: `Задержка ${formatAnalyticsNumber(item.ai_latency_avg_ms, 0)} мс` }); } if (item.reason_label) { badges.push({ tone: 'handle', label: item.reason_label }); } if (!badges.length) { return ''; } return `
${badges .map( (badge) => `${escapeHtml(badge.label)}`, ) .join('')}
`; } renderAnalyticsDrilldown = function renderAnalyticsDrilldown() { if (state.drilldown.mode !== 'ai') { return renderAnalyticsDrilldownBase(); } const drawer = $('analyticsDrilldownDrawer'); const backdrop = $('analyticsDrilldownBackdrop'); const closeBtn = $('analyticsDrilldownCloseBtn'); const title = $('analyticsDrilldownTitle'); const meta = $('analyticsDrilldownMeta'); const filters = $('analyticsDrilldownFilters'); const list = $('analyticsDrilldownList'); const detail = $('analyticsDrilldownDetail'); const prevBtn = $('analyticsDrilldownPrevBtn'); const nextBtn = $('analyticsDrilldownNextBtn'); const pageMeta = $('analyticsDrilldownPageMeta'); const listHeading = drawer?.querySelector('.analytics-drawer-list-shell .analytics-drawer-list-head strong'); const detailHeading = drawer?.querySelector('.analytics-drawer-detail-shell .analytics-drawer-list-head strong'); const detailHint = drawer?.querySelector('.analytics-drawer-detail-shell .analytics-drawer-list-head .hint'); if (!drawer || !backdrop || !closeBtn || !title || !meta || !filters || !list || !detail || !prevBtn || !nextBtn || !pageMeta) { return; } const isOpen = state.drilldown.open; document.body.classList.toggle('analytics-drilldown-open', isOpen); drawer.classList.toggle('is-open', isOpen); backdrop.classList.toggle('is-open', isOpen); drawer.setAttribute('aria-hidden', isOpen ? 'false' : 'true'); backdrop.setAttribute('aria-hidden', isOpen ? 'false' : 'true'); closeBtn.classList.add('analytics-drawer-close'); closeBtn.setAttribute('aria-label', 'Закрыть детализацию'); closeBtn.textContent = 'Закрыть · Esc'; drawer.hidden = !isOpen; backdrop.hidden = !isOpen; if (!isOpen) { title.textContent = 'Детализация AI-сессий'; meta.textContent = 'Выберите карточку, исход, причину передачи или канал, чтобы открыть срез по AI-сессиям.'; filters.innerHTML = ''; list.innerHTML = ''; detail.innerHTML = ''; pageMeta.textContent = '0 из 0'; prevBtn.disabled = true; nextBtn.disabled = true; if (listHeading) { listHeading.textContent = 'Список AI-сессий'; } if (detailHeading) { detailHeading.textContent = 'Детали AI-сессии'; } if (detailHint) { detailHint.textContent = 'Только метаданные: без текста сообщений, AI-сводок, записей и транскриптов.'; } syncAnalyticsDrilldownControls(); return; } const chips = analyticsDrilldownChips(); const currentPageStart = state.drilldown.total ? state.drilldown.offset + 1 : 0; const currentPageEnd = state.drilldown.total ? Math.min(state.drilldown.offset + state.drilldown.items.length, state.drilldown.total) : 0; title.textContent = state.drilldown.sourceLabel || 'Детализация AI-сессий'; if (listHeading) { listHeading.textContent = 'Список AI-сессий'; } if (detailHeading) { detailHeading.textContent = 'Детали AI-сессии'; } if (detailHint) { detailHint.textContent = 'Только метаданные: без текста сообщений, AI-сводок, записей и транскриптов.'; } meta.textContent = state.drilldown.error ? `Не удалось обновить детализацию AI: ${state.drilldown.error}` : state.drilldown.exporting ? 'Готовим CSV по текущему AI-срезу.' : state.drilldown.loading ? 'Загружаем AI-сессии и ленту метаданных...' : `${formatAnalyticsNumber(state.drilldown.total, 0)} AI-сессий в выбранном срезе.`; filters.innerHTML = [ ...chips.map((chip) => `${escapeHtml(chip)}`), state.drilldown.notice ? `${escapeHtml(state.drilldown.notice)}` : '', ].join(''); pageMeta.textContent = `${currentPageStart}-${currentPageEnd} из ${formatAnalyticsNumber(state.drilldown.total, 0)}`; prevBtn.disabled = state.drilldown.loading || state.drilldown.exporting || state.drilldown.offset <= 0; nextBtn.disabled = state.drilldown.loading || state.drilldown.exporting || state.drilldown.offset + state.drilldown.limit >= state.drilldown.total; syncAnalyticsDrilldownControls(); if (state.drilldown.loading && !state.drilldown.items.length) { list.innerHTML = '
Загружаем AI-сессии...
'; } else if (!state.drilldown.items.length) { list.innerHTML = '
По этому AI-срезу сессий пока нет.
'; } else { list.innerHTML = state.drilldown.items .map((item) => ` `) .join(''); } const selectedSession = state.drilldown.selectedInteraction || state.drilldown.items.find((item) => item.session_id === state.drilldown.selectedInteractionId) || null; if (!selectedSession) { detail.innerHTML = '
Выберите AI-сессию в списке, чтобы увидеть детали на уровне метаданных.
'; return; } const timelineMarkup = state.drilldown.detailLoading ? '
Загружаем ленту метаданных...
' : state.drilldown.selectedTimeline.length ? state.drilldown.selectedTimeline .map((event) => { const safeMetadata = sanitizeAnalyticsTimelineMetadata(event.metadata); const metadataMarkup = Object.keys(safeMetadata).length ? `
${escapeHtml(JSON.stringify(safeMetadata, null, 2))}
` : '

Только событие уровня метаданных без текста сообщений.

'; return `
${escapeHtml(event.label || event.event_type || 'событие')} ${escapeHtml(formatTime(event.ts))}
${metadataMarkup}
`; }) .join('') : '
Metadata timeline по этой AI session пока пуст.
'; const linkedInteractionMarkup = state.drilldown.selectedLinkedInteraction ? `
Связанное обращение

${escapeHtml(state.drilldown.selectedLinkedInteraction.interaction_id || '—')}

Канал${escapeHtml(analyticsChannelLabel(state.drilldown.selectedLinkedInteraction.channel))}
Очередь${escapeHtml(analyticsQueueName(state.drilldown.selectedLinkedInteraction.queue_id))}
Статус${escapeHtml(interactionStatusLabel(state.drilldown.selectedLinkedInteraction.status))}
Назначен${escapeHtml(state.drilldown.selectedLinkedInteraction.assigned_to || 'Не назначен')}
Тема${escapeHtml(state.drilldown.selectedLinkedInteraction.subject || '—')}
Обновлено${escapeHtml(formatTime(state.drilldown.selectedLinkedInteraction.updated_at))}
` : ''; detail.innerHTML = `
${escapeHtml(selectedSession.session_id)}

${escapeHtml(aiAnalyticsSliceLabel(state.drilldown.filters?.slice || 'all'))}

${escapeHtml(aiAnalyticsStatusLabel(selectedSession.status))}

${escapeHtml(aiAnalyticsDrilldownNote(state.drilldown.filters))}

${renderAiAnalyticsDrilldownFactBadges(selectedSession)}
Канал${escapeHtml(analyticsChannelLabel(selectedSession.channel))}
Очередь${escapeHtml(analyticsQueueName(selectedSession.queue_id))}
ID обращения${escapeHtml(selectedSession.interaction_id || '—')}
ID диалога${escapeHtml(selectedSession.thread_id || '—')}
Назначено${escapeHtml(selectedSession.assigned_to || 'Не назначен')}
Взял оператор${escapeHtml(selectedSession.claimed_by_user || '—')}
Ответов AI${escapeHtml(String(selectedSession.assistant_turns || 0))}
Сообщений клиента${escapeHtml(String(selectedSession.user_turns || 0))}
Вызовов инструментов${escapeHtml(String(selectedSession.tool_turns || 0))}
Средняя задержка${escapeHtml(formatAiAnalyticsMetric('ai_latency_avg_ms', selectedSession.ai_latency_avg_ms))}
P95 задержки${escapeHtml(formatAiAnalyticsMetric('ai_latency_avg_ms', selectedSession.ai_latency_p95_ms))}
Причина${escapeHtml(selectedSession.reason_label || selectedSession.raw_handoff_reason || '—')}
Исходная причина${escapeHtml(selectedSession.raw_handoff_reason || '—')}
Создано${escapeHtml(formatTime(selectedSession.created_at))}
Обновлено${escapeHtml(formatTime(selectedSession.updated_at))}
Закрыто${escapeHtml(formatTime(selectedSession.closed_at))}
${linkedInteractionMarkup}
${timelineMarkup}
`; } async function exportAnalyticsDrilldownCsv() { if (state.drilldown.mode !== 'ai') { return exportAnalyticsDrilldownCsvBase(); } if (!state.drilldown.open || !state.drilldown.filters || !state.drilldown.total || state.drilldown.exporting) { return; } state.drilldown.exporting = true; state.drilldown.notice = ''; renderAnalyticsDrilldown(); try { const lines = [[ 'session_id', 'interaction_id', 'thread_id', 'channel', 'queue_id', 'status', 'reason_key', 'reason_label', 'raw_handoff_reason', 'assigned_to', 'claimed_by_user', 'assistant_turns', 'user_turns', 'tool_turns', 'ai_latency_avg_ms', 'ai_latency_p95_ms', 'created_at', 'updated_at', 'closed_at', ].join(',')]; const maxRows = Math.min(state.drilldown.total, DRILLDOWN_EXPORT_SOFT_CAP); let currentOffset = 0; while (currentOffset < maxRows) { const batchLimit = Math.min(DRILLDOWN_EXPORT_BATCH_SIZE, maxRows - currentOffset); const payload = await fetchAiAnalyticsDrilldown(state.drilldown.filters, batchLimit, currentOffset); const items = Array.isArray(payload?.items) ? payload.items : []; items.forEach((item) => { lines.push([ item.session_id, item.interaction_id, item.thread_id, item.channel, item.queue_id, item.status, item.reason_key, item.reason_label, item.raw_handoff_reason, item.assigned_to, item.claimed_by_user, item.assistant_turns, item.user_turns, item.tool_turns, item.ai_latency_avg_ms, item.ai_latency_p95_ms, item.created_at, item.updated_at, item.closed_at, ].map(csvCell).join(',')); }); if (!items.length) { break; } currentOffset += batchLimit; } const dateStamp = new Date().toISOString().slice(0, 10); downloadCsv( lines.join('\n'), `konturcc-drilldown-${analyticsDrilldownFilenameSource()}-${dateStamp}.csv`, ); state.drilldown.notice = state.drilldown.total > DRILLDOWN_EXPORT_SOFT_CAP ? `Экспортированы первые ${formatAnalyticsNumber(DRILLDOWN_EXPORT_SOFT_CAP, 0)} строк.` : 'CSV готов.'; } catch (err) { state.drilldown.error = err.message; } finally { state.drilldown.exporting = false; renderAnalyticsDrilldown(); } } function renderAiAnalyticsDrilldownActive() { const drawer = $('analyticsDrilldownDrawer'); const backdrop = $('analyticsDrilldownBackdrop'); const closeBtn = $('analyticsDrilldownCloseBtn'); const title = $('analyticsDrilldownTitle'); const meta = $('analyticsDrilldownMeta'); const filters = $('analyticsDrilldownFilters'); const list = $('analyticsDrilldownList'); const detail = $('analyticsDrilldownDetail'); const prevBtn = $('analyticsDrilldownPrevBtn'); const nextBtn = $('analyticsDrilldownNextBtn'); const pageMeta = $('analyticsDrilldownPageMeta'); const listHeading = drawer?.querySelector('.analytics-drawer-list-shell .analytics-drawer-list-head strong'); const detailHeading = drawer?.querySelector('.analytics-drawer-detail-shell .analytics-drawer-list-head strong'); const detailHint = drawer?.querySelector('.analytics-drawer-detail-shell .analytics-drawer-list-head .hint'); if (!drawer || !backdrop || !closeBtn || !title || !meta || !filters || !list || !detail || !prevBtn || !nextBtn || !pageMeta) { return; } const isOpen = state.drilldown.open; document.body.classList.toggle('analytics-drilldown-open', isOpen); drawer.classList.toggle('is-open', isOpen); backdrop.classList.toggle('is-open', isOpen); drawer.setAttribute('aria-hidden', isOpen ? 'false' : 'true'); backdrop.setAttribute('aria-hidden', isOpen ? 'false' : 'true'); closeBtn.classList.add('analytics-drawer-close'); closeBtn.setAttribute('aria-label', 'Закрыть детализацию'); closeBtn.textContent = 'Закрыть · Esc'; drawer.hidden = !isOpen; backdrop.hidden = !isOpen; if (!isOpen) { title.textContent = 'Детализация AI-сессий'; meta.textContent = 'Выберите карточку, исход, причину передачи или канал, чтобы открыть срез по AI-сессиям.'; filters.innerHTML = ''; list.innerHTML = ''; detail.innerHTML = ''; pageMeta.textContent = '0 из 0'; prevBtn.disabled = true; nextBtn.disabled = true; if (listHeading) { listHeading.textContent = 'Список AI-сессий'; } if (detailHeading) { detailHeading.textContent = 'Детали AI-сессии'; } if (detailHint) { detailHint.textContent = 'Только метаданные: без текста сообщений, AI-сводок, записей и транскриптов.'; } syncAnalyticsDrilldownControls(); return; } const chips = analyticsDrilldownChips(); const currentPageStart = state.drilldown.total ? state.drilldown.offset + 1 : 0; const currentPageEnd = state.drilldown.total ? Math.min(state.drilldown.offset + state.drilldown.items.length, state.drilldown.total) : 0; title.textContent = state.drilldown.sourceLabel || 'Детализация AI-сессий'; if (listHeading) { listHeading.textContent = 'Список AI-сессий'; } if (detailHeading) { detailHeading.textContent = 'Детали AI-сессии'; } if (detailHint) { detailHint.textContent = 'Только метаданные: без текста сообщений, AI-сводок, записей и транскриптов.'; } meta.textContent = state.drilldown.error ? `Не удалось обновить детализацию AI: ${state.drilldown.error}` : state.drilldown.exporting ? 'Готовим CSV по текущему AI-срезу.' : state.drilldown.loading ? 'Загружаем AI-сессии и ленту метаданных...' : `${formatAnalyticsNumber(state.drilldown.total, 0)} AI-сессий в выбранном срезе.`; filters.innerHTML = [ ...chips.map((chip) => `${escapeHtml(chip)}`), state.drilldown.notice ? `${escapeHtml(state.drilldown.notice)}` : '', ].join(''); pageMeta.textContent = `${currentPageStart}-${currentPageEnd} из ${formatAnalyticsNumber(state.drilldown.total, 0)}`; prevBtn.disabled = state.drilldown.loading || state.drilldown.exporting || state.drilldown.offset <= 0; nextBtn.disabled = state.drilldown.loading || state.drilldown.exporting || state.drilldown.offset + state.drilldown.limit >= state.drilldown.total; syncAnalyticsDrilldownControls(); if (state.drilldown.loading && !state.drilldown.items.length) { list.innerHTML = '
Загружаем AI-сессии...
'; } else if (!state.drilldown.items.length) { list.innerHTML = '
По этому AI-срезу сессий пока нет.
'; } else { list.innerHTML = state.drilldown.items .map((item) => ` `) .join(''); } const selectedSession = state.drilldown.selectedInteraction || state.drilldown.items.find((item) => item.session_id === state.drilldown.selectedInteractionId) || null; if (!selectedSession) { detail.innerHTML = '
Выберите AI-сессию в списке, чтобы увидеть детали на уровне метаданных.
'; return; } const timelineMarkup = state.drilldown.detailLoading ? '
Загружаем ленту метаданных...
' : state.drilldown.selectedTimeline.length ? state.drilldown.selectedTimeline .map((event) => { const safeMetadata = sanitizeAnalyticsTimelineMetadata(event.metadata); const metadataMarkup = Object.keys(safeMetadata).length ? `
${escapeHtml(JSON.stringify(safeMetadata, null, 2))}
` : '

Только событие уровня метаданных без текста сообщений.

'; return `
${escapeHtml(event.label || event.event_type || 'событие')} ${escapeHtml(formatTime(event.ts))}
${metadataMarkup}
`; }) .join('') : '
Лента метаданных по этой AI-сессии пока пуста.
'; const linkedInteractionMarkup = state.drilldown.selectedLinkedInteraction ? `
Связанное обращение

${escapeHtml(state.drilldown.selectedLinkedInteraction.interaction_id || '—')}

Канал${escapeHtml(analyticsChannelLabel(state.drilldown.selectedLinkedInteraction.channel))}
Очередь${escapeHtml(analyticsQueueName(state.drilldown.selectedLinkedInteraction.queue_id))}
Статус${escapeHtml(interactionStatusLabel(state.drilldown.selectedLinkedInteraction.status))}
Назначен${escapeHtml(state.drilldown.selectedLinkedInteraction.assigned_to || 'Не назначен')}
Тема${escapeHtml(state.drilldown.selectedLinkedInteraction.subject || '—')}
Обновлено${escapeHtml(formatTime(state.drilldown.selectedLinkedInteraction.updated_at))}
` : ''; detail.innerHTML = `
${escapeHtml(selectedSession.session_id)}

${escapeHtml(aiAnalyticsSliceLabel(state.drilldown.filters?.slice || 'all'))}

${escapeHtml(aiAnalyticsStatusLabel(selectedSession.status))}

${escapeHtml(aiAnalyticsDrilldownNote(state.drilldown.filters))}

${renderAiAnalyticsDrilldownFactBadges(selectedSession)}
Канал${escapeHtml(analyticsChannelLabel(selectedSession.channel))}
Очередь${escapeHtml(analyticsQueueName(selectedSession.queue_id))}
ID обращения${escapeHtml(selectedSession.interaction_id || '—')}
ID диалога${escapeHtml(selectedSession.thread_id || '—')}
Назначено${escapeHtml(selectedSession.assigned_to || 'Не назначен')}
Взял оператор${escapeHtml(selectedSession.claimed_by_user || '—')}
Ответов AI${escapeHtml(String(selectedSession.assistant_turns || 0))}
Сообщений клиента${escapeHtml(String(selectedSession.user_turns || 0))}
Вызовов инструментов${escapeHtml(String(selectedSession.tool_turns || 0))}
Средняя задержка${escapeHtml(formatAiAnalyticsMetric('ai_latency_avg_ms', selectedSession.ai_latency_avg_ms))}
P95 задержки${escapeHtml(formatAiAnalyticsMetric('ai_latency_avg_ms', selectedSession.ai_latency_p95_ms))}
Причина${escapeHtml(selectedSession.reason_label || selectedSession.raw_handoff_reason || '—')}
Исходная причина${escapeHtml(selectedSession.raw_handoff_reason || '—')}
Создано${escapeHtml(formatTime(selectedSession.created_at))}
Обновлено${escapeHtml(formatTime(selectedSession.updated_at))}
Закрыто${escapeHtml(formatTime(selectedSession.closed_at))}
${linkedInteractionMarkup}
${timelineMarkup}
`; } function renderAnalyticsDrilldown() { if (state.drilldown.mode === 'ai') { renderAiAnalyticsDrilldownActive(); return; } const drawer = $('analyticsDrilldownDrawer'); const backdrop = $('analyticsDrilldownBackdrop'); const closeBtn = $('analyticsDrilldownCloseBtn'); const title = $('analyticsDrilldownTitle'); const meta = $('analyticsDrilldownMeta'); const filters = $('analyticsDrilldownFilters'); const list = $('analyticsDrilldownList'); const detail = $('analyticsDrilldownDetail'); const prevBtn = $('analyticsDrilldownPrevBtn'); const nextBtn = $('analyticsDrilldownNextBtn'); const pageMeta = $('analyticsDrilldownPageMeta'); const listHeading = drawer?.querySelector('.analytics-drawer-list-shell .analytics-drawer-list-head strong'); const detailHeading = drawer?.querySelector('.analytics-drawer-detail-shell .analytics-drawer-list-head strong'); const detailHint = drawer?.querySelector('.analytics-drawer-detail-shell .analytics-drawer-list-head .hint'); if (!drawer || !backdrop || !closeBtn || !title || !meta || !filters || !list || !detail || !prevBtn || !nextBtn || !pageMeta) { return; } const isOpen = state.drilldown.open; document.body.classList.toggle('analytics-drilldown-open', isOpen); drawer.classList.toggle('is-open', isOpen); backdrop.classList.toggle('is-open', isOpen); drawer.setAttribute('aria-hidden', isOpen ? 'false' : 'true'); backdrop.setAttribute('aria-hidden', isOpen ? 'false' : 'true'); closeBtn.classList.add('analytics-drawer-close'); closeBtn.setAttribute('aria-label', 'Закрыть детализацию'); closeBtn.textContent = 'Закрыть · Esc'; drawer.hidden = !isOpen; backdrop.hidden = !isOpen; if (!isOpen) { title.textContent = 'Детализация обращений'; meta.textContent = 'Выберите карточку обращений, канал или очередь на витрине, чтобы открыть список обращений.'; filters.innerHTML = ''; list.innerHTML = ''; detail.innerHTML = ''; pageMeta.textContent = '0 из 0'; prevBtn.disabled = true; nextBtn.disabled = true; title.textContent = 'Точная детализация'; meta.textContent = 'Выберите KPI-карточку, канал или очередь на витрине, чтобы открыть точный срез по обращениям.'; pageMeta.textContent = '0 из 0'; if (listHeading) { listHeading.textContent = 'Список обращений'; } if (detailHeading) { detailHeading.textContent = 'Карточка обращения'; } if (detailHint) { detailHint.textContent = 'Лента событий и базовые поля без записей, транскриптов и канального контента.'; } syncAnalyticsDrilldownControls(); return; } const chips = analyticsDrilldownChips(); const currentPageStart = state.drilldown.total ? state.drilldown.offset + 1 : 0; const currentPageEnd = state.drilldown.total ? Math.min(state.drilldown.offset + state.drilldown.items.length, state.drilldown.total) : 0; title.textContent = state.drilldown.sourceLabel || 'Точная детализация'; if (listHeading) { listHeading.textContent = state.drilldown.mode === 'metric' ? 'Точный KPI-срез' : 'Список обращений'; } if (detailHeading) { detailHeading.textContent = state.drilldown.mode === 'metric' ? 'Карточка и KPI-факты' : 'Карточка обращения'; } if (detailHint) { detailHint.textContent = state.drilldown.mode === 'metric' ? 'KPI-факты, лента событий и базовые поля без исходных сообщений, записей, транскриптов и AI-сводок.' : 'Лента событий и базовые поля без записей, транскриптов и канального контента.'; } title.textContent = state.drilldown.sourceLabel || 'Детализация обращений'; meta.textContent = state.drilldown.error ? `Не удалось полностью обновить детализацию: ${state.drilldown.error}` : state.drilldown.exporting ? 'Готовим CSV по текущему срезу. Детализация и фильтры остаются доступны только на уровне метаданных.' : state.drilldown.loading ? 'Загружаем список обращений и ленту событий...' : `${formatAnalyticsNumber(state.drilldown.total, 0)} обращений в выбранном срезе.`; meta.textContent = state.drilldown.error ? `Не удалось обновить детализацию: ${state.drilldown.error}` : state.drilldown.exporting ? 'Готовим CSV по текущему срезу. Детализация остаётся только на уровне метаданных.' : state.drilldown.loading ? state.drilldown.mode === 'metric' ? 'Загружаем точный KPI-срез и ленту событий...' : 'Загружаем список обращений и ленту событий...' : state.drilldown.mode === 'metric' ? `${formatAnalyticsNumber(state.drilldown.total, 0)} обращений в точном срезе по метрике. ${state.drilldown.metricNote || ''}`.trim() : `${formatAnalyticsNumber(state.drilldown.total, 0)} обращений в выбранном срезе.`; title.textContent = state.drilldown.sourceLabel || 'Точная детализация'; filters.innerHTML = [ ...chips.map((chip) => `${escapeHtml(chip)}`), state.drilldown.notice ? `${escapeHtml(state.drilldown.notice)}` : '', ].join(''); pageMeta.textContent = `${currentPageStart}-${currentPageEnd} из ${formatAnalyticsNumber(state.drilldown.total, 0)}`; prevBtn.disabled = state.drilldown.loading || state.drilldown.exporting || state.drilldown.offset <= 0; nextBtn.disabled = state.drilldown.loading || state.drilldown.exporting || state.drilldown.offset + state.drilldown.limit >= state.drilldown.total; syncAnalyticsDrilldownControls(); if (state.drilldown.loading && !state.drilldown.items.length) { list.innerHTML = '
Загружаем список обращений...
'; } else if (!state.drilldown.items.length) { list.innerHTML = '
По этому срезу обращений пока нет.
'; } else { list.innerHTML = state.drilldown.items .map((item) => ` `) .join(''); } const selectedInteraction = state.drilldown.selectedInteraction || state.drilldown.items.find((item) => item.interaction_id === state.drilldown.selectedInteractionId) || null; if (!selectedInteraction) { detail.innerHTML = '
Выберите обращение в списке, чтобы увидеть карточку и timeline.
'; return; } const timelineMarkup = state.drilldown.detailLoading ? '
Загружаем ленту событий...
' : state.drilldown.selectedTimeline.length ? state.drilldown.selectedTimeline .map((event) => ` ${(() => { const safeMetadata = sanitizeAnalyticsTimelineMetadata(event.metadata); const metadataMarkup = Object.keys(safeMetadata).length ? `
${escapeHtml(JSON.stringify(safeMetadata, null, 2))}
` : '

Только событие уровня метаданных: исходный текст, записи и транскрипты скрыты в analyst-экране.

'; return `
${escapeHtml(event.action || 'событие')} ${escapeHtml(formatTime(event.timestamp))}
${metadataMarkup}
`; })()} `) .join('') : '
Лента событий по этому обращению пока пуста.
'; const metricContextMarkup = state.drilldown.mode === 'metric' && state.drilldown.metric ? `

${escapeHtml(state.drilldown.metricNote || '')}

` : ''; const factBadgesMarkup = renderAnalyticsDrilldownFactBadges(selectedInteraction); const factFields = [ selectedInteraction.wait_seconds !== null && selectedInteraction.wait_seconds !== undefined ? `
Ожидание${escapeHtml(`${formatAnalyticsNumber(selectedInteraction.wait_seconds, 0)} с`)}
` : '', selectedInteraction.handle_seconds !== null && selectedInteraction.handle_seconds !== undefined ? `
Обработка${escapeHtml(`${formatAnalyticsNumber(selectedInteraction.handle_seconds, 0)} с`)}
` : '', selectedInteraction.within_sla !== null && selectedInteraction.within_sla !== undefined ? `
SLA${escapeHtml(selectedInteraction.within_sla ? 'В SLA' : 'Вне SLA')}
` : '', selectedInteraction.resolved_first_contact !== null && selectedInteraction.resolved_first_contact !== undefined ? `
FCR${escapeHtml(selectedInteraction.resolved_first_contact ? 'Да' : 'Нет')}
` : '', selectedInteraction.abandoned !== null && selectedInteraction.abandoned !== undefined ? `
Потеря${escapeHtml(selectedInteraction.abandoned ? 'Да' : 'Нет')}
` : '', ].filter(Boolean).join(''); detail.innerHTML = `
${escapeHtml(selectedInteraction.subject || selectedInteraction.interaction_id)}

${escapeHtml(selectedInteraction.interaction_id)}

${state.drilldown.mode === 'metric' && state.drilldown.metric ? analyticsMetricCoverageBadge(state.drilldown.metric) : ''} ${escapeHtml(interactionStatusLabel(selectedInteraction.status))}
${metricContextMarkup} ${factBadgesMarkup}
Канал${escapeHtml(analyticsChannelLabel(selectedInteraction.channel))}
Очередь${escapeHtml(analyticsQueueName(selectedInteraction.queue_id))}
Назначен${escapeHtml(selectedInteraction.assigned_to || 'Не назначен')}
Создано${escapeHtml(formatTime(selectedInteraction.created_at))}
Обновлено${escapeHtml(formatTime(selectedInteraction.updated_at))}
Срез${escapeHtml(state.drilldown.sourceLabel || 'Все обращения')}
${factFields}
${timelineMarkup}
`; } async function loadAiAnalyticsDrilldownDetailsState(sessionId) { if (!state.drilldown.open || !sessionId) { return; } state.drilldown.selectedInteractionId = sessionId; state.drilldown.selectedInteraction = state.drilldown.items.find((item) => item.session_id === sessionId) || null; state.drilldown.selectedLinkedInteraction = null; state.drilldown.selectedTimeline = []; state.drilldown.error = ''; state.drilldown.detailLoading = true; renderAnalyticsDrilldown(); syncAnalyticsUrlState(); if (analyticsMockEnabled()) { await ensureAnalyticsMockDataLoaded(); const detail = buildMockAiDetail(sessionId); state.drilldown.selectedInteraction = { ...(state.drilldown.selectedInteraction || {}), ...(detail?.session || {}), }; state.drilldown.selectedLinkedInteraction = detail?.interaction || null; state.drilldown.selectedTimeline = Array.isArray(detail?.timeline) ? detail.timeline : []; state.drilldown.detailLoading = false; renderAnalyticsDrilldown(); syncAnalyticsUrlState(); return; } try { const detail = await fetchAiAnalyticsSessionDetail(sessionId); if (!state.drilldown.open || state.drilldown.selectedInteractionId !== sessionId) { return; } state.drilldown.selectedInteraction = { ...(state.drilldown.selectedInteraction || {}), ...(detail?.session || {}), }; state.drilldown.selectedLinkedInteraction = detail?.interaction || null; state.drilldown.selectedTimeline = Array.isArray(detail?.timeline) ? detail.timeline : []; } catch (err) { if (!state.drilldown.open || state.drilldown.selectedInteractionId !== sessionId) { return; } state.drilldown.error = err.message; } finally { if (state.drilldown.open && state.drilldown.selectedInteractionId === sessionId) { state.drilldown.detailLoading = false; renderAnalyticsDrilldown(); syncAnalyticsUrlState(); } } } loadAnalyticsDrilldownDetails = async function loadAnalyticsDrilldownDetails(interactionId) { if (state.drilldown.mode === 'ai') { return loadAiAnalyticsDrilldownDetailsState(interactionId); } if (!state.drilldown.open || !interactionId) { return; } state.drilldown.selectedInteractionId = interactionId; state.drilldown.selectedInteraction = state.drilldown.items.find((item) => item.interaction_id === interactionId) || null; state.drilldown.selectedTimeline = []; state.drilldown.error = ''; state.drilldown.detailLoading = true; renderAnalyticsDrilldown(); syncAnalyticsUrlState(); if (analyticsMockEnabled()) { await ensureAnalyticsMockDataLoaded(); const detail = buildMockInteractionDetail(interactionId); state.drilldown.selectedInteraction = { ...(state.drilldown.selectedInteraction || {}), ...(detail?.detail || {}), }; state.drilldown.selectedTimeline = Array.isArray(detail?.timeline?.events) ? detail.timeline.events : []; state.drilldown.detailLoading = false; renderAnalyticsDrilldown(); syncAnalyticsUrlState(); return; } try { const [detail, timeline] = await Promise.all([ api('interaction', `interactions/${encodeURIComponent(interactionId)}`), api('interaction', `interactions/${encodeURIComponent(interactionId)}/timeline`), ]); if (!state.drilldown.open || state.drilldown.selectedInteractionId !== interactionId) { return; } state.drilldown.selectedInteraction = { ...(state.drilldown.selectedInteraction || {}), ...(detail || {}), }; state.drilldown.selectedTimeline = Array.isArray(timeline?.events) ? timeline.events : []; } catch (err) { if (!state.drilldown.open || state.drilldown.selectedInteractionId !== interactionId) { return; } state.drilldown.error = err.message; } finally { if (state.drilldown.open && state.drilldown.selectedInteractionId === interactionId) { state.drilldown.detailLoading = false; renderAnalyticsDrilldown(); syncAnalyticsUrlState(); } } } async function loadAiAnalyticsDrilldownPageState(options = {}) { if (!state.drilldown.open || !state.drilldown.filters) { return; } const preserveSelection = Boolean(options.preserveSelection); state.drilldown.loading = true; state.drilldown.error = ''; renderAnalyticsDrilldown(); if (analyticsMockEnabled()) { await ensureAnalyticsMockDataLoaded(); const data = buildMockAiDrilldownData(state.drilldown.filters, state.drilldown.limit, state.drilldown.offset); state.drilldown.items = Array.isArray(data?.items) ? data.items : []; state.drilldown.total = Number(data?.total || 0); state.drilldown.limit = Number(data?.limit || state.drilldown.limit || DRILLDOWN_PAGE_SIZE); state.drilldown.offset = Number(data?.offset || 0); state.drilldown.filters = normalizeAnalyticsDrilldownFilters(data?.filters || state.drilldown.filters); state.drilldown.coverage = data?.coverage || state.drilldown.coverage; state.drilldown.metricNote = aiAnalyticsDrilldownNote(state.drilldown.filters); const selectedId = state.drilldown.selectedInteractionId || ''; const selectedStillVisible = preserveSelection && selectedId && state.drilldown.items.some((item) => analyticsDrilldownCurrentItemId(item) === selectedId); if (selectedStillVisible) { state.drilldown.selectedInteraction = state.drilldown.items.find((item) => analyticsDrilldownCurrentItemId(item) === selectedId) || state.drilldown.selectedInteraction; void loadAnalyticsDrilldownDetails(selectedId); } else if (state.drilldown.items.length) { state.drilldown.selectedInteractionId = analyticsDrilldownCurrentItemId(state.drilldown.items[0]); state.drilldown.selectedInteraction = state.drilldown.items[0]; state.drilldown.selectedLinkedInteraction = null; state.drilldown.selectedTimeline = []; void loadAnalyticsDrilldownDetails(state.drilldown.selectedInteractionId); } else { state.drilldown.selectedInteractionId = ''; state.drilldown.selectedInteraction = null; state.drilldown.selectedLinkedInteraction = null; state.drilldown.selectedTimeline = []; state.drilldown.detailLoading = false; } state.drilldown.loading = false; renderAnalyticsDrilldown(); syncAnalyticsUrlState(); return; } try { const data = await fetchAiAnalyticsDrilldown(state.drilldown.filters, state.drilldown.limit, state.drilldown.offset); if (!state.drilldown.open) { return; } state.drilldown.items = Array.isArray(data?.items) ? data.items : []; state.drilldown.total = Number(data?.total || 0); state.drilldown.limit = Number(data?.limit || state.drilldown.limit || DRILLDOWN_PAGE_SIZE); state.drilldown.offset = Number(data?.offset || 0); state.drilldown.filters = normalizeAnalyticsDrilldownFilters(data?.filters || state.drilldown.filters); state.drilldown.coverage = data?.coverage || state.drilldown.coverage; state.drilldown.metricNote = aiAnalyticsDrilldownNote(state.drilldown.filters); const selectedId = state.drilldown.selectedInteractionId || ''; const selectedStillVisible = preserveSelection && selectedId && state.drilldown.items.some((item) => analyticsDrilldownCurrentItemId(item) === selectedId); if (selectedStillVisible) { state.drilldown.selectedInteraction = state.drilldown.items.find( (item) => analyticsDrilldownCurrentItemId(item) === selectedId, ) || state.drilldown.selectedInteraction; void loadAnalyticsDrilldownDetails(selectedId); } else if (state.drilldown.items.length) { state.drilldown.selectedInteractionId = analyticsDrilldownCurrentItemId(state.drilldown.items[0]); state.drilldown.selectedInteraction = state.drilldown.items[0]; state.drilldown.selectedLinkedInteraction = null; state.drilldown.selectedTimeline = []; void loadAnalyticsDrilldownDetails(state.drilldown.selectedInteractionId); } else { state.drilldown.selectedInteractionId = ''; state.drilldown.selectedInteraction = null; state.drilldown.selectedLinkedInteraction = null; state.drilldown.selectedTimeline = []; state.drilldown.detailLoading = false; } } catch (err) { if (!state.drilldown.open) { return; } state.drilldown.items = []; state.drilldown.total = 0; state.drilldown.selectedInteractionId = ''; state.drilldown.selectedInteraction = null; state.drilldown.selectedLinkedInteraction = null; state.drilldown.selectedTimeline = []; state.drilldown.detailLoading = false; state.drilldown.error = err.message; } finally { if (state.drilldown.open) { state.drilldown.loading = false; renderAnalyticsDrilldown(); syncAnalyticsUrlState(); } } } loadAnalyticsDrilldownPage = async function loadAnalyticsDrilldownPage(options = {}) { if (state.drilldown.mode === 'ai') { return loadAiAnalyticsDrilldownPageState(options); } if (!state.drilldown.open || !state.drilldown.filters) { return; } const preserveSelection = Boolean(options.preserveSelection); state.drilldown.loading = true; state.drilldown.error = ''; renderAnalyticsDrilldown(); if (analyticsMockEnabled()) { await ensureAnalyticsMockDataLoaded(); const data = buildMockInteractionDrilldownData(state.drilldown.filters, state.drilldown.limit, state.drilldown.offset, state.drilldown.mode); state.drilldown.items = Array.isArray(data?.items) ? data.items : []; state.drilldown.total = Number(data?.total || 0); state.drilldown.limit = Number(data?.limit || state.drilldown.limit || DRILLDOWN_PAGE_SIZE); state.drilldown.offset = Number(data?.offset || 0); state.drilldown.filters = normalizeAnalyticsDrilldownFilters(data?.filters || state.drilldown.filters); state.drilldown.metric = data?.metric || state.drilldown.metric; state.drilldown.coverage = data?.coverage || state.drilldown.coverage; if (state.drilldown.mode === 'metric' && state.drilldown.metric) { state.drilldown.metricNote = analyticsMetricDrilldownNote(state.drilldown.metric); } const selectedStillVisible = preserveSelection && state.drilldown.selectedInteractionId && state.drilldown.items.some((item) => item.interaction_id === state.drilldown.selectedInteractionId); if (selectedStillVisible) { state.drilldown.selectedInteraction = state.drilldown.items.find((item) => item.interaction_id === state.drilldown.selectedInteractionId) || state.drilldown.selectedInteraction; void loadAnalyticsDrilldownDetails(state.drilldown.selectedInteractionId); } else if (state.drilldown.items.length) { state.drilldown.selectedInteractionId = state.drilldown.items[0].interaction_id; state.drilldown.selectedInteraction = state.drilldown.items[0]; state.drilldown.selectedTimeline = []; void loadAnalyticsDrilldownDetails(state.drilldown.selectedInteractionId); } else { state.drilldown.selectedInteractionId = ''; state.drilldown.selectedInteraction = null; state.drilldown.selectedTimeline = []; state.drilldown.detailLoading = false; } state.drilldown.loading = false; renderAnalyticsDrilldown(); syncAnalyticsUrlState(); return; } try { const data = state.drilldown.mode === 'metric' ? await fetchAnalyticsMetricDrilldown(state.drilldown.filters, state.drilldown.limit, state.drilldown.offset) : await api( 'interaction', `interactions/drilldown?${analyticsDrilldownQuery(state.drilldown.filters, state.drilldown.limit, state.drilldown.offset)}`, ); if (!state.drilldown.open) { return; } state.drilldown.items = Array.isArray(data?.items) ? data.items : []; state.drilldown.total = Number(data?.total || 0); state.drilldown.limit = Number(data?.limit || state.drilldown.limit || DRILLDOWN_PAGE_SIZE); state.drilldown.offset = Number(data?.offset || 0); state.drilldown.filters = normalizeAnalyticsDrilldownFilters(data?.filters || state.drilldown.filters); state.drilldown.metric = data?.metric || state.drilldown.metric; state.drilldown.coverage = data?.coverage || state.drilldown.coverage; if (state.drilldown.mode === 'metric' && state.drilldown.metric) { state.drilldown.metricNote = analyticsMetricDrilldownNote(state.drilldown.metric); } const selectedStillVisible = preserveSelection && state.drilldown.selectedInteractionId && state.drilldown.items.some((item) => item.interaction_id === state.drilldown.selectedInteractionId); if (selectedStillVisible) { state.drilldown.selectedInteraction = state.drilldown.items.find( (item) => item.interaction_id === state.drilldown.selectedInteractionId, ) || state.drilldown.selectedInteraction; void loadAnalyticsDrilldownDetails(state.drilldown.selectedInteractionId); } else if (state.drilldown.items.length) { state.drilldown.selectedInteractionId = state.drilldown.items[0].interaction_id; state.drilldown.selectedInteraction = state.drilldown.items[0]; state.drilldown.selectedTimeline = []; void loadAnalyticsDrilldownDetails(state.drilldown.selectedInteractionId); } else { state.drilldown.selectedInteractionId = ''; state.drilldown.selectedInteraction = null; state.drilldown.selectedTimeline = []; state.drilldown.detailLoading = false; } } catch (err) { if (!state.drilldown.open) { return; } state.drilldown.items = []; state.drilldown.total = 0; state.drilldown.selectedInteractionId = ''; state.drilldown.selectedInteraction = null; state.drilldown.selectedTimeline = []; state.drilldown.detailLoading = false; state.drilldown.error = err.message; } finally { if (state.drilldown.open) { state.drilldown.loading = false; renderAnalyticsDrilldown(); syncAnalyticsUrlState(); } } } openAnalyticsDrilldown = function openAnalyticsDrilldown(sourceType, sourceValue = '', options = {}) { const mode = options.mode || (sourceType === 'metric' ? 'metric' : 'interaction'); const filters = analyticsDrilldownBaseFilters(); if (mode === 'metric') { filters.metric = options.metric || sourceValue; filters.status = ''; filters.q = ''; } if (mode === 'ai') { filters.slice = options.slice || 'all'; filters.reason_key = options.reasonKey || ''; filters.status = ''; filters.q = ''; filters.sort_by = 'created_at'; filters.sort_dir = 'desc'; if (sourceType === 'ai-overview' && sourceValue) { filters.slice = sourceValue; } if (sourceType === 'ai-channel' && sourceValue) { filters.channel = sourceValue; } if (sourceType === 'ai-reason' && sourceValue) { filters.slice = 'handoff'; filters.reason_key = options.reasonKey || sourceValue; } } if (sourceType === 'channel' && sourceValue) { filters.channel = sourceValue; } if (sourceType === 'queue' && sourceValue) { filters.queue_id = sourceValue; } if (sourceType === 'agent' && sourceValue) { filters.agent_id = sourceValue; } rememberAnalyticsDrilldownReturnFocus(); state.drilldown = { ...emptyDrilldownState(), open: true, mode, sourceType, sourceLabel: options.sourceLabel || analyticsDrilldownSourceLabel(sourceType, sourceValue), sourceValue, metric: mode === 'metric' ? (options.metric || sourceValue) : '', filters: normalizeAnalyticsDrilldownFilters(filters), coverage: options.coverage || null, metricNote: mode === 'metric' ? (options.metricNote || analyticsMetricDrilldownNote(options.metric || sourceValue)) : mode === 'ai' ? aiAnalyticsDrilldownNote(filters) : '', }; renderAnalyticsDrilldown(); syncAnalyticsUrlState(); window.requestAnimationFrame(() => { focusAnalyticsDrilldownPrimaryControl(true); }); void loadAnalyticsDrilldownPage(); } 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()) { const refreshBtn = $('refreshBtn'); const analyticsRefreshBtn = $('analyticsRefreshBtn'); const analyticsExportBtn = $('analyticsExportBtn'); const analyticsSaveViewBtn = $('analyticsSaveViewBtn'); if (refreshBtn) { refreshBtn.disabled = state.analytics.loading; } if (analyticsRefreshBtn) { analyticsRefreshBtn.disabled = state.analytics.loading; } if (analyticsExportBtn) { analyticsExportBtn.disabled = state.analytics.loading; } if (analyticsSaveViewBtn) { analyticsSaveViewBtn.disabled = state.analytics.loading; } renderAnalyticsQueueOptions(); renderSavedAnalyticsViews(); updateAnalyticsRangeHint(rangeMeta); renderAnalyticsNarrative(rangeMeta); renderAnalyticsComparePanel(); renderAnalyticsOverview(); renderVoiceNameAnalyticsEmptyState(); renderVoiceNameAnalyticsOverview(); renderVoiceNameAnalyticsTrendChart(); renderVoiceNameAnalyticsFunnel(); renderVoiceNameAnalyticsLanguageTable(); renderVoiceNameAnalyticsQueueTable(); renderVoiceNameAnalyticsHandoffTable(); renderAiAnalyticsEmptyState(); renderAiAnalyticsOverview(); renderAiAnalyticsTrendChart(); renderAiAnalyticsChannelComparison(); renderAiAnalyticsCoverage(); renderAgentAnalyticsEmptyState(); renderAgentAnalyticsOverview(); renderAgentAnalyticsStateStrip(); renderAgentAnalyticsTrendChart(); renderAgentAnalyticsTeamTable(); renderAgentAnalyticsShiftTable(); renderAgentAnalyticsTable(); renderAnalyticsTrendChart(); renderAnalyticsChannelTable(); renderAnalyticsQueueTable(); renderAnalyticsCoverage(); renderAnalyticsEmptyState(); renderAnalyticsDrilldown(); } async function loadAnalyticsDashboard(_silent = false) { syncAnalyticsStateFromControls(); const rangeMeta = analyticsRangeFromControls(); const requestId = state.analytics.requestId + 1; state.analytics.requestId = requestId; state.analytics.lastRangeMeta = rangeMeta; state.analytics.loading = true; state.analytics.error = ''; state.analytics.voiceNameError = ''; state.analytics.voiceNameTrendError = ''; state.analytics.aiError = ''; state.analytics.agentError = ''; state.analytics.agentTrendError = ''; renderAnalyticsDashboard(rangeMeta); if (analyticsMockEnabled()) { await ensureAnalyticsMockDataLoaded(); const payload = buildMockAnalyticsDashboard(rangeMeta); if (requestId !== state.analytics.requestId) { return; } state.analytics.queueOptions = payload.queueOptions; state.analytics.queueAccessError = ''; state.analytics.overview = payload.overview; state.analytics.compare = state.analytics.compareMode === 'previous' ? payload.compare : emptyKpiEnvelope(rangeMeta.previous.from.toISOString(), rangeMeta.previous.to.toISOString()); state.analytics.coverage = payload.coverage; state.analytics.voiceNameOverview = payload.voiceNameOverview; state.analytics.voiceNameCompare = state.analytics.compareMode === 'previous' ? payload.voiceNameCompare : emptyVoiceNameAnalyticsOverview( rangeMeta.previous.from.toISOString(), rangeMeta.previous.to.toISOString(), state.analytics.queueId === 'all' ? null : state.analytics.queueId, null, ); state.analytics.aiOverview = payload.aiOverview; state.analytics.aiCompare = state.analytics.compareMode === 'previous' ? payload.aiCompare : emptyAiAnalyticsOverview(rangeMeta.previous.from.toISOString(), rangeMeta.previous.to.toISOString(), state.analytics.channel, state.analytics.queueId === 'all' ? null : state.analytics.queueId); state.analytics.agentOverview = payload.agentOverview; state.analytics.agentCompare = state.analytics.compareMode === 'previous' ? payload.agentCompare : emptyAgentAnalyticsOverview(rangeMeta.previous.from.toISOString(), rangeMeta.previous.to.toISOString(), state.analytics.channel, state.analytics.queueId === 'all' ? null : state.analytics.queueId); state.analytics.agentRows = Array.isArray(payload.agentOverview?.items) ? payload.agentOverview.items : []; state.analytics.channelRows = payload.channelRows; state.analytics.queueRows = payload.queueRows; state.analytics.trend = payload.trend; state.analytics.voiceNameTrend = payload.voiceNameTrend; state.analytics.aiTrend = payload.aiTrend; state.analytics.agentTrend = payload.agentTrend; state.analytics.loading = false; state.analytics.statusMessage = state.analytics.mockDataError ? `Показаны демонстрационные данные. Не удалось загрузить внешний mock-файл: ${state.analytics.mockDataError}` : 'Показаны демонстрационные данные из внешнего mock-файла.'; renderAnalyticsDashboard(rangeMeta); syncAnalyticsUrlState(); if (state.drilldown.open) { void loadAnalyticsDrilldownPage({ preserveSelection: true }); } return; } try { const queuePromise = loadAnalyticsQueueOptions(); const previousWindowPromise = state.analytics.compareMode === 'previous' ? fetchAnalyticsKpi(rangeMeta.previous).catch(() => emptyKpiEnvelope()) : Promise.resolve(emptyKpiEnvelope(rangeMeta.previous.from.toISOString(), rangeMeta.previous.to.toISOString())); const emptyAiCurrent = emptyAiAnalyticsOverview( rangeMeta.current.from.toISOString(), rangeMeta.current.to.toISOString(), aiAnalyticsSupportedChannel() ? state.analytics.channel : state.analytics.channel, state.analytics.queueId === 'all' ? null : state.analytics.queueId, ); const emptyAiPrevious = emptyAiAnalyticsOverview( rangeMeta.previous.from.toISOString(), rangeMeta.previous.to.toISOString(), aiAnalyticsSupportedChannel() ? state.analytics.channel : state.analytics.channel, state.analytics.queueId === 'all' ? null : state.analytics.queueId, ); const emptyVoiceCurrent = emptyVoiceNameAnalyticsOverview( rangeMeta.current.from.toISOString(), rangeMeta.current.to.toISOString(), state.analytics.queueId === 'all' ? null : state.analytics.queueId, null, ); const emptyVoicePrevious = emptyVoiceNameAnalyticsOverview( rangeMeta.previous.from.toISOString(), rangeMeta.previous.to.toISOString(), state.analytics.queueId === 'all' ? null : state.analytics.queueId, null, ); const emptyAgentCurrent = emptyAgentAnalyticsOverview( rangeMeta.current.from.toISOString(), rangeMeta.current.to.toISOString(), state.analytics.channel, state.analytics.queueId === 'all' ? null : state.analytics.queueId, ); const emptyAgentPrevious = emptyAgentAnalyticsOverview( rangeMeta.previous.from.toISOString(), rangeMeta.previous.to.toISOString(), state.analytics.channel, state.analytics.queueId === 'all' ? null : state.analytics.queueId, ); const aiOverviewPromise = aiAnalyticsSupportedChannel() ? fetchAiAnalyticsOverview(rangeMeta.current).catch((err) => { state.analytics.aiError = err.message; return emptyAiCurrent; }) : Promise.resolve(emptyAiCurrent); const aiComparePromise = aiAnalyticsSupportedChannel() && state.analytics.compareMode === 'previous' ? fetchAiAnalyticsOverview(rangeMeta.previous).catch(() => emptyAiPrevious) : Promise.resolve(emptyAiPrevious); const voiceNameOverviewPromise = voiceNameAnalyticsSupportedChannel() ? fetchVoiceNameAnalyticsOverview(rangeMeta.current).catch((err) => { state.analytics.voiceNameError = err.message; return emptyVoiceCurrent; }) : Promise.resolve(emptyVoiceCurrent); const voiceNameComparePromise = voiceNameAnalyticsSupportedChannel() && state.analytics.compareMode === 'previous' ? fetchVoiceNameAnalyticsOverview(rangeMeta.previous).catch(() => emptyVoicePrevious) : Promise.resolve(emptyVoicePrevious); const agentOverviewPromise = fetchAgentAnalyticsOverview(rangeMeta.current).catch((err) => { state.analytics.agentError = err.message; return emptyAgentCurrent; }); const agentComparePromise = state.analytics.compareMode === 'previous' ? fetchAgentAnalyticsOverview(rangeMeta.previous).catch(() => emptyAgentPrevious) : Promise.resolve(emptyAgentPrevious); const [current, previous, coverage, queues, voiceNameOverview, voiceNameCompare, aiOverview, aiCompare, agentOverview, agentCompare] = await Promise.all([ fetchAnalyticsKpi(rangeMeta.current), previousWindowPromise, api('reporting', 'reports/coverage').catch(() => ({ implemented_metrics: [], dimensions: [], supported_filters: [] })), queuePromise, voiceNameOverviewPromise, voiceNameComparePromise, aiOverviewPromise, aiComparePromise, agentOverviewPromise, agentComparePromise, ]); 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.voiceNameOverview = voiceNameOverview || emptyVoiceCurrent; state.analytics.voiceNameCompare = voiceNameCompare || emptyVoicePrevious; state.analytics.aiOverview = aiOverview || emptyAiCurrent; state.analytics.aiCompare = aiCompare || emptyAiPrevious; state.analytics.agentOverview = agentOverview || emptyAgentCurrent; state.analytics.agentCompare = agentCompare || emptyAgentPrevious; state.analytics.agentRows = Array.isArray(agentOverview?.items) ? agentOverview.items : []; 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, voiceNameTrend, aiTrend, agentTrend] = await Promise.all([ loadAnalyticsTrend(rangeMeta, requestId), loadAnalyticsQueueRows(rangeMeta, queues, requestId), loadVoiceNameAnalyticsTrend(rangeMeta, requestId), loadAiAnalyticsTrend(rangeMeta, requestId), loadAgentAnalyticsTrend(rangeMeta, requestId), ]); if (requestId !== state.analytics.requestId) { return; } state.analytics.trend = trend; state.analytics.queueRows = queueRows; state.analytics.voiceNameTrend = voiceNameTrend; state.analytics.aiTrend = aiTrend; state.analytics.agentTrend = agentTrend; state.analytics.loading = false; renderAnalyticsDashboard(rangeMeta); syncAnalyticsUrlState(); if (state.drilldown.open) { void loadAnalyticsDrilldownPage({ preserveSelection: true }); } } 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.voiceNameOverview = emptyVoiceNameAnalyticsOverview(); state.analytics.voiceNameCompare = emptyVoiceNameAnalyticsOverview(); state.analytics.aiOverview = emptyAiAnalyticsOverview(); state.analytics.aiCompare = emptyAiAnalyticsOverview(); state.analytics.agentOverview = emptyAgentAnalyticsOverview(); state.analytics.agentCompare = emptyAgentAnalyticsOverview(); state.analytics.coverage = { implemented_metrics: [], dimensions: [], supported_filters: [] }; state.analytics.trend = []; state.analytics.voiceNameTrend = emptyVoiceNameAnalyticsTimeseries(state.analytics.voiceNameTrendMetric || 'scenario_calls'); state.analytics.aiTrend = emptyAiAnalyticsTimeseries(state.analytics.aiTrendMetric || 'containment_rate'); state.analytics.agentTrend = emptyAgentAnalyticsTimeseries(state.analytics.agentTrendMetric || 'interactions_per_agent'); state.analytics.channelRows = []; state.analytics.queueRows = []; state.analytics.agentRows = []; renderAnalyticsDashboard(rangeMeta); syncAnalyticsUrlState(); } } function handleAnalyticsOverviewClick(event) { const button = event.target.closest('[data-analytics-drilldown-source]'); if (!button) { return; } const metric = button.dataset.analyticsDrilldownMetric || ''; const sourceType = button.dataset.analyticsDrilldownSource || 'overview'; if (metric) { openAnalyticsDrilldown('metric', metric, { mode: 'metric', metric, sourceLabel: button.dataset.analyticsDrilldownLabel || analyticsDrilldownSourceLabel('metric', metric), coverage: analyticsMetricCoverage(metric), metricNote: analyticsMetricDrilldownNote(metric), }); return; } openAnalyticsDrilldown(sourceType); } function handleAnalyticsChannelClick(event) { const button = event.target.closest('[data-analytics-channel]'); if (!button) { return; } openAnalyticsDrilldown('channel', button.dataset.analyticsChannel || ''); } function handleAnalyticsQueueClick(event) { const button = event.target.closest('[data-analytics-queue-id]'); if (!button) { return; } openAnalyticsDrilldown('queue', button.dataset.analyticsQueueId || ''); } function handleAgentAnalyticsClick(event) { const button = event.target.closest('[data-analytics-agent-id]'); if (!button) { return; } openAnalyticsDrilldown('agent', button.dataset.analyticsAgentId || '', { sourceLabel: button.dataset.analyticsSourceLabel || `Агент: ${button.dataset.analyticsAgentId || ''}`, }); } handleAnalyticsDrilldownListClick = function handleAnalyticsDrilldownListClick(event) { if (state.drilldown.mode === 'ai') { const button = event.target.closest('[data-analytics-row-id]'); if (!button) { return; } maybeScrollAnalyticsDrilldownDetailIntoView(); void loadAnalyticsDrilldownDetails(button.dataset.analyticsRowId || ''); return; } const button = event.target.closest('[data-analytics-interaction-id]'); if (!button) { return; } maybeScrollAnalyticsDrilldownDetailIntoView(); void loadAnalyticsDrilldownDetails(button.dataset.analyticsInteractionId || ''); } function handleAnalyticsDrilldownPrevPage() { if (!state.drilldown.open || state.drilldown.offset <= 0 || state.drilldown.loading) { return; } state.drilldown.offset = Math.max(0, state.drilldown.offset - state.drilldown.limit); void loadAnalyticsDrilldownPage(); } function handleAnalyticsDrilldownNextPage() { if (!state.drilldown.open || state.drilldown.loading) { return; } if (state.drilldown.offset + state.drilldown.limit >= state.drilldown.total) { return; } state.drilldown.offset += state.drilldown.limit; void loadAnalyticsDrilldownPage(); } function handleAnalyticsFilterChange() { closeAnalyticsDrilldown({ skipUrlSync: true }); syncAnalyticsStateFromControls(); detachActiveAnalyticsView(); renderSavedAnalyticsViews(); syncAnalyticsUrlState(); void loadAnalyticsDashboard(); } function handleAnalyticsPresetChange() { state.analytics.fromTs = ''; state.analytics.toTs = ''; $('analyticsFrom').value = ''; $('analyticsTo').value = ''; handleAnalyticsFilterChange(); } function applySelectedAnalyticsView() { const viewId = $('analyticsSavedViewSelect').value || ''; if (!viewId) { state.analytics.activeViewId = ''; state.analytics.statusMessage = ''; renderSavedAnalyticsViews(); syncAnalyticsUrlState(); return; } const view = state.analytics.savedViews.find((item) => item.id === viewId); if (!view) { state.analytics.activeViewId = ''; renderSavedAnalyticsViews(); syncAnalyticsUrlState(); return; } state.analytics.activeViewId = view.id; state.analytics.statusMessage = `Загружен вид «${view.name}».`; applyAnalyticsSnapshot(view.snapshot); closeAnalyticsDrilldown({ skipUrlSync: true }); syncAnalyticsControlsFromState(); syncAnalyticsUrlState(); void loadAnalyticsDashboard(); } function aiAnalyticsOutcomeSessions(payload, outcome) { const rows = Array.isArray(payload?.breakdowns?.by_outcome) ? payload.breakdowns.by_outcome : []; return Number(rows.find((item) => item.outcome === outcome)?.sessions || 0); } function aiAnalyticsCanDrilldown(metric, payload = state.analytics.aiOverview || emptyAiAnalyticsOverview()) { if (!aiAnalyticsSupportedChannel() || state.analytics.aiError) { return false; } if (!['containment_rate', 'handoff_rate', 'human_touched_rate', 'closed_without_operator_rate'].includes(metric)) { return false; } const total = Number(payload?.totals?.sessions_started || 0); if (!total) { return false; } return aiAnalyticsOutcomeSessions(payload, aiAnalyticsMetricSlice(metric)) > 0; } function aiAnalyticsMetricDetail(metric, payload) { const totals = payload?.totals || {}; if (metric === 'containment_rate') { return `${formatAnalyticsNumber(totals.sessions_contained || 0, 0)} из ${formatAnalyticsNumber(totals.sessions_started || 0, 0)} сессий`; } if (metric === 'handoff_rate') { return `${formatAnalyticsNumber(totals.sessions_handoff || 0, 0)} из ${formatAnalyticsNumber(totals.sessions_started || 0, 0)} сессий`; } if (metric === 'human_touched_rate') { return `${formatAnalyticsNumber(aiAnalyticsOutcomeSessions(payload, 'human_touched'), 0)} сессий с участием оператора`; } if (metric === 'ai_latency_avg_ms') { return `${formatAnalyticsNumber(totals.assistant_turns || 0, 0)} ответов AI`; } if (metric === 'closed_without_operator_rate') { return `${formatAnalyticsNumber(totals.sessions_closed_without_operator || 0, 0)} закрытий без оператора`; } return ''; } function renderAiAnalyticsCard(label, metric, currentPayload, previousPayload) { const value = aiAnalyticsMetricValue(metric, currentPayload); const showCompare = state.analytics.compareMode === 'previous'; const delta = showCompare ? aiAnalyticsDelta(metric, currentPayload, previousPayload) : null; const note = AI_ANALYTICS_METRIC_META[metric]?.note || ''; const detail = aiAnalyticsMetricDetail(metric, currentPayload); const drilldownEnabled = aiAnalyticsCanDrilldown(metric, currentPayload); const slice = aiAnalyticsMetricSlice(metric); const tag = drilldownEnabled ? 'button' : 'div'; const classes = [ 'summary-card', 'analytics-card', 'ai-analytics-card', drilldownEnabled ? 'analytics-card-button' : 'analytics-card-readonly', ].join(' '); const attributes = drilldownEnabled ? `type="button" data-ai-drilldown-slice="${escapeHtml(slice)}" data-ai-drilldown-label="${escapeHtml(label)}"` : ''; return ` <${tag} class="${classes}" ${attributes}>
${label}
${showCompare ? `${delta.text}` : ''}
${formatAiAnalyticsMetric(metric, value)}
${detail ? `
${detail}
` : ''} ${note ? `
${note}
` : ''} `; } function renderAiAnalyticsOverview() { const box = $('aiAnalyticsOverview'); if (!box) { return; } if (!aiAnalyticsSupportedChannel() || state.analytics.aiError) { box.innerHTML = ''; return; } const current = state.analytics.aiOverview || emptyAiAnalyticsOverview(); if (!state.analytics.loading && Number(current?.totals?.sessions_started || 0) === 0) { box.innerHTML = ''; return; } const previous = state.analytics.aiCompare || emptyAiAnalyticsOverview(); box.innerHTML = [ renderAiAnalyticsCard('Закрыто AI', 'containment_rate', current, previous), renderAiAnalyticsCard('Передано оператору', 'handoff_rate', current, previous), renderAiAnalyticsCard('С участием оператора', 'human_touched_rate', current, previous), renderAiAnalyticsCard('Закрыто без оператора', 'closed_without_operator_rate', current, previous), ].join(''); } function renderAiAnalyticsChannelComparison() { const container = $('aiAnalyticsChannelComparison'); const rows = Array.isArray(state.analytics.aiOverview?.breakdowns?.by_channel) ? state.analytics.aiOverview.breakdowns.by_channel : []; if (!container) { return; } if (!aiAnalyticsSupportedChannel() || state.analytics.aiError) { container.innerHTML = ''; return; } if (!rows.length) { container.innerHTML = '
Нет AI-сессий по Telegram и WhatsApp за выбранный период.
'; return; } container.innerHTML = `
Канал Сессии Только AI С оператором Передача Средняя задержка
${rows .map((item) => { const aiOnlyShare = item.sessions_started ? (item.ai_only_sessions / item.sessions_started) * 100 : 0; const humanTouchedShare = item.sessions_started ? (item.human_touched_sessions / item.sessions_started) * 100 : 0; const tag = item.sessions_started ? 'button' : 'div'; const attributes = item.sessions_started ? `type="button" class="analytics-table-row ai-analytics-channel-grid ai-analytics-row-button" data-ai-channel="${escapeHtml(item.channel)}"` : 'class="analytics-table-row ai-analytics-channel-grid"'; return ` <${tag} ${attributes}> ${escapeHtml(analyticsChannelLabel(item.channel))} ${formatAnalyticsNumber(item.sessions_started, 0)} ${formatAnalyticsNumber(aiOnlyShare, 2)}% ${formatAnalyticsNumber(humanTouchedShare, 2)}% ${formatAnalyticsNumber(item.handoff_rate || 0, 2)}% ${formatAiAnalyticsMetric('ai_latency_avg_ms', item.ai_latency_avg_ms)} `; }) .join('')} `; } function renderAiAnalyticsCoverage() { const container = $('aiAnalyticsCoverage'); const coverage = state.analytics.aiOverview?.coverage || emptyAiAnalyticsOverview().coverage; if (!container) { return; } if (!aiAnalyticsSupportedChannel() || state.analytics.aiError) { container.innerHTML = ''; return; } if (!state.analytics.loading && Number(state.analytics.aiOverview?.totals?.sessions_started || 0) === 0) { container.innerHTML = ''; return; } container.innerHTML = `
${formatAnalyticsNumber(coverage.sessions_with_interaction_id || 0, 0)}

AI-сессий связаны с ID обращения

${formatAnalyticsNumber(coverage.sessions_with_queue_id || 0, 0)}

AI-сессий имеют ID очереди через обращение или диалог

${formatAnalyticsNumber(coverage.sessions_with_latency_turns || 0, 0)}

AI-сессий содержат данные по задержке ответов модели

${formatAnalyticsNumber(coverage.sessions_with_terminal_state || 0, 0)}

AI-сессий дошли до финального состояния

${formatAnalyticsNumber(coverage.sessions_with_handoff_reason || 0, 0)}

AI-сессий содержат причину передачи

`; } function renderAiAnalyticsOutcomeTable() { const container = $('aiAnalyticsOutcomeTable'); const rows = Array.isArray(state.analytics.aiOverview?.breakdowns?.by_outcome) ? state.analytics.aiOverview.breakdowns.by_outcome : []; if (!container) { return; } if (!aiAnalyticsSupportedChannel() || state.analytics.aiError) { container.innerHTML = ''; return; } if (!rows.length) { container.innerHTML = '
Срезы по исходам появятся, когда в окне будут AI-сессии.
'; return; } container.innerHTML = `
Исход Сессии Доля
${rows .map((item) => { const tag = item.sessions ? 'button' : 'div'; const attributes = item.sessions ? `type="button" class="analytics-table-row ai-analytics-outcome-grid ai-analytics-row-button" data-ai-outcome="${escapeHtml(item.outcome)}" data-ai-outcome-label="${escapeHtml(item.label || aiAnalyticsSliceLabel(item.outcome))}"` : 'class="analytics-table-row ai-analytics-outcome-grid"'; return ` <${tag} ${attributes}> ${escapeHtml(item.label || aiAnalyticsSliceLabel(item.outcome))} ${formatAnalyticsNumber(item.sessions, 0)} ${formatAnalyticsNumber(item.share || 0, 2)}% `; }) .join('')} `; } function renderAiAnalyticsReasonTable() { const container = $('aiAnalyticsReasonTable'); const rows = Array.isArray(state.analytics.aiOverview?.breakdowns?.by_handoff_reason) ? state.analytics.aiOverview.breakdowns.by_handoff_reason : []; if (!container) { return; } if (!aiAnalyticsSupportedChannel() || state.analytics.aiError) { container.innerHTML = ''; return; } if (!rows.length) { container.innerHTML = '
За выбранный период handoff reasons не найдены.
'; return; } container.innerHTML = `
Причина Сессии Доля
${rows .map((item) => ` `) .join('')} `; } const renderAnalyticsDashboardBase = renderAnalyticsDashboard; const syncAnalyticsDrilldownControlsBase = syncAnalyticsDrilldownControls; const analyticsDrilldownFiltersForCurrentSourceBase = analyticsDrilldownFiltersForCurrentSource; const applyAnalyticsDrilldownLocalFiltersBase = applyAnalyticsDrilldownLocalFilters; const resetAnalyticsDrilldownLocalFiltersBase = resetAnalyticsDrilldownLocalFilters; const handleAnalyticsDrilldownSearchInputBase = handleAnalyticsDrilldownSearchInput; const handleAnalyticsDrilldownSearchKeydownBase = handleAnalyticsDrilldownSearchKeydown; const handleAnalyticsDrilldownStatusChangeBase = handleAnalyticsDrilldownStatusChange; const handleAnalyticsDrilldownSortChangeBase = handleAnalyticsDrilldownSortChange; renderAnalyticsDashboard = function renderAnalyticsDashboard(rangeMeta = analyticsRangeFromControls()) { renderAnalyticsDashboardBase(rangeMeta); renderAiAnalyticsOutcomeTable(); renderAiAnalyticsReasonTable(); } analyticsDrilldownChips = function analyticsDrilldownChips() { if (!state.drilldown.filters) { return []; } const drilldownFilters = normalizeAnalyticsDrilldownFilters(state.drilldown.filters); const chips = []; chips.push(state.drilldown.sourceLabel || 'Детализация'); chips.push(`${formatTime(drilldownFilters.from_ts)} - ${formatTime(drilldownFilters.to_ts)}`); if (drilldownFilters.channel) { chips.push(analyticsChannelLabel(drilldownFilters.channel)); } if (drilldownFilters.queue_id) { chips.push(analyticsQueueName(drilldownFilters.queue_id)); } if (drilldownFilters.agent_id) { chips.push(`Агент: ${drilldownFilters.agent_id}`); } if (state.drilldown.mode === 'ai' && drilldownFilters.slice && drilldownFilters.slice !== 'all') { chips.push(aiAnalyticsSliceLabel(drilldownFilters.slice)); } if (state.drilldown.mode === 'ai' && drilldownFilters.reason_key) { chips.push(`Причина: ${drilldownFilters.reason_key}`); } if (drilldownFilters.status) { chips.push(`Статус: ${state.drilldown.mode === 'ai' ? aiAnalyticsStatusLabel(drilldownFilters.status) : interactionStatusLabel(drilldownFilters.status)}`); } if (drilldownFilters.q) { chips.push(`Поиск: ${drilldownFilters.q}`); } if (state.drilldown.mode === 'metric' && state.drilldown.metric) { chips.push(analyticsMetricCoverageLabel(state.drilldown.metric)); } else if (state.drilldown.mode === 'ai') { chips.push(aiAnalyticsSortLabel(drilldownFilters)); } else { chips.push(analyticsDrilldownSortLabel(drilldownFilters)); } return chips; } function analyticsDrilldownCurrentItemId(item) { if (!item) { return ''; } if (state.drilldown.mode === 'ai') { return String(item.session_id || ''); } return String(item.interaction_id || ''); } syncAnalyticsDrilldownControls = function syncAnalyticsDrilldownControls() { if (state.drilldown.mode !== 'ai') { syncAnalyticsDrilldownControlsBase(); return; } const searchInput = $('analyticsDrilldownSearch'); const statusSelect = $('analyticsDrilldownStatus'); const sortSelect = $('analyticsDrilldownSort'); const clearBtn = $('analyticsDrilldownClearBtn'); const exportBtn = $('analyticsDrilldownExportBtn'); const isOpen = state.drilldown.open; const localFiltersEnabled = isOpen; const filters = normalizeAnalyticsDrilldownFilters(state.drilldown.filters || {}); if (statusSelect) { statusSelect.innerHTML = [ '', '', '', '', '', '', ].join(''); statusSelect.value = isOpen ? (filters.status || 'all') : 'all'; statusSelect.disabled = !localFiltersEnabled || state.drilldown.loading || state.drilldown.exporting; } if (sortSelect) { sortSelect.innerHTML = [ '', '', '', '', '', ].join(''); sortSelect.value = analyticsDrilldownSortValue(filters); sortSelect.disabled = !localFiltersEnabled || state.drilldown.loading || state.drilldown.exporting; } if (searchInput) { searchInput.value = isOpen ? filters.q : ''; searchInput.disabled = !localFiltersEnabled || state.drilldown.loading || state.drilldown.exporting; searchInput.placeholder = 'ID сессии, диалога, обращения или причина'; } if (clearBtn) { clearBtn.disabled = !localFiltersEnabled || state.drilldown.loading || state.drilldown.exporting; } if (exportBtn) { exportBtn.disabled = !isOpen || state.drilldown.loading || state.drilldown.exporting || !state.drilldown.total; exportBtn.textContent = state.drilldown.exporting ? 'Готовим CSV...' : 'Экспорт CSV'; } } analyticsDrilldownFiltersForCurrentSource = function analyticsDrilldownFiltersForCurrentSource() { if (state.drilldown.mode !== 'ai') { return analyticsDrilldownFiltersForCurrentSourceBase(); } const filters = analyticsDrilldownBaseFilters(); filters.slice = 'all'; filters.reason_key = ''; filters.status = ''; filters.q = ''; filters.sort_by = 'created_at'; filters.sort_dir = 'desc'; if (state.drilldown.sourceType === 'ai-overview' && state.drilldown.sourceValue) { filters.slice = state.drilldown.sourceValue; } if (state.drilldown.sourceType === 'ai-channel' && state.drilldown.sourceValue) { filters.channel = state.drilldown.sourceValue; } if (state.drilldown.sourceType === 'ai-reason' && state.drilldown.sourceValue) { filters.slice = 'handoff'; filters.reason_key = state.drilldown.sourceValue; } return normalizeAnalyticsDrilldownFilters(filters); } applyAnalyticsDrilldownLocalFilters = function applyAnalyticsDrilldownLocalFilters(nextFilters, options = {}) { if (!state.drilldown.open || state.drilldown.mode === 'metric') { if (state.drilldown.mode !== 'ai') { return applyAnalyticsDrilldownLocalFiltersBase(nextFilters, options); } return; } state.drilldown.filters = normalizeAnalyticsDrilldownFilters(nextFilters); state.drilldown.offset = 0; state.drilldown.notice = ''; void loadAnalyticsDrilldownPage({ preserveSelection: options.preserveSelection !== false }); } resetAnalyticsDrilldownLocalFilters = function resetAnalyticsDrilldownLocalFilters() { clearAnalyticsDrilldownSearchDebounce(); if (!state.drilldown.open || state.drilldown.mode === 'metric') { if (state.drilldown.mode !== 'ai') { return resetAnalyticsDrilldownLocalFiltersBase(); } return; } state.drilldown.filters = analyticsDrilldownFiltersForCurrentSource(); state.drilldown.offset = 0; state.drilldown.notice = ''; renderAnalyticsDrilldown(); void loadAnalyticsDrilldownPage({ preserveSelection: true }); } handleAnalyticsDrilldownSearchInput = function handleAnalyticsDrilldownSearchInput(event) { if (!state.drilldown.open || state.drilldown.mode === 'metric') { if (state.drilldown.mode !== 'ai') { return handleAnalyticsDrilldownSearchInputBase(event); } return; } clearAnalyticsDrilldownSearchDebounce(); const query = event.target?.value || ''; analyticsDrilldownSearchDebounce = window.setTimeout(() => { if (!state.drilldown.open || !state.drilldown.filters) { return; } const normalizedQuery = query.trim(); if ((state.drilldown.filters.q || '') === normalizedQuery) { return; } applyAnalyticsDrilldownLocalFilters({ ...state.drilldown.filters, q: normalizedQuery, }); }, 300); } handleAnalyticsDrilldownSearchKeydown = function handleAnalyticsDrilldownSearchKeydown(event) { if (event.key !== 'Enter' || !state.drilldown.open || !state.drilldown.filters || state.drilldown.mode === 'metric') { if (state.drilldown.mode !== 'ai') { return handleAnalyticsDrilldownSearchKeydownBase(event); } return; } event.preventDefault(); clearAnalyticsDrilldownSearchDebounce(); const normalizedQuery = (event.target?.value || '').trim(); if ((state.drilldown.filters.q || '') === normalizedQuery) { return; } applyAnalyticsDrilldownLocalFilters({ ...state.drilldown.filters, q: normalizedQuery, }); } handleAnalyticsDrilldownStatusChange = function handleAnalyticsDrilldownStatusChange(event) { if (!state.drilldown.open || !state.drilldown.filters || state.drilldown.mode === 'metric') { if (state.drilldown.mode !== 'ai') { return handleAnalyticsDrilldownStatusChangeBase(event); } return; } const nextStatus = event.target?.value === 'all' ? '' : String(event.target?.value || ''); if ((state.drilldown.filters.status || '') === nextStatus) { return; } clearAnalyticsDrilldownSearchDebounce(); applyAnalyticsDrilldownLocalFilters({ ...state.drilldown.filters, status: nextStatus, }); } handleAnalyticsDrilldownSortChange = function handleAnalyticsDrilldownSortChange(event) { if (!state.drilldown.open || !state.drilldown.filters || state.drilldown.mode === 'metric') { if (state.drilldown.mode !== 'ai') { return handleAnalyticsDrilldownSortChangeBase(event); } return; } const nextSort = parseAnalyticsDrilldownSort(event.target?.value || 'created_at:desc'); if ( (state.drilldown.filters.sort_by || 'created_at') === nextSort.sort_by && (state.drilldown.filters.sort_dir || 'desc') === nextSort.sort_dir ) { return; } clearAnalyticsDrilldownSearchDebounce(); applyAnalyticsDrilldownLocalFilters({ ...state.drilldown.filters, ...nextSort, }); } function analyticsTimeseriesIntervalForRange(rangeMeta) { const durationMs = Math.max(0, rangeMeta.current.to.getTime() - rangeMeta.current.from.getTime()); return durationMs <= 36 * 60 * 60 * 1000 ? 'hour' : 'day'; } async function fetchAnalyticsTimeseries(range, metric, interval, overrides = {}) { const params = new URLSearchParams(analyticsQuery(range, overrides)); params.set('metric', metric || state.analytics.trendMetric || 'volume'); params.set('interval', interval || 'day'); return api('reporting', `reports/timeseries?${params.toString()}`); } function trendPointEnvelope(metric, value) { const envelope = emptyKpiEnvelope(); if (metric === 'volume' || metric === 'total') { envelope.volume.total = Number(value || 0); return envelope; } if (metric === 'answered') { envelope.volume.answered = Number(value || 0); return envelope; } envelope.kpi[metric] = Number(value || 0); return envelope; } async function loadAnalyticsTrend(rangeMeta, requestId) { const metric = state.analytics.trendMetric || 'volume'; const interval = analyticsTimeseriesIntervalForRange(rangeMeta); try { const payload = await fetchAnalyticsTimeseries(rangeMeta.current, metric, interval); if (requestId !== state.analytics.requestId) { return []; } const resolvedInterval = payload?.interval || interval; state.analytics.trendInterval = resolvedInterval; const points = Array.isArray(payload?.points) ? payload.points : []; return points.map((point) => ({ ts: point.ts, label: formatAnalyticsBucketLabel(point.ts, resolvedInterval), payload: trendPointEnvelope(metric, point.value), sampleSize: Number(point.sample_size || 0), })); } catch { state.analytics.trendInterval = interval; return []; } } function reportingExportQuery(range, extra = {}) { const params = new URLSearchParams(analyticsQuery(range, extra)); if (extra.metric) { params.set('metric', extra.metric); } return params.toString(); } async function exportAnalyticsCsv() { const rangeMeta = state.analytics.lastRangeMeta || analyticsRangeFromControls(); const dateStamp = new Date().toISOString().slice(0, 10); try { await downloadProxyFile( 'reporting', `reports/export?${reportingExportQuery(rangeMeta.current)}`, `konturcc-analytics-${dateStamp}.csv`, ); state.analytics.statusMessage = `CSV выгружен в ${new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', })}.`; } catch (err) { state.analytics.statusMessage = `Не удалось выгрузить CSV: ${err.message}`; } renderSavedAnalyticsViews(); } async function loadSavedAnalyticsViews() { try { const items = await api('reporting', 'reports/views'); state.analytics.savedViews = Array.isArray(items) ? items .filter((item) => item && typeof item === 'object') .map((item) => normalizeSavedAnalyticsView(item)) .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt))) : []; } catch (err) { state.analytics.savedViews = []; state.analytics.statusMessage = `Не удалось загрузить сохранённые виды: ${err.message}`; } if ( state.analytics.activeViewId && !state.analytics.savedViews.some((item) => item.id === state.analytics.activeViewId) ) { state.analytics.activeViewId = ''; } renderSavedAnalyticsViews(); } async function saveAnalyticsView() { syncAnalyticsStateFromControls(); const input = $('analyticsSavedViewName'); const name = input?.value.trim() || suggestAnalyticsViewName(); try { const view = await api('reporting', 'reports/views', { method: 'POST', body: JSON.stringify({ id: state.analytics.activeViewId || null, name, snapshot: analyticsCurrentSnapshot(), }), }); const normalized = normalizeSavedAnalyticsView(view); const existingIndex = state.analytics.savedViews.findIndex((item) => item.id === normalized.id); if (existingIndex >= 0) { state.analytics.savedViews.splice(existingIndex, 1, normalized); state.analytics.statusMessage = `Вид «${name}» обновлён.`; } else { state.analytics.savedViews.unshift(normalized); state.analytics.statusMessage = `Вид «${name}» сохранён.`; } state.analytics.savedViews = state.analytics.savedViews .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt))) .slice(0, 12); state.analytics.activeViewId = normalized.id; } catch (err) { state.analytics.statusMessage = `Не удалось сохранить вид: ${err.message}`; } renderSavedAnalyticsViews(); } async function deleteAnalyticsView() { const activeView = state.analytics.savedViews.find((item) => item.id === state.analytics.activeViewId) || null; if (!activeView) { state.analytics.statusMessage = 'Выберите сохранённый вид, чтобы удалить его.'; renderSavedAnalyticsViews(); return; } try { await api('reporting', `reports/views/${encodeURIComponent(activeView.id)}`, { method: 'DELETE' }); state.analytics.savedViews = state.analytics.savedViews.filter((item) => item.id !== activeView.id); state.analytics.activeViewId = ''; state.analytics.statusMessage = `Вид «${activeView.name}» удалён.`; } catch (err) { state.analytics.statusMessage = `Не удалось удалить вид: ${err.message}`; } renderSavedAnalyticsViews(); } function wire() { $('logoutBtn').addEventListener('click', logout); $('refreshBtn').addEventListener('click', () => { void loadAnalyticsDashboard(); }); $('analyticsRefreshBtn').addEventListener('click', () => { void loadAnalyticsDashboard(); }); $('analyticsResetBtn').addEventListener('click', () => { resetAnalyticsFilters(); void loadAnalyticsDashboard(); }); $('analyticsPreset').addEventListener('change', handleAnalyticsPresetChange); $('analyticsFrom').addEventListener('change', handleAnalyticsFilterChange); $('analyticsTo').addEventListener('change', handleAnalyticsFilterChange); $('analyticsQueue').addEventListener('change', handleAnalyticsFilterChange); $('analyticsChannel').addEventListener('change', handleAnalyticsFilterChange); $('analyticsCompareMode').addEventListener('change', handleAnalyticsFilterChange); $('analyticsSavedViewSelect').addEventListener('change', applySelectedAnalyticsView); $('analyticsSaveViewBtn').addEventListener('click', async () => { await saveAnalyticsView(); syncAnalyticsUrlState(); }); $('analyticsDeleteViewBtn').addEventListener('click', async () => { await deleteAnalyticsView(); syncAnalyticsUrlState(); }); $('analyticsExportBtn').addEventListener('click', exportAnalyticsCsv); $('analyticsTrendMetric').addEventListener('change', () => { syncAnalyticsStateFromControls(); detachActiveAnalyticsView(); renderSavedAnalyticsViews(); renderAnalyticsTrendChart(); syncAnalyticsUrlState(); }); $('voiceNameAnalyticsTrendMetric')?.addEventListener('change', () => { syncAnalyticsStateFromControls(); detachActiveAnalyticsView(); renderSavedAnalyticsViews(); syncAnalyticsUrlState(); void loadAnalyticsDashboard(); }); $('aiAnalyticsTrendMetric').addEventListener('change', () => { syncAnalyticsStateFromControls(); detachActiveAnalyticsView(); renderSavedAnalyticsViews(); syncAnalyticsUrlState(); void loadAnalyticsDashboard(); }); $('agentAnalyticsTrendMetric')?.addEventListener('change', () => { syncAnalyticsStateFromControls(); detachActiveAnalyticsView(); renderSavedAnalyticsViews(); syncAnalyticsUrlState(); void loadAnalyticsDashboard(); }); $('analyticsOverview').addEventListener('click', handleAnalyticsOverviewClick); $('analyticsChannelTable').addEventListener('click', handleAnalyticsChannelClick); $('analyticsQueueTable').addEventListener('click', handleAnalyticsQueueClick); $('agentAnalyticsTable')?.addEventListener('click', handleAgentAnalyticsClick); $('analyticsDrilldownCloseBtn').addEventListener('click', closeAnalyticsDrilldown); $('analyticsDrilldownBackdrop').addEventListener('click', closeAnalyticsDrilldown); $('analyticsDrilldownList').addEventListener('click', handleAnalyticsDrilldownListClick); $('analyticsDrilldownPrevBtn').addEventListener('click', handleAnalyticsDrilldownPrevPage); $('analyticsDrilldownNextBtn').addEventListener('click', handleAnalyticsDrilldownNextPage); $('analyticsDrilldownSearch').addEventListener('input', handleAnalyticsDrilldownSearchInput); $('analyticsDrilldownSearch').addEventListener('keydown', handleAnalyticsDrilldownSearchKeydown); $('analyticsDrilldownStatus').addEventListener('change', handleAnalyticsDrilldownStatusChange); $('analyticsDrilldownSort').addEventListener('change', handleAnalyticsDrilldownSortChange); $('analyticsDrilldownClearBtn').addEventListener('click', resetAnalyticsDrilldownLocalFilters); $('analyticsDrilldownExportBtn').addEventListener('click', () => { void exportAnalyticsDrilldownCsv(); }); window.addEventListener('keydown', handleAnalyticsDrilldownWindowKeydown); window.addEventListener('message', handleOidcMessage); $('aiAnalyticsOverview')?.addEventListener('click', handleAiAnalyticsOverviewClick); $('aiAnalyticsChannelComparison')?.addEventListener('click', handleAiAnalyticsChannelClick); $('aiAnalyticsOutcomeTable')?.addEventListener('click', handleAiAnalyticsOutcomeClick); $('aiAnalyticsReasonTable')?.addEventListener('click', handleAiAnalyticsReasonClick); } async function init() { if (!restoreStoredSession()) { window.location.href = '/'; return; } if (!ensurePageAccess()) { return; } wire(); updateSessionInfo(); const deepLinkState = parseAnalyticsDeepLinkState(); analyticsUrlSyncSuspended = true; try { resetAnalyticsFilters(); if (deepLinkState.hasSnapshot) { applyAnalyticsSnapshot(deepLinkState.snapshot); } state.analytics.activeViewId = deepLinkState.activeViewId || ''; syncAnalyticsControlsFromState(); await Promise.all([ checkGateway(), loadOidcConfig(), loadSavedAnalyticsViews(), ]); if (!deepLinkState.hasSnapshot && state.analytics.activeViewId) { const savedView = state.analytics.savedViews.find((item) => item.id === state.analytics.activeViewId) || null; if (savedView) { applyAnalyticsSnapshot(savedView.snapshot); syncAnalyticsControlsFromState(); } } else { syncAnalyticsControlsFromState(); } await loadAnalyticsDashboard(true); if (deepLinkState.drilldown) { restoreAnalyticsDrilldownFromUrlState(deepLinkState.drilldown); } } finally { analyticsUrlSyncSuspended = false; } syncAnalyticsUrlState(); } init();