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) => `${escapeHtml(item.name)} `,
),
].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(`${escapeHtml(selected)} `);
}
options.push(
...state.analytics.queueOptions.map(
(item) => `${escapeHtml(item.name || item.queue_id)} `,
),
);
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}
${tag}>
`;
}
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)}%.
`;
}
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) => `
${escapeHtml(item.agent_id)}
${escapeHtml(item.last_activity_at ? `Последняя активность: ${formatTime(item.last_activity_at)}` : 'Без активности в окне')}
${escapeHtml(agentAnalyticsStateLabel(item.current_state || 'OFFLINE'))}
${escapeHtml(analyticsQueueName(item.current_queue_id || item.dominant_queue_id || ''))}
${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('')}
${totalAgents > rows.length ? `` : ''}
`;
}
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 = `
${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 `
${escapeHtml(analyticsChannelLabel(item.channel))}
${formatAnalyticsNumber(item.total, 0)}
${formatAnalyticsNumber(item.answered, 0)}
${formatAnalyticsNumber(item.abandoned, 0)}
${formatAnalyticsNumber(answerRate, 2)}%
`;
})
.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) => `
${escapeHtml(item.name || item.queue_id)} ${escapeHtml(item.queue_id)}
${formatAnalyticsNumber(item.total, 0)}
${formatAnalyticsNumber(item.answered, 0)}
${formatAnalyticsNumber(item.SL, 2)}%
${formatAnalyticsNumber(item.Abandon, 2)}%
`)
.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) => `
${escapeHtml(item.reason_label || item.session_id)}
${escapeHtml(aiAnalyticsStatusLabel(item.status))}
${escapeHtml(item.session_id)}
${escapeHtml(analyticsChannelLabel(item.channel))}
${escapeHtml(analyticsQueueName(item.queue_id))}
${renderAiAnalyticsDrilldownFactBadges(item)}
Обращение: ${escapeHtml(item.interaction_id || '—')}
${escapeHtml(formatTime(item.created_at))}
`)
.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) => `
${escapeHtml(item.reason_label || item.session_id)}
${escapeHtml(aiAnalyticsStatusLabel(item.status))}
${escapeHtml(item.session_id)}
${escapeHtml(analyticsChannelLabel(item.channel))}
${escapeHtml(analyticsQueueName(item.queue_id))}
${renderAiAnalyticsDrilldownFactBadges(item)}
Обращение: ${escapeHtml(item.interaction_id || '—')}
${escapeHtml(formatTime(item.created_at))}
`)
.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) => `
${escapeHtml(item.subject || item.interaction_id)}
${escapeHtml(interactionStatusLabel(item.status))}
${escapeHtml(item.interaction_id)}
${escapeHtml(analyticsChannelLabel(item.channel))}
${escapeHtml(analyticsQueueName(item.queue_id))}
${renderAnalyticsDrilldownFactBadges(item)}
Назначен: ${escapeHtml(item.assigned_to || 'Не назначен')}
${escapeHtml(formatTime(item.created_at))}
`)
.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}
` : ''}
${tag}>
`;
}
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)}
${tag}>
`;
})
.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)}%
${tag}>
`;
})
.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) => `
${escapeHtml(item.label || item.reason_key)}
${formatAnalyticsNumber(item.sessions, 0)}
${formatAnalyticsNumber(item.share || 0, 2)}%
`)
.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 = [
'Новые сверху ',
'Старые сверху ',
'Обновлённые сверху ',
'Задержка сверху ',
'Статус A-Z ',
].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();