Files
Didar KozhikovandClaude Sonnet 5 cdabe61bc2 Add architecture longread and remove dead code found during review
- docs: longread.md — deep architecture/flow review of the whole
  platform (services, event bus reality vs docs, AI/ML stack honesty
  check, tech debt inventory)
- ai_orchestrator_service/voice.py: drop _voice_decision_legacy
  (unreferenced) and the shadowed first _voice_decision definition
  (silently overwritten by the real one, dead code)
- ui/analyst/app.js: drop duplicate dead definitions of
  loadSavedAnalyticsViews/saveAnalyticsView/deleteAnalyticsView and
  the first loadAnalyticsTrend implementation, all shadowed by later
  declarations in the same file; kept the intentional AI-mode
  drilldown wrapper layer (openAnalyticsDrilldown/exportAnalyticsDrilldownCsv/etc.)
  since that duplication is deliberate delegation, not dead code
- ui/operator/vendor/sip-0.21.2.min.js: remove byte-identical orphaned
  duplicate of ui/operator/sip-0.21.2.min.js (unreferenced anywhere)

Verified via full pytest run: identical set of 97 pre-existing
failures before and after (sales_* test-isolation ordering issue and
one known persona-prompt test), no new regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 22:25:27 +05:00

7838 lines
311 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const state = {
user: '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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function renderSummaryCard(label, value, note = '') {
return `
<div class="summary-card">
<div class="summary-label">${label}</div>
<div class="summary-value">${value}</div>
${note ? `<div class="summary-note">${note}</div>` : ''}
</div>
`;
}
function updateProfileMeta() {
const profileName = $('profileName');
const profileRole = $('profileRole');
if (!profileName || !profileRole) {
return;
}
profileName.textContent = state.fullName || state.user || 'Пользователь KonturCC';
profileRole.textContent = ROLE_LABELS[state.role] || state.role || 'Пользователь';
}
function updateSessionInfo(extra = '') {
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 = [
'<option value="">Без сохранённого вида</option>',
...state.analytics.savedViews.map(
(item) => `<option value="${escapeHtml(item.id)}">${escapeHtml(item.name)}</option>`,
),
].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 = ['<option value="all">Все очереди</option>'];
if (selected !== 'all' && !state.analytics.queueOptions.some((item) => item.queue_id === selected)) {
options.push(`<option value="${escapeHtml(selected)}">${escapeHtml(selected)}</option>`);
}
options.push(
...state.analytics.queueOptions.map(
(item) => `<option value="${escapeHtml(item.queue_id)}">${escapeHtml(item.name || item.queue_id)}</option>`,
),
);
select.innerHTML = options.join('');
select.value = options.some((item) => item.includes(`value="${selected}"`)) ? selected : 'all';
}
async function loadAnalyticsQueueOptions() {
try {
const data = await api('routing', 'queues');
state.analytics.queueOptions = Array.isArray(data) ? data : [];
state.analytics.queueAccessError = '';
} catch (err) {
state.analytics.queueOptions = [];
state.analytics.queueAccessError = err.message;
}
renderAnalyticsQueueOptions();
return state.analytics.queueOptions;
}
function formatAnalyticsNumber(value, digits = 0) {
return new Intl.NumberFormat('ru-RU', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(Number(value || 0));
}
function analyticsChannelLabel(channel) {
const meta = ANALYTICS_CHANNEL_OPTIONS.find((item) => item.value === channel);
return meta ? meta.label : String(channel || 'неизвестно');
}
function metricValueFromPayload(metric, payload) {
if (metric === 'volume' || metric === 'total') {
return Number(payload?.volume?.total || 0);
}
if (metric === 'answered') {
return Number(payload?.volume?.answered || 0);
}
return Number(payload?.kpi?.[metric] || 0);
}
function formatAnalyticsMetric(metric, value) {
const meta = ANALYTICS_METRIC_META[metric] || { unit: 'count' };
if (meta.unit === 'pct') {
return `${formatAnalyticsNumber(value, 2)}%`;
}
if (meta.unit === 'seconds') {
return `${formatAnalyticsNumber(value, 2)} с`;
}
return formatAnalyticsNumber(value, 0);
}
function 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 `<span class="analytics-coverage-badge analytics-coverage-badge-${tone}">${escapeHtml(label)}</span>`;
}
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}>
<div class="analytics-card-top">
<div class="summary-label">${label}</div>
<div class="analytics-card-top-meta">
${coverageBadge}
${showCompare ? `<span class="analytics-delta analytics-delta-${delta.tone}">${delta.text}</span>` : ''}
</div>
</div>
<div class="summary-value">${formatAnalyticsMetric(metric, value)}</div>
${note ? `<div class="summary-note">${note}</div>` : ''}
<div class="analytics-card-hint">${hint}</div>
</${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 `
<div class="analytics-drilldown-facts">
${badges
.map(
(badge) => `<span class="analytics-fact-badge analytics-fact-badge-${escapeHtml(badge.tone)}">${escapeHtml(badge.label)}</span>`,
)
.join('')}
</div>
`;
}
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'
? `<span class="analytics-delta analytics-delta-${delta.tone}">${delta.text} к предыдущему окну</span>`
: '<span class="analytics-filter-chip analytics-filter-chip-quiet">Сравнение отключено</span>';
box.innerHTML = `
<div class="analytics-narrative-main">
<strong>${formatAnalyticsNumber(total, 0)} обращений</strong>
<p>За выбранный период ${formatAnalyticsNumber(sl, 2)}% потока уложилось в SLA, потери составили ${formatAnalyticsNumber(abandon, 2)}%, а цифровая доля достигла ${formatAnalyticsNumber(digitalShare, 2)}%.</p>
</div>
<div class="analytics-narrative-meta">
${deltaBadge}
<div class="analytics-filter-chips">
${chips.map((chip) => `<span class="analytics-filter-chip">${escapeHtml(chip)}</span>`).join('')}
</div>
</div>
`;
}
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 `
<article class="analytics-compare-card">
<div class="analytics-compare-card-head">
<span class="analytics-compare-label">${label}</span>
<div class="analytics-card-top-meta">
${coverageBadge}
<span class="analytics-delta analytics-delta-${delta.tone}">${delta.text}</span>
</div>
</div>
<div class="analytics-compare-values">
<div class="analytics-compare-value">
<small>Текущее окно</small>
<strong>${formatAnalyticsMetric(metric, currentValue)}</strong>
</div>
<div class="analytics-compare-value">
<small>Предыдущее окно</small>
<strong>${formatAnalyticsMetric(metric, previousValue)}</strong>
</div>
</div>
<p class="analytics-compare-note">${note}</p>
</article>
`;
}
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 = `
<div class="analytics-compare-head">
<div>
<h3>Сравнение периодов</h3>
<p class="hint">Быстрый разрез по главным изменениям между текущим и предыдущим окном без перехода в отдельный отчёт.</p>
</div>
<span class="analytics-filter-chip analytics-filter-chip-quiet">V2 compare mode</span>
</div>
<div class="analytics-compare-grid">
${[
renderAnalyticsCompareCard('Обращения', 'total', current, previous),
renderAnalyticsCompareCard('SL', 'SL', current, previous),
renderAnalyticsCompareCard('Среднее ожидание', 'ASA', current, previous),
renderAnalyticsCompareCard('Потери', 'Abandon', current, previous),
].join('')}
</div>
`;
}
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 `
<div class="summary-card analytics-card voice-name-card analytics-card-readonly">
<div class="analytics-card-top">
<div class="summary-label">${label}</div>
${showCompare ? `<span class="analytics-delta analytics-delta-${delta.tone}">${delta.text}</span>` : ''}
</div>
<div class="summary-value">${formatVoiceNameAnalyticsMetric(metric, value)}</div>
${detail ? `<div class="summary-note">${detail}</div>` : ''}
${note ? `<div class="analytics-card-hint">${note}</div>` : ''}
</div>
`;
}
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 = `<div class="empty-state">Не удалось обновить тренд по voice name-flow: ${escapeHtml(state.analytics.voiceNameTrendError)}</div>`;
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 ? `<span class="analytics-delta analytics-delta-${delta.tone}">${escapeHtml(delta.text)}</span>` : '',
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 = '<div class="empty-state">Нет данных по этапам сценария за выбранный период.</div>';
return;
}
container.innerHTML = `
<div class="analytics-table-head voice-name-funnel-grid">
<span>Этап</span>
<span>Сессии</span>
<span>Доля</span>
</div>
${rows.map((item) => `
<div class="analytics-table-row voice-name-funnel-grid">
<span><strong>${escapeHtml(VOICE_NAME_FUNNEL_LABELS[item.stage] || item.label || item.stage)}</strong></span>
<span>${formatAnalyticsNumber(item.sessions || 0, 0)}</span>
<span>${formatAnalyticsNumber(item.share || 0, 2)}%</span>
</div>
`).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 = '<div class="empty-state">По языкам пока нет достаточных данных.</div>';
return;
}
container.innerHTML = `
<div class="analytics-table-head voice-name-language-grid">
<span>Язык</span>
<span>Звонки</span>
<span>Старт</span>
<span>AI после follow-up</span>
<span>Передача без имени</span>
<span>Ручное исправление</span>
</div>
${rows.map((item) => `
<div class="analytics-table-row voice-name-language-grid">
<span><strong>${escapeHtml(voiceNameLanguageLabel(item.language))}</strong></span>
<span>${formatAnalyticsNumber(item.scenario_calls || 0, 0)}</span>
<span>${formatAnalyticsNumber(item.start_capture_rate || 0, 2)}%</span>
<span>${formatAnalyticsNumber(item.downstream_rescue_rate || 0, 2)}%</span>
<span>${formatAnalyticsNumber(item.handoff_unconfirmed_rate || 0, 2)}%</span>
<span>${formatAnalyticsNumber(item.manual_correction_rate || 0, 2)}%</span>
</div>
`).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 = '<div class="empty-state">По очередям пока нет данных по voice name-flow.</div>';
return;
}
container.innerHTML = `
<div class="analytics-table-head voice-name-queue-grid">
<span>Очередь</span>
<span>Звонки</span>
<span>Старт</span>
<span>AI после follow-up</span>
<span>Передача без имени</span>
<span>Ручное исправление</span>
</div>
${rows.map((item) => `
<div class="analytics-table-row voice-name-queue-grid">
<span><strong>${escapeHtml(analyticsQueueName(item.queue_id))}</strong><small>${escapeHtml(item.queue_id)}</small></span>
<span>${formatAnalyticsNumber(item.scenario_calls || 0, 0)}</span>
<span>${formatAnalyticsNumber(item.start_capture_rate || 0, 2)}%</span>
<span>${formatAnalyticsNumber(item.downstream_rescue_rate || 0, 2)}%</span>
<span>${formatAnalyticsNumber(item.handoff_unconfirmed_rate || 0, 2)}%</span>
<span>${formatAnalyticsNumber(item.manual_correction_rate || 0, 2)}%</span>
</div>
`).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 = '<div class="empty-state">Разрез по передачам пока пуст для выбранного окна.</div>';
return;
}
container.innerHTML = `
<div class="analytics-table-head voice-name-handoff-grid">
<span>Итог передачи</span>
<span>Звонки</span>
<span>Доля</span>
</div>
${rows.map((item) => `
<div class="analytics-table-row voice-name-handoff-grid">
<span><strong>${escapeHtml(VOICE_NAME_HANDOFF_LABELS[item.outcome] || item.label || item.outcome)}</strong></span>
<span>${formatAnalyticsNumber(item.sessions || 0, 0)}</span>
<span>${formatAnalyticsNumber(item.share || 0, 2)}%</span>
</div>
`).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 `
<div class="summary-card analytics-card ai-analytics-card analytics-card-readonly">
<div class="analytics-card-top">
<div class="summary-label">${label}</div>
${showCompare ? `<span class="analytics-delta analytics-delta-${delta.tone}">${delta.text}</span>` : ''}
</div>
<div class="summary-value">${formatAiAnalyticsMetric(metric, value)}</div>
${detail ? `<div class="summary-note">${detail}</div>` : ''}
${note ? `<div class="analytics-card-hint ai-analytics-card-hint">${note}</div>` : ''}
</div>
`;
}
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 = '<div class="empty-state">Нет AI-сессий по Telegram и WhatsApp за выбранный период.</div>';
return;
}
container.innerHTML = `
<div class="analytics-table-head ai-analytics-channel-grid">
<span>Канал</span>
<span>Сессии</span>
<span>Только AI</span>
<span>С оператором</span>
<span>Передача</span>
<span>Средняя задержка</span>
</div>
${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 `
<div class="analytics-table-row ai-analytics-channel-grid">
<span>${escapeHtml(analyticsChannelLabel(item.channel))}</span>
<span>${formatAnalyticsNumber(item.sessions_started, 0)}</span>
<span>${formatAnalyticsNumber(aiOnlyShare, 2)}%</span>
<span>${formatAnalyticsNumber(humanTouchedShare, 2)}%</span>
<span>${formatAnalyticsNumber(item.handoff_rate || 0, 2)}%</span>
<span>${formatAiAnalyticsMetric('ai_latency_avg_ms', item.ai_latency_avg_ms)}</span>
</div>
`;
})
.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 = `
<div class="ai-analytics-coverage-grid">
<div class="analytics-placeholder-card">
<strong>${formatAnalyticsNumber(coverage.sessions_with_interaction_id || 0, 0)}</strong>
<p>AI-сессий связаны с ID обращения</p>
</div>
<div class="analytics-placeholder-card">
<strong>${formatAnalyticsNumber(coverage.sessions_with_queue_id || 0, 0)}</strong>
<p>AI-сессий имеют ID очереди через обращение или диалог</p>
</div>
<div class="analytics-placeholder-card">
<strong>${formatAnalyticsNumber(coverage.sessions_with_latency_turns || 0, 0)}</strong>
<p>AI-сессий содержат данные по задержке ответов модели</p>
</div>
<div class="analytics-placeholder-card">
<strong>${formatAnalyticsNumber(coverage.sessions_with_terminal_state || 0, 0)}</strong>
<p>AI-сессий дошли до финального состояния</p>
</div>
</div>
`;
}
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 `
<div class="summary-card analytics-card agent-analytics-card analytics-card-readonly">
<div class="analytics-card-top">
<div class="summary-label">${label}</div>
${delta ? `<span class="analytics-delta analytics-delta-${delta.tone}">${delta.text}</span>` : ''}
</div>
<div class="summary-value">${formatAgentAnalyticsMetric(metric, value)}</div>
</div>
`;
}
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]) => `<span class="agent-state-chip agent-state-chip-${String(code).toLowerCase()}">${agentAnalyticsStateLabel(code)}: ${formatAnalyticsNumber(value, 0)}</span>`)
.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 = '<div class="empty-state">За выбранный период пока нет обращений с назначенным агентом.</div>';
return;
}
const totalAgents = Number(payload?.totals?.agents_total || rows.length);
container.innerHTML = `
<div class="analytics-table-head agent-analytics-grid">
<span>Агент</span>
<span>Статус</span>
<span>Очередь</span>
<span>Обращения</span>
<span>Обработано</span>
<span>AHT</span>
<span>FCR</span>
</div>
${rows
.map((item) => `
<button class="analytics-table-row analytics-table-button agent-analytics-grid" type="button" data-analytics-agent-id="${escapeHtml(item.agent_id)}" data-analytics-source-label="${escapeHtml(`Агент: ${item.agent_id}`)}">
<span>
<strong>${escapeHtml(item.agent_id)}</strong>
<small>${escapeHtml(item.last_activity_at ? `Последняя активность: ${formatTime(item.last_activity_at)}` : 'Без активности в окне')}</small>
</span>
<span><span class="analytics-drilldown-status analytics-drilldown-status-${escapeHtml(String(item.current_state || 'offline').toLowerCase())}">${escapeHtml(agentAnalyticsStateLabel(item.current_state || 'OFFLINE'))}</span></span>
<span>${escapeHtml(analyticsQueueName(item.current_queue_id || item.dominant_queue_id || ''))}</span>
<span>${formatAnalyticsNumber(item.interactions_total || 0, 0)}</span>
<span>${formatAnalyticsNumber(item.answered_total || 0, 0)}</span>
<span>${formatAgentAnalyticsMetric('avg_handle_seconds', item.avg_handle_seconds)}</span>
<span>${formatAgentAnalyticsMetric('avg_fcr_rate', item.fcr_rate)}</span>
</button>
`)
.join('')}
${totalAgents > rows.length ? `<div class="agent-analytics-table-foot">Показаны ${formatAnalyticsNumber(rows.length, 0)} из ${formatAnalyticsNumber(totalAgents, 0)} агентов по текущему окну.</div>` : ''}
`;
}
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 = `<div class="empty-state">Не удалось обновить тренд по агентам: ${escapeHtml(state.analytics.agentTrendError)}</div>`;
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 = '<div class="empty-state">По командам пока нет достаточно данных.</div>';
return;
}
container.innerHTML = `
<div class="analytics-table-head agent-team-grid">
<span>Команда</span>
<span>Агенты</span>
<span>Активны</span>
<span>Обращения</span>
<span>AHT</span>
<span>FCR</span>
</div>
${rows
.map((item) => `
<div class="analytics-table-row agent-team-grid">
<span><strong>${escapeHtml(item.label || item.team_key || 'Без очереди')}</strong></span>
<span>${formatAnalyticsNumber(item.agents_total || 0, 0)}</span>
<span>${formatAnalyticsNumber(item.agents_with_activity || 0, 0)}</span>
<span>${formatAnalyticsNumber(item.interactions_total || 0, 0)}</span>
<span>${formatAgentAnalyticsMetric('avg_handle_seconds', item.avg_handle_seconds)}</span>
<span>${formatAgentAnalyticsMetric('avg_fcr_rate', item.fcr_rate)}</span>
</div>
`)
.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 = '<div class="empty-state">По сменам пока нет достаточно данных.</div>';
return;
}
container.innerHTML = `
<div class="analytics-table-head agent-shift-grid">
<span>Смена</span>
<span>Агенты</span>
<span>Обращения</span>
<span>Обработано</span>
<span>AHT</span>
<span>FCR</span>
</div>
${rows
.map((item) => `
<div class="analytics-table-row agent-shift-grid">
<span><strong>${escapeHtml(item.label || item.shift_key)}</strong></span>
<span>${formatAnalyticsNumber(item.agents_with_activity || 0, 0)}</span>
<span>${formatAnalyticsNumber(item.interactions_total || 0, 0)}</span>
<span>${formatAnalyticsNumber(item.answered_total || 0, 0)}</span>
<span>${formatAgentAnalyticsMetric('avg_handle_seconds', item.avg_handle_seconds)}</span>
<span>${formatAgentAnalyticsMetric('avg_fcr_rate', item.fcr_rate)}</span>
</div>
`)
.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 = `
<strong>${escapeHtml(label)}</strong>
<span>${escapeHtml(value)}</span>
`;
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 = `<div class="empty-state">${options.emptyText}</div>`;
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 `
<line x1="${paddingLeft}" y1="${y}" x2="${width - paddingRight}" y2="${y}" class="analytics-grid-line"></line>
<text x="${paddingLeft - 8}" y="${y + 4}" class="analytics-axis-label" text-anchor="end">${escapeHtml(options.axisFormatter(labelValue))}</text>
`;
})
.join('');
const lineSegments = [];
let currentSegment = [];
let markers = '';
let hitTargets = '';
points.forEach((point, index) => {
if (point.value === null) {
if (currentSegment.length > 1) {
lineSegments.push(`<polyline points="${currentSegment.join(' ')}" class="analytics-line"></polyline>`);
}
currentSegment = [];
return;
}
const marker = pointFor(point.value, index);
currentSegment.push(`${marker.x},${marker.y}`);
const formattedValue = options.valueFormatter(point.value);
markers += `<circle cx="${marker.x}" cy="${marker.y}" r="4" class="analytics-point" data-chart-point="${index}"></circle>`;
hitTargets += `<circle cx="${marker.x}" cy="${marker.y}" r="14" class="analytics-point-hit" tabindex="0" role="img" aria-label="${escapeHtml(`${point.label}: ${formattedValue}`)}" data-index="${index}" data-label="${escapeHtml(point.label)}" data-value="${escapeHtml(formattedValue)}" data-x="${marker.x}" data-width="${width}"></circle>`;
});
if (currentSegment.length > 1) {
lineSegments.push(`<polyline points="${currentSegment.join(' ')}" class="analytics-line"></polyline>`);
}
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 `<text x="${x}" y="${height - 10}" class="analytics-axis-label" text-anchor="middle">${escapeHtml(point.label)}</text>`;
})
.join('');
const summaryText = options.summaryText || '';
const metaBadge = options.metaBadge || '';
container.innerHTML = `
<div class="analytics-chart-meta">
<strong>${escapeHtml(options.title)}</strong>
<div class="analytics-chart-meta-values">
${metaBadge}
<span class="analytics-chart-summary" data-chart-summary data-default="${escapeHtml(summaryText)}">${escapeHtml(summaryText)}</span>
</div>
</div>
<div class="analytics-chart-plot">
<div class="analytics-chart-tooltip" data-chart-tooltip hidden></div>
<svg viewBox="0 0 ${width} ${height}" class="analytics-chart-svg" role="img" aria-label="${escapeHtml(options.ariaLabel)}">
${grid}
${lineSegments.join('')}
${markers}
${hitTargets}
${labels}
</svg>
</div>
`;
resetTrendChartHover(container);
bindTrendChartHover(container);
}
function renderAnalyticsChannelTable() {
const container = $('analyticsChannelTable');
const rows = state.analytics.channelRows || [];
if (!rows.length) {
container.innerHTML = '<div class="empty-state">Нет данных по каналам за выбранный период.</div>';
return;
}
container.innerHTML = `
<div class="analytics-table-head analytics-channel-grid">
<span>Канал</span>
<span>Всего</span>
<span>Обработано</span>
<span>Потери</span>
<span>Доля ответа</span>
</div>
${rows
.map((item) => {
const answerRate = item.total ? (item.answered / item.total) * 100 : 0;
return `
<button class="analytics-table-row analytics-table-button analytics-channel-grid" type="button" data-analytics-channel="${escapeHtml(item.channel)}" data-analytics-source-label="${escapeHtml(analyticsChannelLabel(item.channel))}">
<span>${escapeHtml(analyticsChannelLabel(item.channel))}</span>
<span>${formatAnalyticsNumber(item.total, 0)}</span>
<span>${formatAnalyticsNumber(item.answered, 0)}</span>
<span>${formatAnalyticsNumber(item.abandoned, 0)}</span>
<span>${formatAnalyticsNumber(answerRate, 2)}%</span>
</button>
`;
})
.join('')}
`;
}
function renderAnalyticsQueueTable() {
const container = $('analyticsQueueTable');
const rows = state.analytics.queueRows || [];
if (!rows.length) {
const reason = state.analytics.queueAccessError
? 'Каталог очередей пока недоступен для этой роли. Можно продолжить анализ без разреза по очередям.'
: 'За выбранное окно пока нет данных по очередям. Попробуйте расширить период или снять часть фильтров.';
container.innerHTML = `<div class="empty-state">${escapeHtml(reason)}</div>`;
return;
}
container.innerHTML = `
<div class="analytics-table-head analytics-queue-grid">
<span>Очередь</span>
<span>Всего</span>
<span>Обработано</span>
<span>SL</span>
<span>Потери</span>
</div>
${rows
.map((item) => `
<button class="analytics-table-row analytics-table-button analytics-queue-grid" type="button" data-analytics-queue-id="${escapeHtml(item.queue_id)}" data-analytics-source-label="${escapeHtml(item.name || item.queue_id)}">
<span><strong>${escapeHtml(item.name || item.queue_id)}</strong><small>${escapeHtml(item.queue_id)}</small></span>
<span>${formatAnalyticsNumber(item.total, 0)}</span>
<span>${formatAnalyticsNumber(item.answered, 0)}</span>
<span>${formatAnalyticsNumber(item.SL, 2)}%</span>
<span>${formatAnalyticsNumber(item.Abandon, 2)}%</span>
</button>
`)
.join('')}
`;
}
function renderAnalyticsCoverage() {
const 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 = `
<div class="analytics-coverage-list">
<div class="analytics-coverage-block">
<h4>Уже измеряем</h4>
<div class="analytics-coverage-chips">
${implemented.length
? implemented
.map((item) => `<span class="analytics-coverage-chip">${escapeHtml(item.code)} · ${escapeHtml(item.label)}</span>`)
.join('')
: '<span class="empty-state">Каталог метрик пока не загружен.</span>'}
</div>
</div>
<div class="analytics-coverage-block">
<h4>Разрезы</h4>
<div class="analytics-coverage-chips">
${dimensions.length
? dimensions.map((item) => `<span class="analytics-coverage-chip soft">${escapeHtml(item)}</span>`).join('')
: '<span class="empty-state">Разрезы появятся после загрузки coverage.</span>'}
</div>
</div>
<div class="analytics-coverage-block">
<h4>Фильтры</h4>
<div class="analytics-coverage-chips">
${filters.length
? filters.map((item) => `<span class="analytics-coverage-chip soft">${escapeHtml(item)}</span>`).join('')
: '<span class="empty-state">Фильтры появятся после загрузки coverage.</span>'}
</div>
</div>
</div>
<div class="analytics-placeholder-grid">
<article class="analytics-placeholder-card">
<strong>Аналитика по агентам</strong>
<p>Следующий шаг: персональные метрики по агентам, сменам и командам с детализацией по эффективности.</p>
</article>
<article class="analytics-placeholder-card">
<strong>AI-аналитика</strong>
<p>Добавим долю закрытий AI, передачу оператору, задержку ответа и устойчивость AI-сценариев по каналам.</p>
</article>
<article class="analytics-placeholder-card">
<strong>Качество диалогов</strong>
<p>Здесь появятся оценки качества речи и диалогов на основе скоринга транскриптов и QA-правил.</p>
</article>
${
state.analytics.queueAccessError
? `<article class="analytics-placeholder-card">
<strong>Доступ к очередям</strong>
<p>${escapeHtml(state.analytics.queueAccessError)}</p>
</article>`
: ''
}
</div>
`;
}
function 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 = `
<div class="analytics-coverage-list">
<div class="analytics-coverage-block">
<h4>Уже измеряем</h4>
<div class="analytics-coverage-chips">
${implemented.length
? implemented
.map((item) => `<span class="analytics-coverage-chip">${escapeHtml(item.code)} · ${escapeHtml(item.label)}</span>`)
.join('')
: '<span class="empty-state">Каталог метрик пока не загружен.</span>'}
</div>
</div>
<div class="analytics-coverage-block">
<h4>Разрезы</h4>
<div class="analytics-coverage-chips">
${dimensions.length
? dimensions.map((item) => `<span class="analytics-coverage-chip soft">${escapeHtml(item)}</span>`).join('')
: '<span class="empty-state">Разрезы появятся после загрузки coverage.</span>'}
</div>
</div>
<div class="analytics-coverage-block">
<h4>Фильтры</h4>
<div class="analytics-coverage-chips">
${filters.length
? filters.map((item) => `<span class="analytics-coverage-chip soft">${escapeHtml(item)}</span>`).join('')
: '<span class="empty-state">Фильтры появятся после загрузки coverage.</span>'}
</div>
</div>
</div>
<div class="analytics-placeholder-grid">
<article class="analytics-placeholder-card">
<strong>Аналитика по агентам</strong>
<p>Следующий шаг: персональные метрики по агентам, сменам и командам с детализацией по эффективности.</p>
</article>
<article class="analytics-placeholder-card">
<strong>Прогнозы и алерты</strong>
<p>Дальше сюда добавим прогноз нагрузки, сигналы по SLA и аномалии по очередям и каналам.</p>
</article>
<article class="analytics-placeholder-card">
<strong>Качество диалогов</strong>
<p>Здесь появятся оценки качества речи и диалогов на основе скоринга транскриптов и QA-правил.</p>
</article>
${
state.analytics.queueAccessError
? `<article class="analytics-placeholder-card">
<strong>Доступ к очередям</strong>
<p>${escapeHtml(state.analytics.queueAccessError)}</p>
</article>`
: ''
}
</div>
`;
}
function 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 = '<div class="empty-state">За выбранный период недостаточно точек для построения тренда. Попробуйте расширить окно анализа.</div>';
return;
}
const values = items.map((item) => metricValueFromPayload(metric, item.payload));
const maxValue = Math.max(...values, 1);
const width = 720;
const height = 220;
const paddingLeft = 38;
const paddingRight = 16;
const paddingTop = 16;
const paddingBottom = 36;
const chartWidth = width - paddingLeft - paddingRight;
const chartHeight = height - paddingTop - paddingBottom;
const step = items.length > 1 ? chartWidth / (items.length - 1) : chartWidth;
const pointFor = (value, index) => {
const x = paddingLeft + index * step;
const y = paddingTop + chartHeight - (value / maxValue) * chartHeight;
return { x, y };
};
const polyline = values
.map((value, index) => {
const point = pointFor(value, index);
return `${point.x},${point.y}`;
})
.join(' ');
const grid = [0, 0.25, 0.5, 0.75, 1]
.map((ratio) => {
const y = paddingTop + chartHeight - ratio * chartHeight;
const labelValue = maxValue * ratio;
return `
<line x1="${paddingLeft}" y1="${y}" x2="${width - paddingRight}" y2="${y}" class="analytics-grid-line"></line>
<text x="${paddingLeft - 8}" y="${y + 4}" class="analytics-axis-label" text-anchor="end">${escapeHtml(formatAnalyticsNumber(labelValue, metric === 'volume' ? 0 : 1))}</text>
`;
})
.join('');
const markers = values
.map((value, index) => {
const point = pointFor(value, index);
return `<circle cx="${point.x}" cy="${point.y}" r="4" class="analytics-point"></circle>`;
})
.join('');
const labelStep = items.length > 12 ? Math.ceil(items.length / 6) : 1;
const labels = items
.map((item, index) => {
if (index % labelStep !== 0 && index !== items.length - 1) {
return '';
}
const point = pointFor(values[index], index);
return `<text x="${point.x}" y="${height - 10}" class="analytics-axis-label" text-anchor="middle">${escapeHtml(item.label)}</text>`;
})
.join('');
container.innerHTML = `
<div class="analytics-chart-meta">
<strong>${escapeHtml(analyticsMetricLabel(metric))}</strong>
<span>${escapeHtml(formatAnalyticsMetric(metric, values[values.length - 1] || 0))} в последней точке</span>
</div>
<svg viewBox="0 0 ${width} ${height}" class="analytics-chart-svg" role="img" aria-label="Analytics trend chart">
${grid}
<polyline points="${polyline}" class="analytics-line"></polyline>
${markers}
${labels}
</svg>
`;
}
function 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 = `<div class="empty-state">Не удалось обновить AI-тренд: ${escapeHtml(state.analytics.aiError)}</div>`;
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 ? `<span class="analytics-delta analytics-delta-${delta.tone}">${escapeHtml(delta.text)}</span>` : '',
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 `
<div class="analytics-drilldown-facts">
${badges
.map(
(badge) => `<span class="analytics-fact-badge analytics-fact-badge-${escapeHtml(badge.tone)}">${escapeHtml(badge.label)}</span>`,
)
.join('')}
</div>
`;
}
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) => `<span class="analytics-filter-chip">${escapeHtml(chip)}</span>`),
state.drilldown.notice
? `<span class="analytics-filter-chip analytics-filter-chip-quiet">${escapeHtml(state.drilldown.notice)}</span>`
: '',
].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 = '<div class="empty-state">Загружаем AI-сессии...</div>';
} else if (!state.drilldown.items.length) {
list.innerHTML = '<div class="empty-state">По этому AI-срезу сессий пока нет.</div>';
} else {
list.innerHTML = state.drilldown.items
.map((item) => `
<button
class="analytics-drilldown-row${item.session_id === state.drilldown.selectedInteractionId ? ' selected' : ''}"
type="button"
data-analytics-row-id="${escapeHtml(item.session_id)}"
>
<div class="analytics-drilldown-row-top">
<strong>${escapeHtml(item.reason_label || item.session_id)}</strong>
<span class="analytics-drilldown-status analytics-drilldown-status-${escapeHtml(analyticsDrilldownStatusClass(item.status))}">${escapeHtml(aiAnalyticsStatusLabel(item.status))}</span>
</div>
<div class="analytics-drilldown-row-meta">
<span>${escapeHtml(item.session_id)}</span>
<span class="analytics-drilldown-pill">${escapeHtml(analyticsChannelLabel(item.channel))}</span>
<span class="analytics-drilldown-pill">${escapeHtml(analyticsQueueName(item.queue_id))}</span>
</div>
${renderAiAnalyticsDrilldownFactBadges(item)}
<div class="analytics-drilldown-row-meta">
<span>Обращение: ${escapeHtml(item.interaction_id || '—')}</span>
<span>${escapeHtml(formatTime(item.created_at))}</span>
</div>
</button>
`)
.join('');
}
const selectedSession = state.drilldown.selectedInteraction
|| state.drilldown.items.find((item) => item.session_id === state.drilldown.selectedInteractionId)
|| null;
if (!selectedSession) {
detail.innerHTML = '<div class="empty-state">Выберите AI-сессию в списке, чтобы увидеть детали на уровне метаданных.</div>';
return;
}
const timelineMarkup = state.drilldown.detailLoading
? '<div class="empty-state">Загружаем ленту метаданных...</div>'
: state.drilldown.selectedTimeline.length
? state.drilldown.selectedTimeline
.map((event) => {
const safeMetadata = sanitizeAnalyticsTimelineMetadata(event.metadata);
const metadataMarkup = Object.keys(safeMetadata).length
? `<pre>${escapeHtml(JSON.stringify(safeMetadata, null, 2))}</pre>`
: '<p class="hint">Только событие уровня метаданных без текста сообщений.</p>';
return `
<div class="analytics-timeline-item">
<div class="analytics-timeline-meta">
<strong>${escapeHtml(event.label || event.event_type || 'событие')}</strong>
<span>${escapeHtml(formatTime(event.ts))}</span>
</div>
${metadataMarkup}
</div>
`;
})
.join('')
: '<div class="empty-state">Metadata timeline по этой AI session пока пуст.</div>';
const linkedInteractionMarkup = state.drilldown.selectedLinkedInteraction
? `
<div class="analytics-drilldown-detail-card">
<div class="analytics-drilldown-detail-head">
<div>
<strong>Связанное обращение</strong>
<p class="hint">${escapeHtml(state.drilldown.selectedLinkedInteraction.interaction_id || '—')}</p>
</div>
</div>
<div class="analytics-drilldown-detail-grid">
<div><small>Канал</small><strong>${escapeHtml(analyticsChannelLabel(state.drilldown.selectedLinkedInteraction.channel))}</strong></div>
<div><small>Очередь</small><strong>${escapeHtml(analyticsQueueName(state.drilldown.selectedLinkedInteraction.queue_id))}</strong></div>
<div><small>Статус</small><strong>${escapeHtml(interactionStatusLabel(state.drilldown.selectedLinkedInteraction.status))}</strong></div>
<div><small>Назначен</small><strong>${escapeHtml(state.drilldown.selectedLinkedInteraction.assigned_to || 'Не назначен')}</strong></div>
<div><small>Тема</small><strong>${escapeHtml(state.drilldown.selectedLinkedInteraction.subject || '—')}</strong></div>
<div><small>Обновлено</small><strong>${escapeHtml(formatTime(state.drilldown.selectedLinkedInteraction.updated_at))}</strong></div>
</div>
</div>
`
: '';
detail.innerHTML = `
<div class="analytics-drilldown-detail-card">
<div class="analytics-drilldown-detail-head">
<div>
<strong>${escapeHtml(selectedSession.session_id)}</strong>
<p class="hint">${escapeHtml(aiAnalyticsSliceLabel(state.drilldown.filters?.slice || 'all'))}</p>
</div>
<div class="analytics-card-top-meta">
<span class="analytics-filter-chip analytics-filter-chip-quiet">${escapeHtml(aiAnalyticsStatusLabel(selectedSession.status))}</span>
</div>
</div>
<div class="analytics-drilldown-metric-note">
<p class="hint">${escapeHtml(aiAnalyticsDrilldownNote(state.drilldown.filters))}</p>
</div>
${renderAiAnalyticsDrilldownFactBadges(selectedSession)}
<div class="analytics-drilldown-detail-grid">
<div><small>Канал</small><strong>${escapeHtml(analyticsChannelLabel(selectedSession.channel))}</strong></div>
<div><small>Очередь</small><strong>${escapeHtml(analyticsQueueName(selectedSession.queue_id))}</strong></div>
<div><small>ID обращения</small><strong>${escapeHtml(selectedSession.interaction_id || '—')}</strong></div>
<div><small>ID диалога</small><strong>${escapeHtml(selectedSession.thread_id || '—')}</strong></div>
<div><small>Назначено</small><strong>${escapeHtml(selectedSession.assigned_to || 'Не назначен')}</strong></div>
<div><small>Взял оператор</small><strong>${escapeHtml(selectedSession.claimed_by_user || '—')}</strong></div>
<div><small>Ответов AI</small><strong>${escapeHtml(String(selectedSession.assistant_turns || 0))}</strong></div>
<div><small>Сообщений клиента</small><strong>${escapeHtml(String(selectedSession.user_turns || 0))}</strong></div>
<div><small>Вызовов инструментов</small><strong>${escapeHtml(String(selectedSession.tool_turns || 0))}</strong></div>
<div><small>Средняя задержка</small><strong>${escapeHtml(formatAiAnalyticsMetric('ai_latency_avg_ms', selectedSession.ai_latency_avg_ms))}</strong></div>
<div><small>P95 задержки</small><strong>${escapeHtml(formatAiAnalyticsMetric('ai_latency_avg_ms', selectedSession.ai_latency_p95_ms))}</strong></div>
<div><small>Причина</small><strong>${escapeHtml(selectedSession.reason_label || selectedSession.raw_handoff_reason || '—')}</strong></div>
<div><small>Исходная причина</small><strong>${escapeHtml(selectedSession.raw_handoff_reason || '—')}</strong></div>
<div><small>Создано</small><strong>${escapeHtml(formatTime(selectedSession.created_at))}</strong></div>
<div><small>Обновлено</small><strong>${escapeHtml(formatTime(selectedSession.updated_at))}</strong></div>
<div><small>Закрыто</small><strong>${escapeHtml(formatTime(selectedSession.closed_at))}</strong></div>
</div>
</div>
${linkedInteractionMarkup}
<div class="analytics-drilldown-timeline">
${timelineMarkup}
</div>
`;
}
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) => `<span class="analytics-filter-chip">${escapeHtml(chip)}</span>`),
state.drilldown.notice
? `<span class="analytics-filter-chip analytics-filter-chip-quiet">${escapeHtml(state.drilldown.notice)}</span>`
: '',
].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 = '<div class="empty-state">Загружаем AI-сессии...</div>';
} else if (!state.drilldown.items.length) {
list.innerHTML = '<div class="empty-state">По этому AI-срезу сессий пока нет.</div>';
} else {
list.innerHTML = state.drilldown.items
.map((item) => `
<button
class="analytics-drilldown-row${item.session_id === state.drilldown.selectedInteractionId ? ' selected' : ''}"
type="button"
data-analytics-row-id="${escapeHtml(item.session_id)}"
>
<div class="analytics-drilldown-row-top">
<strong>${escapeHtml(item.reason_label || item.session_id)}</strong>
<span class="analytics-drilldown-status analytics-drilldown-status-${escapeHtml(analyticsDrilldownStatusClass(item.status))}">${escapeHtml(aiAnalyticsStatusLabel(item.status))}</span>
</div>
<div class="analytics-drilldown-row-meta">
<span>${escapeHtml(item.session_id)}</span>
<span class="analytics-drilldown-pill">${escapeHtml(analyticsChannelLabel(item.channel))}</span>
<span class="analytics-drilldown-pill">${escapeHtml(analyticsQueueName(item.queue_id))}</span>
</div>
${renderAiAnalyticsDrilldownFactBadges(item)}
<div class="analytics-drilldown-row-meta">
<span>Обращение: ${escapeHtml(item.interaction_id || '—')}</span>
<span>${escapeHtml(formatTime(item.created_at))}</span>
</div>
</button>
`)
.join('');
}
const selectedSession = state.drilldown.selectedInteraction
|| state.drilldown.items.find((item) => item.session_id === state.drilldown.selectedInteractionId)
|| null;
if (!selectedSession) {
detail.innerHTML = '<div class="empty-state">Выберите AI-сессию в списке, чтобы увидеть детали на уровне метаданных.</div>';
return;
}
const timelineMarkup = state.drilldown.detailLoading
? '<div class="empty-state">Загружаем ленту метаданных...</div>'
: state.drilldown.selectedTimeline.length
? state.drilldown.selectedTimeline
.map((event) => {
const safeMetadata = sanitizeAnalyticsTimelineMetadata(event.metadata);
const metadataMarkup = Object.keys(safeMetadata).length
? `<pre>${escapeHtml(JSON.stringify(safeMetadata, null, 2))}</pre>`
: '<p class="hint">Только событие уровня метаданных без текста сообщений.</p>';
return `
<div class="analytics-timeline-item">
<div class="analytics-timeline-meta">
<strong>${escapeHtml(event.label || event.event_type || 'событие')}</strong>
<span>${escapeHtml(formatTime(event.ts))}</span>
</div>
${metadataMarkup}
</div>
`;
})
.join('')
: '<div class="empty-state">Лента метаданных по этой AI-сессии пока пуста.</div>';
const linkedInteractionMarkup = state.drilldown.selectedLinkedInteraction
? `
<div class="analytics-drilldown-detail-card">
<div class="analytics-drilldown-detail-head">
<div>
<strong>Связанное обращение</strong>
<p class="hint">${escapeHtml(state.drilldown.selectedLinkedInteraction.interaction_id || '—')}</p>
</div>
</div>
<div class="analytics-drilldown-detail-grid">
<div><small>Канал</small><strong>${escapeHtml(analyticsChannelLabel(state.drilldown.selectedLinkedInteraction.channel))}</strong></div>
<div><small>Очередь</small><strong>${escapeHtml(analyticsQueueName(state.drilldown.selectedLinkedInteraction.queue_id))}</strong></div>
<div><small>Статус</small><strong>${escapeHtml(interactionStatusLabel(state.drilldown.selectedLinkedInteraction.status))}</strong></div>
<div><small>Назначен</small><strong>${escapeHtml(state.drilldown.selectedLinkedInteraction.assigned_to || 'Не назначен')}</strong></div>
<div><small>Тема</small><strong>${escapeHtml(state.drilldown.selectedLinkedInteraction.subject || '—')}</strong></div>
<div><small>Обновлено</small><strong>${escapeHtml(formatTime(state.drilldown.selectedLinkedInteraction.updated_at))}</strong></div>
</div>
</div>
`
: '';
detail.innerHTML = `
<div class="analytics-drilldown-detail-card">
<div class="analytics-drilldown-detail-head">
<div>
<strong>${escapeHtml(selectedSession.session_id)}</strong>
<p class="hint">${escapeHtml(aiAnalyticsSliceLabel(state.drilldown.filters?.slice || 'all'))}</p>
</div>
<div class="analytics-card-top-meta">
<span class="analytics-filter-chip analytics-filter-chip-quiet">${escapeHtml(aiAnalyticsStatusLabel(selectedSession.status))}</span>
</div>
</div>
<div class="analytics-drilldown-metric-note">
<p class="hint">${escapeHtml(aiAnalyticsDrilldownNote(state.drilldown.filters))}</p>
</div>
${renderAiAnalyticsDrilldownFactBadges(selectedSession)}
<div class="analytics-drilldown-detail-grid">
<div><small>Канал</small><strong>${escapeHtml(analyticsChannelLabel(selectedSession.channel))}</strong></div>
<div><small>Очередь</small><strong>${escapeHtml(analyticsQueueName(selectedSession.queue_id))}</strong></div>
<div><small>ID обращения</small><strong>${escapeHtml(selectedSession.interaction_id || '—')}</strong></div>
<div><small>ID диалога</small><strong>${escapeHtml(selectedSession.thread_id || '—')}</strong></div>
<div><small>Назначено</small><strong>${escapeHtml(selectedSession.assigned_to || 'Не назначен')}</strong></div>
<div><small>Взял оператор</small><strong>${escapeHtml(selectedSession.claimed_by_user || '—')}</strong></div>
<div><small>Ответов AI</small><strong>${escapeHtml(String(selectedSession.assistant_turns || 0))}</strong></div>
<div><small>Сообщений клиента</small><strong>${escapeHtml(String(selectedSession.user_turns || 0))}</strong></div>
<div><small>Вызовов инструментов</small><strong>${escapeHtml(String(selectedSession.tool_turns || 0))}</strong></div>
<div><small>Средняя задержка</small><strong>${escapeHtml(formatAiAnalyticsMetric('ai_latency_avg_ms', selectedSession.ai_latency_avg_ms))}</strong></div>
<div><small>P95 задержки</small><strong>${escapeHtml(formatAiAnalyticsMetric('ai_latency_avg_ms', selectedSession.ai_latency_p95_ms))}</strong></div>
<div><small>Причина</small><strong>${escapeHtml(selectedSession.reason_label || selectedSession.raw_handoff_reason || '—')}</strong></div>
<div><small>Исходная причина</small><strong>${escapeHtml(selectedSession.raw_handoff_reason || '—')}</strong></div>
<div><small>Создано</small><strong>${escapeHtml(formatTime(selectedSession.created_at))}</strong></div>
<div><small>Обновлено</small><strong>${escapeHtml(formatTime(selectedSession.updated_at))}</strong></div>
<div><small>Закрыто</small><strong>${escapeHtml(formatTime(selectedSession.closed_at))}</strong></div>
</div>
</div>
${linkedInteractionMarkup}
<div class="analytics-drilldown-timeline">
${timelineMarkup}
</div>
`;
}
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) => `<span class="analytics-filter-chip">${escapeHtml(chip)}</span>`),
state.drilldown.notice
? `<span class="analytics-filter-chip analytics-filter-chip-quiet">${escapeHtml(state.drilldown.notice)}</span>`
: '',
].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 = '<div class="empty-state">Загружаем список обращений...</div>';
} else if (!state.drilldown.items.length) {
list.innerHTML = '<div class="empty-state">По этому срезу обращений пока нет.</div>';
} else {
list.innerHTML = state.drilldown.items
.map((item) => `
<button
class="analytics-drilldown-row${item.interaction_id === state.drilldown.selectedInteractionId ? ' selected' : ''}"
type="button"
data-analytics-interaction-id="${escapeHtml(item.interaction_id)}"
>
<div class="analytics-drilldown-row-top">
<strong>${escapeHtml(item.subject || item.interaction_id)}</strong>
<span class="analytics-drilldown-status analytics-drilldown-status-${escapeHtml(analyticsDrilldownStatusClass(item.status))}">${escapeHtml(interactionStatusLabel(item.status))}</span>
</div>
<div class="analytics-drilldown-row-meta">
<span>${escapeHtml(item.interaction_id)}</span>
<span class="analytics-drilldown-pill">${escapeHtml(analyticsChannelLabel(item.channel))}</span>
<span class="analytics-drilldown-pill">${escapeHtml(analyticsQueueName(item.queue_id))}</span>
</div>
${renderAnalyticsDrilldownFactBadges(item)}
<div class="analytics-drilldown-row-meta">
<span>Назначен: ${escapeHtml(item.assigned_to || 'Не назначен')}</span>
<span>${escapeHtml(formatTime(item.created_at))}</span>
</div>
</button>
`)
.join('');
}
const selectedInteraction = state.drilldown.selectedInteraction
|| state.drilldown.items.find((item) => item.interaction_id === state.drilldown.selectedInteractionId)
|| null;
if (!selectedInteraction) {
detail.innerHTML = '<div class="empty-state">Выберите обращение в списке, чтобы увидеть карточку и timeline.</div>';
return;
}
const timelineMarkup = state.drilldown.detailLoading
? '<div class="empty-state">Загружаем ленту событий...</div>'
: state.drilldown.selectedTimeline.length
? state.drilldown.selectedTimeline
.map((event) => `
${(() => {
const safeMetadata = sanitizeAnalyticsTimelineMetadata(event.metadata);
const metadataMarkup = Object.keys(safeMetadata).length
? `<pre>${escapeHtml(JSON.stringify(safeMetadata, null, 2))}</pre>`
: '<p class="hint">Только событие уровня метаданных: исходный текст, записи и транскрипты скрыты в analyst-экране.</p>';
return `
<div class="analytics-timeline-item">
<div class="analytics-timeline-meta">
<strong>${escapeHtml(event.action || 'событие')}</strong>
<span>${escapeHtml(formatTime(event.timestamp))}</span>
</div>
${metadataMarkup}
</div>
`;
})()}
`)
.join('')
: '<div class="empty-state">Лента событий по этому обращению пока пуста.</div>';
const metricContextMarkup = state.drilldown.mode === 'metric' && state.drilldown.metric
? `
<div class="analytics-drilldown-metric-note">
<p class="hint">${escapeHtml(state.drilldown.metricNote || '')}</p>
</div>
`
: '';
const factBadgesMarkup = renderAnalyticsDrilldownFactBadges(selectedInteraction);
const factFields = [
selectedInteraction.wait_seconds !== null && selectedInteraction.wait_seconds !== undefined
? `<div><small>Ожидание</small><strong>${escapeHtml(`${formatAnalyticsNumber(selectedInteraction.wait_seconds, 0)} с`)}</strong></div>`
: '',
selectedInteraction.handle_seconds !== null && selectedInteraction.handle_seconds !== undefined
? `<div><small>Обработка</small><strong>${escapeHtml(`${formatAnalyticsNumber(selectedInteraction.handle_seconds, 0)} с`)}</strong></div>`
: '',
selectedInteraction.within_sla !== null && selectedInteraction.within_sla !== undefined
? `<div><small>SLA</small><strong>${escapeHtml(selectedInteraction.within_sla ? 'В SLA' : 'Вне SLA')}</strong></div>`
: '',
selectedInteraction.resolved_first_contact !== null && selectedInteraction.resolved_first_contact !== undefined
? `<div><small>FCR</small><strong>${escapeHtml(selectedInteraction.resolved_first_contact ? 'Да' : 'Нет')}</strong></div>`
: '',
selectedInteraction.abandoned !== null && selectedInteraction.abandoned !== undefined
? `<div><small>Потеря</small><strong>${escapeHtml(selectedInteraction.abandoned ? 'Да' : 'Нет')}</strong></div>`
: '',
].filter(Boolean).join('');
detail.innerHTML = `
<div class="analytics-drilldown-detail-card">
<div class="analytics-drilldown-detail-head">
<div>
<strong>${escapeHtml(selectedInteraction.subject || selectedInteraction.interaction_id)}</strong>
<p class="hint">${escapeHtml(selectedInteraction.interaction_id)}</p>
</div>
<div class="analytics-card-top-meta">
${state.drilldown.mode === 'metric' && state.drilldown.metric ? analyticsMetricCoverageBadge(state.drilldown.metric) : ''}
<span class="analytics-filter-chip analytics-filter-chip-quiet">${escapeHtml(interactionStatusLabel(selectedInteraction.status))}</span>
</div>
</div>
${metricContextMarkup}
${factBadgesMarkup}
<div class="analytics-drilldown-detail-grid">
<div><small>Канал</small><strong>${escapeHtml(analyticsChannelLabel(selectedInteraction.channel))}</strong></div>
<div><small>Очередь</small><strong>${escapeHtml(analyticsQueueName(selectedInteraction.queue_id))}</strong></div>
<div><small>Назначен</small><strong>${escapeHtml(selectedInteraction.assigned_to || 'Не назначен')}</strong></div>
<div><small>Создано</small><strong>${escapeHtml(formatTime(selectedInteraction.created_at))}</strong></div>
<div><small>Обновлено</small><strong>${escapeHtml(formatTime(selectedInteraction.updated_at))}</strong></div>
<div><small>Срез</small><strong>${escapeHtml(state.drilldown.sourceLabel || 'Все обращения')}</strong></div>
${factFields}
</div>
</div>
<div class="analytics-drilldown-timeline">
${timelineMarkup}
</div>
`;
}
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}>
<div class="analytics-card-top">
<div class="summary-label">${label}</div>
${showCompare ? `<span class="analytics-delta analytics-delta-${delta.tone}">${delta.text}</span>` : ''}
</div>
<div class="summary-value">${formatAiAnalyticsMetric(metric, value)}</div>
${detail ? `<div class="summary-note">${detail}</div>` : ''}
${note ? `<div class="analytics-card-hint ai-analytics-card-hint">${note}</div>` : ''}
</${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 = '<div class="empty-state">Нет AI-сессий по Telegram и WhatsApp за выбранный период.</div>';
return;
}
container.innerHTML = `
<div class="analytics-table-head ai-analytics-channel-grid">
<span>Канал</span>
<span>Сессии</span>
<span>Только AI</span>
<span>С оператором</span>
<span>Передача</span>
<span>Средняя задержка</span>
</div>
${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}>
<span>${escapeHtml(analyticsChannelLabel(item.channel))}</span>
<span>${formatAnalyticsNumber(item.sessions_started, 0)}</span>
<span>${formatAnalyticsNumber(aiOnlyShare, 2)}%</span>
<span>${formatAnalyticsNumber(humanTouchedShare, 2)}%</span>
<span>${formatAnalyticsNumber(item.handoff_rate || 0, 2)}%</span>
<span>${formatAiAnalyticsMetric('ai_latency_avg_ms', item.ai_latency_avg_ms)}</span>
</${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 = `
<div class="ai-analytics-coverage-grid">
<div class="analytics-placeholder-card">
<strong>${formatAnalyticsNumber(coverage.sessions_with_interaction_id || 0, 0)}</strong>
<p>AI-сессий связаны с ID обращения</p>
</div>
<div class="analytics-placeholder-card">
<strong>${formatAnalyticsNumber(coverage.sessions_with_queue_id || 0, 0)}</strong>
<p>AI-сессий имеют ID очереди через обращение или диалог</p>
</div>
<div class="analytics-placeholder-card">
<strong>${formatAnalyticsNumber(coverage.sessions_with_latency_turns || 0, 0)}</strong>
<p>AI-сессий содержат данные по задержке ответов модели</p>
</div>
<div class="analytics-placeholder-card">
<strong>${formatAnalyticsNumber(coverage.sessions_with_terminal_state || 0, 0)}</strong>
<p>AI-сессий дошли до финального состояния</p>
</div>
<div class="analytics-placeholder-card">
<strong>${formatAnalyticsNumber(coverage.sessions_with_handoff_reason || 0, 0)}</strong>
<p>AI-сессий содержат причину передачи</p>
</div>
</div>
`;
}
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 = '<div class="empty-state">Срезы по исходам появятся, когда в окне будут AI-сессии.</div>';
return;
}
container.innerHTML = `
<div class="analytics-table-head ai-analytics-outcome-grid">
<span>Исход</span>
<span>Сессии</span>
<span>Доля</span>
</div>
${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}>
<span>${escapeHtml(item.label || aiAnalyticsSliceLabel(item.outcome))}</span>
<span>${formatAnalyticsNumber(item.sessions, 0)}</span>
<span>${formatAnalyticsNumber(item.share || 0, 2)}%</span>
</${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 = '<div class="empty-state">За выбранный период handoff reasons не найдены.</div>';
return;
}
container.innerHTML = `
<div class="analytics-table-head ai-analytics-reason-grid">
<span>Причина</span>
<span>Сессии</span>
<span>Доля</span>
</div>
${rows
.map((item) => `
<button
type="button"
class="analytics-table-row ai-analytics-reason-grid ai-analytics-row-button"
data-ai-reason-key="${escapeHtml(item.reason_key)}"
data-ai-reason-label="${escapeHtml(item.label || item.reason_key)}"
>
<span>${escapeHtml(item.label || item.reason_key)}</span>
<span>${formatAnalyticsNumber(item.sessions, 0)}</span>
<span>${formatAnalyticsNumber(item.share || 0, 2)}%</span>
</button>
`)
.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 = [
'<option value="all">Все статусы</option>',
'<option value="active">Активна</option>',
'<option value="closed">Закрыта</option>',
'<option value="handoff_required">Требуется передача</option>',
'<option value="human_owned">У оператора</option>',
'<option value="error">Ошибка</option>',
].join('');
statusSelect.value = isOpen ? (filters.status || 'all') : 'all';
statusSelect.disabled = !localFiltersEnabled || state.drilldown.loading || state.drilldown.exporting;
}
if (sortSelect) {
sortSelect.innerHTML = [
'<option value="created_at:desc">Новые сверху</option>',
'<option value="created_at:asc">Старые сверху</option>',
'<option value="updated_at:desc">Обновлённые сверху</option>',
'<option value="ai_latency_avg_ms:desc">Задержка сверху</option>',
'<option value="status:asc">Статус A-Z</option>',
].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();