qa-20260913-p2-repeat-ya-dan/index.html

570 lines
28 KiB
HTML
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.

<!DOCTYPE html>
<html lang="ru" data-theme="light" data-kt-data-mode="demo">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Контроль качества сервиса — Дашборд</title>
<link rel="stylesheet" href="./design-system/kt-ai-fonts.css">
<link rel="stylesheet" href="./design-system/kt-ai-tokens.css">
<link rel="stylesheet" href="./design-system/kt-ai-components.css">
<link rel="stylesheet" href="./design-system/kt-ai-page.css">
<style>
.kt-ai-app { min-height: 100vh; display: flex; flex-direction: column; }
.app-header { display: flex; align-items: center; gap: var(--kt-ai-space-4); }
.app-title { font-size: var(--kt-ai-text-lg); font-weight: var(--kt-ai-weight-semibold); }
.filters-panel { display: flex; gap: var(--kt-ai-space-4); flex-wrap: wrap; align-items: flex-end; margin-bottom: var(--kt-ai-space-6); }
.filter-group { display: flex; flex-direction: column; gap: var(--kt-ai-space-2); }
.filter-label { font-size: var(--kt-ai-text-xs); color: var(--kt-ai-fg-muted); }
.kpi-container { margin-bottom: var(--kt-ai-space-8); }
.chart-container { margin-bottom: var(--kt-ai-space-8); min-height: 220px; }
.table-container { margin-bottom: var(--ai-space-8); overflow-x: auto; }
.insights-container { display: grid; grid-template-columns: 1fr 1fr; gap: var(--kt-ai-space-6); }
.insights-card { padding: var(--kt-ai-space-6); }
.explanation-list { list-style: none; padding: 0; margin: 0; }
.explanation-list li { padding: var(--kt-ai-space-2) 0; border-bottom: 1px solid var(--kt-ai-border); }
.explanation-list li:last-child { border-bottom: none; }
.recommendation-item { display: flex; gap: var(--kt-ai-space-3); padding: var(--kt-ai-space-3) 0; }
.recommendation-icon { flex-shrink: 0; width: 20px; height: 20px; border-radius: var(--kt-ai-radius-md); background: var(--kt-ai-primary); color: white; display: flex; align-items: center; justify-content: center; font-size: var(--kt-ai-text-sm); font-weight: var(--kt-ai-weight-bold); }
.recommendation-text { font-size: var(--kt-ai-text-sm); color: var(--kt-ai-fg); }
.data-mode-badge { position: fixed; top: var(--kt-ai-space-4); right: var(--kt-ai-space-4); z-index: 100; }
.empty-state { text-align: center; padding: var(--kt-ai-space-12); color: var(--kt-ai-fg-muted); }
.empty-icon { font-size: 48px; margin-bottom: var(--kt-ai-space-4); opacity: 0.5; }
.hidden { display: none !important; }
[data-kt-data-state="empty"] [data-kt-region] { display: none; }
.chart-tip { position: absolute; background: var(--kt-ai-bg); border: 1px solid var(--kt-ai-border); border-radius: var(--kt-ai-radius-lg); padding: var(--kt-ai-space-2) var(--kt-ai-space-3); font-size: var(--kt-ai-text-xs); pointer-events: none; z-index: 1000; box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
</style>
</head>
<body class="kt-ai-app">
<header class="kt-ai-pagehead">
<div class="kt-ai-wrap kt-ai-pagehead-in">
<div class="app-header">
<img src="https://code.vibe42.kz/tore/aitu/kt-logo.svg" alt="Казахтелеком" height="28">
<span class="app-title">Контроль качества сервиса</span>
</div>
<span class="kt-ai-spacer"></span>
<span class="kt-ai-chip" data-tone="orange">Демо-данные</span>
</div>
</header>
<main class="kt-ai-wrap" style="padding-top: var(--kt-ai-space-8); padding-bottom: var(--kt-ai-space-8); flex: 1;">
<div class="filters-panel">
<div class="filter-group">
<label class="filter-label" for="segmentFilter">Сегмент</label>
<select id="segmentFilter" class="kt-ai-select" style="min-width: 160px;">
<option value="all">Все сегменты</option>
<option value="B2B">B2B</option>
<option value="B2C">B2C</option>
</select>
</div>
<div class="filter-group">
<label class="filter-label" for="channelFilter">Канал</label>
<select id="channelFilter" class="kt-ai-select" style="min-width: 160px;">
<option value="all">Все каналы</option>
<option value="callcenter">Колл-центр</option>
<option value="chat">Онлайн-чат</option>
<option value="office">Офис</option>
<option value="social">Соцсети</option>
</select>
</div>
<div class="filter-group">
<label class="filter-label" for="periodFilter">Период</label>
<select id="periodFilter" class="kt-ai-select" style="min-width: 160px;">
<option value="7d">7 дней</option>
<option value="14d">14 дней</option>
<option value="30d">30 дней</option>
<option value="quarter">Квартал</option>
</select>
</div>
<div class="filter-group" style="margin-left: auto;">
<label class="filter-label">Демо-режим</label>
<span style="font-size: var(--kt-ai-text-xs); color: var(--kt-ai-fg-muted);">в демо не меняет источник</span>
</div>
</div>
<div data-kt-region="kpi" class="kpi-container">
<div class="kt-ai-kpi-strip" id="kpiStrip"></div>
</div>
<div data-kt-region="chart" class="chart-container">
<div class="kt-ai-card" style="padding: var(--kt-ai-space-6);">
<h3 style="margin-bottom: var(--kt-ai-space-4); font-size: var(--kt-ai-text-lg);">Динамика показателей</h3>
<div id="chartArea" style="position: relative; height: 220px;"></div>
</div>
</div>
<div data-kt-region="table" class="table-container">
<div class="kt-ai-card" style="padding: var(--kt-ai-space-6);">
<h3 style="margin-bottom: var(--kt-ai-space-4); font-size: var(--kt-ai-text-lg);">Детальные данные</h3>
<div class="kt-ai-table-wrap">
<table class="kt-ai-table">
<thead>
<tr>
<th>Дата</th>
<th>Сегмент</th>
<th>Канал</th>
<th style="text-align: right;">NPS</th>
<th style="text-align: right;">CSI</th>
<th style="text-align: right;">FCR</th>
<th style="text-align: right;">AHT, сек</th>
<th style="text-align: right;">СЛ, %</th>
</tr>
</thead>
<tbody id="tableBody"></tbody>
</table>
</div>
</div>
</div>
<div data-kt-region="insights" class="insights-container">
<div class="insights-card kt-ai-card">
<h3 style="margin-bottom: var(--kt-ai-space-4); font-size: var(--kt-ai-text-lg);">Объяснение отклонений</h3>
<ul class="explanation-list" id="explanationList"></ul>
</div>
<div data-kt-region="recommendations" class="insights-card kt-ai-card">
<h3 style="margin-bottom: var(--kt-ai-space-4); font-size: var(--kt-ai-text-lg);">Рекомендации</h3>
<div id="recommendationsList"></div>
</div>
</div>
<div data-kt-region="empty" class="empty-state hidden" id="emptyState">
<div class="empty-icon">📊</div>
<h3 style="margin-bottom: var(--kt-ai-space-2);">Недостаточно данных</h3>
<p>По выбранным фильтрам нет данных для отображения</p>
</div>
</main>
<footer class="kt-ai-wrap kt-ai-pagefoot">
<span style="color: var(--kt-ai-fg-muted); font-size: var(--kt-ai-text-sm);">© 2026 Казахтелеком • Контроль качества сервиса</span>
<span class="kt-ai-spacer"></span>
<span style="color: var(--kt-ai-fg-muted); font-size: var(--kt-ai-text-sm);">Демо-прототип</span>
</footer>
<div class="chart-tip" id="chartTip" style="display: none;"></div>
<script>
const DATA = [
{ date: "2025-12-01", segment: "B2B", channel: "callcenter", nps: 72, csi: 85, fcr: 78, aht: 245, sl: 82 },
{ date: "2025-12-02", segment: "B2B", channel: "callcenter", nps: 74, csi: 86, fcr: 80, aht: 238, sl: 84 },
{ date: "2025-12-03", segment: "B2B", channel: "callcenter", nps: 71, csi: 84, fcr: 77, aht: 252, sl: 80 },
{ date: "2025-12-04", segment: "B2B", channel: "callcenter", nps: 76, csi: 88, fcr: 82, aht: 230, sl: 86 },
{ date: "2025-12-05", segment: "B2B", channel: "callcenter", nps: 73, csi: 85, fcr: 79, aht: 242, sl: 83 },
{ date: "2025-12-06", segment: "B2B", channel: "callcenter", nps: 75, csi: 87, fcr: 81, aht: 235, sl: 85 },
{ date: "2025-12-07", segment: "B2B", channel: "callcenter", nps: 77, csi: 89, fcr: 83, aht: 228, sl: 87 },
{ date: "2025-12-01", segment: "B2B", channel: "chat", nps: 68, csi: 82, fcr: 74, aht: 180, sl: 78 },
{ date: "2025-12-02", segment: "B2B", channel: "chat", nps: 70, csi: 83, fcr: 76, aht: 175, sl: 80 },
{ date: "2025-12-03", segment: "B2B", channel: "chat", nps: 67, csi: 81, fcr: 73, aht: 185, sl: 76 },
{ date: "2025-12-04", segment: "B2B", channel: "chat", nps: 72, csi: 84, fcr: 78, aht: 170, sl: 82 },
{ date: "2025-12-05", segment: "B2B", channel: "chat", nps: 69, csi: 82, fcr: 75, aht: 178, sl: 79 },
{ date: "2025-12-06", segment: "B2B", channel: "chat", nps: 71, csi: 83, fcr: 77, aht: 172, sl: 81 },
{ date: "2025-12-07", segment: "B2B", channel: "chat", nps: 73, csi: 85, fcr: 79, aht: 168, sl: 83 },
{ date: "2025-12-01", segment: "B2C", channel: "callcenter", nps: 65, csi: 78, fcr: 70, aht: 290, sl: 72 },
{ date: "2025-12-02", segment: "B2C", channel: "callcenter", nps: 67, csi: 79, fcr: 72, aht: 285, sl: 74 },
{ date: "2025-12-03", segment: "B2C", channel: "callcenter", nps: 63, csi: 76, fcr: 68, aht: 298, sl: 70 },
{ date: "2025-12-04", segment: "B2C", channel: "callcenter", nps: 69, csi: 81, fcr: 74, aht: 275, sl: 76 },
{ date: "2025-12-05", segment: "B2C", channel: "callcenter", nps: 66, csi: 78, fcr: 71, aht: 288, sl: 73 },
{ date: "2025-12-06", segment: "B2C", channel: "callcenter", nps: 68, csi: 80, fcr: 73, aht: 280, sl: 75 },
{ date: "2025-12-07", segment: "B2C", channel: "callcenter", nps: 70, csi: 82, fcr: 75, aht: 272, sl: 77 },
{ date: "2025-12-01", segment: "B2C", channel: "chat", nps: 60, csi: 74, fcr: 65, aht: 195, sl: 68 },
{ date: "2025-12-02", segment: "B2C", channel: "chat", nps: 62, csi: 75, fcr: 67, aht: 190, sl: 70 },
{ date: "2025-12-03", segment: "B2C", channel: "chat", nps: 58, csi: 72, fcr: 63, aht: 200, sl: 66 },
{ date: "2025-12-04", segment: "B2C", channel: "chat", nps: 64, csi: 77, fcr: 69, aht: 185, sl: 72 },
{ date: "2025-12-05", segment: "B2C", channel: "chat", nps: 61, csi: 74, fcr: 66, aht: 193, sl: 69 },
{ date: "2025-12-06", segment: "B2C", channel: "chat", nps: 63, csi: 76, fcr: 68, aht: 188, sl: 71 },
{ date: "2025-12-07", segment: "B2C", channel: "chat", nps: 65, csi: 78, fcr: 70, aht: 182, sl: 73 },
{ date: "2025-12-01", segment: "B2C", channel: "office", nps: 78, csi: 90, fcr: 85, aht: 420, sl: 92 },
{ date: "2025-12-02", segment: "B2C", channel: "office", nps: 79, csi: 91, fcr: 86, aht: 410, sl: 93 },
{ date: "2025-12-03", segment: "B2C", channel: "office", nps: 77, csi: 89, fcr: 84, aht: 430, sl: 91 },
{ date: "2025-12-04", segment: "B2C", channel: "office", nps: 81, csi: 93, fcr: 88, aht: 400, sl: 95 },
{ date: "2025-12-05", segment: "B2C", channel: "office", nps: 78, csi: 90, fcr: 85, aht: 415, sl: 92 },
{ date: "2025-12-06", segment: "B2C", channel: "office", nps: 80, csi: 92, fcr: 87, aht: 405, sl: 94 },
{ date: "2025-12-07", segment: "B2C", channel: "office", nps: 82, csi: 94, fcr: 89, aht: 395, sl: 96 },
{ date: "2025-12-01", segment: "B2B", channel: "office", nps: 80, csi: 92, fcr: 87, aht: 380, sl: 90 },
{ date: "2025-12-02", segment: "B2B", channel: "office", nps: 82, csi: 93, fcr: 89, aht: 370, sl: 92 },
{ date: "2025-12-03", segment: "B2B", channel: "office", nps: 79, csi: 91, fcr: 86, aht: 385, sl: 89 },
{ date: "2025-12-04", segment: "B2B", channel: "office", nps: 84, csi: 95, fcr: 91, aht: 360, sl: 94 },
{ date: "2025-12-05", segment: "B2B", channel: "office", nps: 81, csi: 92, fcr: 88, aht: 375, sl: 91 },
{ date: "2025-12-06", segment: "B2B", channel: "office", nps: 83, csi: 94, fcr: 90, aht: 365, sl: 93 },
{ date: "2025-12-07", segment: "B2B", channel: "office", nps: 85, csi: 96, fcr: 92, aht: 355, sl: 95 },
{ date: "2025-12-01", segment: "B2C", channel: "social", nps: 55, csi: 68, fcr: 58, aht: 150, sl: 62 },
{ date: "2025-12-02", segment: "B2C", channel: "social", nps: 57, csi: 69, fcr: 60, aht: 145, sl: 64 },
{ date: "2025-12-03", segment: "B2C", channel: "social", nps: 53, csi: 66, fcr: 56, aht: 155, sl: 60 },
{ date: "2025-12-04", segment: "B2C", channel: "social", nps: 59, csi: 71, fcr: 62, aht: 140, sl: 66 },
{ date: "2025-12-05", segment: "B2C", channel: "social", nps: 56, csi: 68, fcr: 59, aht: 148, sl: 63 },
{ date: "2025-12-06", segment: "B2C", channel: "social", nps: 58, csi: 70, fcr: 61, aht: 143, sl: 65 },
{ date: "2025-12-07", segment: "B2C", channel: "social", nps: 60, csi: 72, fcr: 63, aht: 138, sl: 67 },
{ date: "2025-12-01", segment: "B2B", channel: "social", nps: 62, csi: 75, fcr: 65, aht: 160, sl: 68 },
{ date: "2025-12-02", segment: "B2B", channel: "social", nps: 64, csi: 76, fcr: 67, aht: 155, sl: 70 },
{ date: "2025-12-03", segment: "B2B", channel: "social", nps: 60, csi: 74, fcr: 63, aht: 165, sl: 66 },
{ date: "2025-12-04", segment: "B2B", channel: "social", nps: 66, csi: 78, fcr: 69, aht: 150, sl: 72 },
{ date: "2025-12-05", segment: "B2B", channel: "social", nps: 63, csi: 75, fcr: 66, aht: 158, sl: 69 },
{ date: "2025-12-06", segment: "B2B", channel: "social", nps: 65, csi: 77, fcr: 68, aht: 153, sl: 71 },
{ date: "2025-12-07", segment: "B2B", channel: "social", nps: 67, csi: 79, fcr: 70, aht: 148, sl: 73 }
];
const FILTERS = [
{ id: "segmentFilter", label: "Сегмент", affects: ["table", "kpi", "chart", "insights"] },
{ id: "channelFilter", label: "Канал", affects: ["table", "kpi", "chart", "insights"] },
{ id: "periodFilter", label: "Период", affects: ["table", "kpi", "chart", "insights"] }
];
function getDaysForPeriod(period) {
const map = { "7d": 7, "14d": 14, "30d": 30, "quarter": 90 };
return map[period] || 7;
}
function filterData(segment, channel, period) {
let filtered = [...DATA];
if (segment !== "all") filtered = filtered.filter(r => r.segment === segment);
if (channel !== "all") filtered = filtered.filter(r => r.channel === channel);
const days = getDaysForPeriod(period);
const endDate = new Date("2025-12-07");
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - days + 1);
filtered = filtered.filter(r => {
const d = new Date(r.date);
return d >= startDate && d <= endDate;
});
return filtered;
}
function calculateKPIs(rows) {
if (rows.length === 0) return null;
const sum = rows.reduce((acc, r) => ({
nps: acc.nps + r.nps,
csi: acc.csi + r.csi,
fcr: acc.fcr + r.fcr,
aht: acc.aht + r.aht,
sl: acc.sl + r.sl
}), { nps: 0, csi: 0, fcr: 0, aht: 0, sl: 0 });
const count = rows.length;
return {
nps: Math.round(sum.nps / count),
csi: Math.round(sum.csi / count),
fcr: Math.round(sum.fcr / count),
aht: Math.round(sum.aht / count),
sl: Math.round(sum.sl / count)
};
}
function getTargetValues() {
return { nps: 75, csi: 85, fcr: 80, aht: 240, sl: 85 };
}
function calculateChange(current, previous) {
if (previous === 0) return 0;
return Math.round(((current - previous) / previous) * 100);
}
function getViewForState(state) {
const rows = filterData(state.segment, state.channel, state.period);
if (rows.length === 0) {
return { status: "empty", rows: [], kpis: null, series: [], explanation: [], recommendations: [] };
}
const kpis = calculateKPIs(rows);
const targets = getTargetValues();
const prevPeriod = state.period === "7d" ? "14d" : (state.period === "14d" ? "30d" : "30d");
const prevPeriodRows = DATA.filter(r => {
if (state.segment !== "all" && r.segment !== state.segment) return false;
if (state.channel !== "all" && r.channel !== state.channel) return false;
const days = getDaysForPeriod(prevPeriod);
const endDate = new Date("2025-11-30");
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - days + 1);
const d = new Date(r.date);
return d >= startDate && d <= endDate;
});
const prevKPIs = calculateKPIs(prevPeriodRows);
const series = [];
const dateGroups = {};
rows.forEach(r => {
if (!dateGroups[r.date]) dateGroups[r.date] = [];
dateGroups[r.date].push(r);
});
Object.keys(dateGroups).sort().forEach(date => {
const dayRows = dateGroups[date];
const dayKPIs = calculateKPIs(dayRows);
series.push({ date, ...dayKPIs });
});
const explanation = [];
if (kpis.nps < targets.nps) {
const deficit = targets.nps - kpis.nps;
explanation.push({ metric: "NPS", text: `NPS ниже цели на ${deficit} п.п. — вероятная причина: длительное время ожидания в канале ${state.channel === "all" ? "колл-центр" : state.channel === "callcenter" ? "колл-центр" : state.channel}`, severity: "risk" });
}
if (kpis.fcr < targets.fcr) {
const deficit = targets.fcr - kpis.fcr;
explanation.push({ metric: "FCR", text: `FCR ниже цели на ${deficit} п.п. — гипотеза: недостаточно обучения операторов по сложным запросам`, severity: "warn" });
}
if (kpis.aht > targets.aht) {
const excess = kpis.aht - targets.aht;
explanation.push({ metric: "AHT", text: `Среднее время обработки выше на ${excess} сек — возможна избыточная процедура верификации`, severity: "warn" });
}
if (explanation.length === 0) {
explanation.push({ metric: "Общее", text: "Все показатели в пределах целевых значений", severity: "ok" });
}
const recommendations = [];
if (kpis.nps < targets.nps) {
recommendations.push({ text: "Внедрить обратную связь сразу после обращения — это повысит лояльность на 5-8 п.п.", priority: 1, status: "не подтверждено" });
}
if (kpis.fcr < targets.fcr) {
recommendations.push({ text: "Запустить программу наставничества для новых операторов — цель: FCR +10%", priority: 2, status: "предложение" });
}
if (kpis.aht > targets.aht) {
recommendations.push({ text: "Автоматизировать верификацию через SMS-код — сократит AHT на 30-40 сек", priority: 3, status: "не подтверждено" });
}
if (recommendations.length === 0) {
recommendations.push({ text: "Поддерживать текущие показатели, масштабировать лучшие практики", priority: 1, status: "измерено" });
}
return { status: "ok", rows, kpis, series, explanation, recommendations };
}
function renderKPIs(kpis, previousKPIs) {
const container = document.getElementById("kpiStrip");
if (!kpis) {
container.innerHTML = "";
return;
}
const targets = getTargetValues();
const metrics = [
{ key: "nps", label: "NPS", format: v => v, suffix: "" },
{ key: "csi", label: "CSI", format: v => v, suffix: "" },
{ key: "fcr", label: "FCR", format: v => v, suffix: "%" },
{ key: "aht", label: "AHT", format: v => v, suffix: " сек" },
{ key: "sl", label: "SL", format: v => v, suffix: "%" }
];
container.innerHTML = metrics.map(m => {
const value = kpis[m.key];
const target = targets[m.key];
const prev = previousKPIs ? previousKPIs[m.key] : null;
const change = prev ? calculateChange(value, prev) : null;
const isBetter = m.key === "aht" ? value <= target : value >= target;
const statusClass = isBetter ? "ok" : (Math.abs(value - target) < 5 ? "warn" : "risk");
return `
<div class="kt-ai-kpi" data-status="${statusClass}">
<div class="value">${m.format(value)}${m.suffix}</div>
<div class="label">${m.label}</div>
<div class="hint">Цель: ${m.format(target)}${m.suffix}${change !== null ? (change >= 0 ? " ▲" : " ▼") : ""}</div>
</div>
`;
}).join("");
}
function renderChart(series) {
const container = document.getElementById("chartArea");
if (!series || series.length === 0) {
container.innerHTML = "";
return;
}
const height = 220;
const padding = { top: 20, right: 20, bottom: 40, left: 50 };
const chartWidth = container.clientWidth - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
const maxValue = Math.max(...series.map(s => s.nps), 100);
const barWidth = Math.min(28, (chartWidth / series.length) - 8);
const gap = (chartWidth - (barWidth * series.length)) / (series.length + 1);
let svg = `<svg width="100%" height="${height}" viewBox="0 0 ${container.clientWidth} ${height}" style="overflow: visible;">`;
svg += `<line x1="${padding.left}" y1="${padding.top + chartHeight}" x2="${container.clientWidth - padding.right}" y2="${padding.top + chartHeight}" stroke="var(--kt-ai-border)" stroke-width="1"/>`;
const targetLine = Math.round((75 / maxValue) * chartHeight);
svg += `<line x1="${padding.left}" y1="${padding.top + chartHeight - targetLine}" x2="${container.clientWidth - padding.right}" y2="${padding.top + chartHeight - targetLine}" stroke="var(--kt-ai-primary)" stroke-width="2" stroke-dasharray="4,4"/>`;
svg += `<text x="${container.clientWidth - padding.right}" y="${padding.top + chartHeight - targetLine - 5}" text-anchor="end" font-size="10" fill="var(--kt-ai-fg-muted)">Цель NPS: 75</text>`;
series.forEach((point, i) => {
const x = padding.left + gap + i * (barWidth + gap);
const barHeight = (point.nps / maxValue) * chartHeight;
const y = padding.top + chartHeight - barHeight;
const colorVar = point.nps >= 75 ? "--kt-ai-chart-green" : (point.nps >= 65 ? "--kt-ai-chart-orange" : "--kt-ai-chart-red");
svg += `<rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" rx="2" fill="var(${colorVar})" data-kt-tip="${point.date}: NPS ${point.nps}" class="chart-bar"/>`;
const shortDate = point.date.slice(5);
svg += `<text x="${x + barWidth / 2}" y="${padding.top + chartHeight + 20}" text-anchor="middle" font-size="10" fill="var(--kt-ai-fg-muted)">${shortDate}</text>`;
});
svg += `</svg>`;
container.innerHTML = svg;
container.querySelectorAll(".chart-bar").forEach(bar => {
bar.addEventListener("mouseenter", (e) => {
const tip = document.getElementById("chartTip");
const rect = bar.getBoundingClientRect();
tip.textContent = bar.getAttribute("data-kt-tip");
tip.style.display = "block";
tip.style.left = (rect.left + rect.width / 2 - tip.offsetWidth / 2) + "px";
tip.style.top = (rect.top - tip.offsetHeight - 8) + "px";
});
bar.addEventListener("mouseleave", () => {
document.getElementById("chartTip").style.display = "none";
});
});
}
function renderTable(rows) {
const tbody = document.getElementById("tableBody");
if (!rows || rows.length === 0) {
tbody.innerHTML = "";
return;
}
const displayRows = rows.slice(-10);
tbody.innerHTML = displayRows.map(r => `
<tr>
<td>${r.date}</td>
<td>${r.segment}</td>
<td>${r.channel === "callcenter" ? "Колл-центр" : r.channel === "chat" ? "Онлайн-чат" : r.channel === "office" ? "Офис" : "Соцсети"}</td>
<td style="text-align: right;">${r.nps}</td>
<td style="text-align: right;">${r.csi}</td>
<td style="text-align: right;">${r.fcr}%</td>
<td style="text-align: right;">${r.aht}</td>
<td style="text-align: right;">${r.sl}%</td>
</tr>
`).join("");
}
function renderExplanation(explanation) {
const list = document.getElementById("explanationList");
if (!explanation || explanation.length === 0) {
list.innerHTML = "";
return;
}
list.innerHTML = explanation.map(item => `
<li style="color: var(--kt-ai-${item.severity === "ok" ? "fg" : (item.severity === "warn" ? "chart-orange" : "chart-red")});">
<strong>${item.metric}:</strong> ${item.text}
</li>
`).join("");
}
function renderRecommendations(recommendations) {
const container = document.getElementById("recommendationsList");
if (!recommendations || recommendations.length === 0) {
container.innerHTML = "";
return;
}
container.innerHTML = recommendations.map((rec, i) => `
<div class="recommendation-item">
<div class="recommendation-icon">${i + 1}</div>
<div>
<div class="recommendation-text">${rec.text}</div>
<span class="kt-ai-chip" style="margin-top: var(--kt-ai-space-2); font-size: var(--kt-ai-text-xs);" data-tone="grey">${rec.status}</span>
</div>
</div>
`).join("");
}
function render(view) {
const dataState = view.status;
document.documentElement.dataset.ktDataState = dataState;
if (dataState === "empty") {
document.getElementById("emptyState").classList.remove("hidden");
document.getElementById("kpiStrip").innerHTML = "";
document.getElementById("chartArea").innerHTML = "";
document.getElementById("tableBody").innerHTML = "";
document.getElementById("explanationList").innerHTML = "";
document.getElementById("recommendationsList").innerHTML = "";
return;
}
document.getElementById("emptyState").classList.add("hidden");
const prevPeriod = currentState.period === "7d" ? "14d" : (currentState.period === "14d" ? "30d" : "30d");
const prevPeriodRows = DATA.filter(r => {
if (currentState.segment !== "all" && r.segment !== currentState.segment) return false;
if (currentState.channel !== "all" && r.channel !== currentState.channel) return false;
const days = getDaysForPeriod(prevPeriod);
const endDate = new Date("2025-11-30");
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - days + 1);
const d = new Date(r.date);
return d >= startDate && d <= endDate;
});
const prevKPIs = calculateKPIs(prevPeriodRows);
renderKPIs(view.kpis, prevKPIs);
renderChart(view.series);
renderTable(view.rows);
renderExplanation(view.explanation);
renderRecommendations(view.recommendations);
}
function updateFromFilters() {
const view = getViewForState(currentState);
render(view);
FILTERS.forEach(f => {
const value = currentState[f.id.replace("Filter", "")];
window.localStorage.setItem(f.id, value);
});
}
let currentState = {
segment: "all",
channel: "all",
period: "7d"
};
function loadState() {
const savedSegment = window.localStorage.getItem("segmentFilter");
const savedChannel = window.localStorage.getItem("channelFilter");
const savedPeriod = window.localStorage.getItem("periodFilter");
if (savedSegment && document.getElementById("segmentFilter").querySelector(`option[value="${savedSegment}"]`)) {
currentState.segment = savedSegment;
}
if (savedChannel && document.getElementById("channelFilter").querySelector(`option[value="${savedChannel}"]`)) {
currentState.channel = savedChannel;
}
if (savedPeriod && document.getElementById("periodFilter").querySelector(`option[value="${savedPeriod}"]`)) {
currentState.period = savedPeriod;
}
document.getElementById("segmentFilter").value = currentState.segment;
document.getElementById("channelFilter").value = currentState.channel;
document.getElementById("periodFilter").value = currentState.period;
}
document.getElementById("segmentFilter").addEventListener("change", (e) => {
currentState.segment = e.target.value;
updateFromFilters();
});
document.getElementById("channelFilter").addEventListener("change", (e) => {
currentState.channel = e.target.value;
updateFromFilters();
});
document.getElementById("periodFilter").addEventListener("change", (e) => {
currentState.period = e.target.value;
updateFromFilters();
});
loadState();
updateFromFilters();
</script>
</body>
</html>