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