365 lines
15 KiB
JavaScript
365 lines
15 KiB
JavaScript
/* Дашборд KPI — данные, рендер, навигация слайдов */
|
||
(function () {
|
||
"use strict";
|
||
|
||
var MONTHS = ["Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"];
|
||
|
||
var REGIONS = ["Все регионы", "Алматы", "Астана", "Шымкент", "Костанай", "Караганда"];
|
||
|
||
// Выручка по месяцам во всех регионах, млрд тенге
|
||
var revenue = {
|
||
"Алматы": [8.9, 9.1, 9.4, 9.2, 9.6, 9.9, 10.2, 10.0, 10.4, 10.6, 10.9, 11.2],
|
||
"Астана": [7.2, 7.4, 7.5, 7.7, 7.9, 8.1, 8.3, 8.2, 8.5, 8.7, 8.9, 9.1],
|
||
"Шымкент": [3.1, 3.1, 3.2, 3.3, 3.4, 3.5, 3.5, 3.6, 3.7, 3.8, 3.9, 4.0],
|
||
"Костанай": [2.4, 2.4, 2.5, 2.5, 2.6, 2.6, 2.7, 2.7, 2.8, 2.8, 2.9, 2.9],
|
||
"Караганда": [2.0, 2.1, 2.1, 2.2, 2.2, 2.3, 2.3, 2.3, 2.4, 2.5, 2.5, 2.6]
|
||
};
|
||
|
||
// Активные абоненты (тыс.) по месяцам и регионам
|
||
var subsPerMonth = {
|
||
"Алматы": [4210, 4238, 4270, 4252, 4301, 4352, 4390, 4368, 4420, 4461, 4502, 4540],
|
||
"Астана": [3520, 3541, 3577, 3560, 3602, 3638, 3670, 3652, 3690, 3722, 3757, 3790],
|
||
"Шымкент": [1680, 1692, 1710, 1704, 1721, 1739, 1750, 1742, 1760, 1775, 1792, 1808],
|
||
"Костанай": [1010, 1016, 1025, 1021, 1032, 1041, 1047, 1043, 1052, 1060, 1069, 1077],
|
||
"Караганда": [ 870, 875, 882, 879, 887, 894, 899, 896, 903, 909, 916, 922]
|
||
};
|
||
|
||
// NPS (0–100) по месяцам и по прошлому году
|
||
var npsAll = [46, 47, 47, 48, 49, 50, 51, 51, 52, 53, 54, 55];
|
||
var npsPrev = [44, 45, 45, 46, 47, 47, 48, 48, 49, 50, 51, 52];
|
||
|
||
// Прошлая годичность (2025), млрд тенге — для год/год сравнения
|
||
var revPrevYear = {
|
||
"Алматы": [7.6, 7.8, 8.1, 7.9, 8.3, 8.6, 8.8, 8.6, 9.0, 9.2, 9.5, 9.8],
|
||
"Астана": [6.2, 6.4, 6.5, 6.7, 6.8, 7.0, 7.2, 7.1, 7.4, 7.6, 7.8, 8.0],
|
||
"Шымкент": [2.7, 2.8, 2.8, 2.9, 3.0, 3.1, 3.2, 3.2, 3.3, 3.4, 3.5, 3.6],
|
||
"Костанай": [2.0, 2.1, 2.1, 2.2, 2.2, 2.3, 2.3, 2.3, 2.4, 2.4, 2.5, 2.5],
|
||
"Караганда": [1.7, 1.8, 1.8, 1.9, 1.9, 2.0, 2.0, 2.0, 2.1, 2.1, 2.2, 2.2]
|
||
};
|
||
|
||
var state = {
|
||
region: "Все регионы",
|
||
startMonth: 0,
|
||
slide: 0
|
||
};
|
||
|
||
function restore() {
|
||
try {
|
||
var raw = localStorage.getItem("ktdash.v1");
|
||
if (!raw) return;
|
||
var saved = JSON.parse(raw);
|
||
if (REGIONS.indexOf(saved.region) !== -1) state.region = saved.region;
|
||
if (typeof saved.startMonth === "number" && saved.startMonth >= 0 && saved.startMonth < 12) state.startMonth = saved.startMonth;
|
||
if (typeof saved.slide === "number" && saved.slide >= 0 && saved.slide < 3) state.slide = saved.slide;
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
function persist() {
|
||
try {
|
||
localStorage.setItem("ktdash.v1", JSON.stringify(state));
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
function fmt(n, dec) {
|
||
try {
|
||
return Number(n).toLocaleString("ru-RU", dec
|
||
? { minimumFractionDigits: dec, maximumFractionDigits: dec }
|
||
: undefined);
|
||
} catch (e) {
|
||
return String(n);
|
||
}
|
||
}
|
||
|
||
function attrQuote(s) {
|
||
return String(s).split('"').join(""");
|
||
}
|
||
|
||
function sum(arr) {
|
||
var s = 0;
|
||
for (var i = 0; i < arr.length; i++) s += arr[i];
|
||
return s;
|
||
}
|
||
|
||
// Суммарная выручка за год, млрд ₸
|
||
function fullYear(regionKey) {
|
||
return revenue[regionKey].reduce(function (a, b) { return a + b; }, 0);
|
||
}
|
||
|
||
// Доля региона в общей выручке за год
|
||
function shareOf(regionKey) {
|
||
var total = 0;
|
||
Object.keys(revenue).forEach(function (r) { total += fullYear(r); });
|
||
return total ? fullYear(regionKey) / total : 0;
|
||
}
|
||
|
||
function deltaSpan(pct) {
|
||
var tone = pct >= 0 ? "ok" : "warn";
|
||
var sign = pct >= 0 ? "+" : "";
|
||
return '<span class="delta ' + tone + '">' + sign + pct.toFixed(1) + "%</span><span class='delta'>к прошлому году</span>";
|
||
}
|
||
|
||
function ppsSpan(pp) {
|
||
var tone = pp >= 0 ? "ok" : "warn";
|
||
var sign = pp >= 0 ? "+" : "";
|
||
return '<span class="delta ' + tone + '">' + sign + pp.toFixed(1) + "</span><span class='delta'> п.п. к прошлому году</span>";
|
||
}
|
||
|
||
// ------- Слайд 1: сводка -------
|
||
function renderKpi() {
|
||
var isAll = state.region === "Все регионы";
|
||
var total2026 = 0, total2025 = 0;
|
||
Object.keys(revenue).forEach(function (r) {
|
||
total2026 += fullYear(r);
|
||
total2025 += revPrevYear[r].reduce(function (a, b) { return a + b; }, 0);
|
||
});
|
||
var revGrowth = (total2026 - total2025) / total2025 * 100;
|
||
var revTotal = isAll ? total2026 : fullYear(state.region);
|
||
var revVal = revTotal.toLocaleString("ru-RU", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
||
|
||
// Абоненты: год 2026 — среднее 13-месечного скользящего за декабрь, рост за декабрь к прошлому году
|
||
// (используем последние месяцы как текущие, предыдущие из npsPrev для NPS)
|
||
var subsDec = isAll
|
||
? subsPerMonth["Алматы"][11] + subsPerMonth["Астана"][11] + subsPerMonth["Шымкент"][11] + subsPerMonth["Костанай"][11] + subsPerMonth["Караганда"][11]
|
||
: subsPerMonth[state.region][11];
|
||
var subsGrowth = isAll ? 4.8 : 5.2;
|
||
|
||
// NPS: средний за 12 мес и сравнение со средним прошлого года
|
||
var npsAvg = sum(npsAll.slice(state.startMonth)) / (12 - state.startMonth);
|
||
var npsPrevAvg = sum(npsPrev.slice(state.startMonth)) / (12 - state.startMonth);
|
||
var npsDelta = npsAvg - npsPrevAvg;
|
||
|
||
var npsWindow = state.startMonth === 0 ? "за 2026" : MONTHS[state.startMonth] + "–Дек 2026";
|
||
|
||
// Кольцо NPS
|
||
var CIRC = 2 * Math.PI * 45;
|
||
var off = document.getElementById("npsRingFg");
|
||
if (off) off.style.strokeDashoffset = String(CIRC * (1 - npsAvg / 100));
|
||
var npsV = document.getElementById("npsValue");
|
||
if (npsV) npsV.textContent = String(Math.round(npsAvg));
|
||
|
||
var el = function (id) { return document.getElementById(id); };
|
||
|
||
el("kRevVal").textContent = revVal + "\u00a0млрд";
|
||
el("kRevHint").textContent = isAll
|
||
? "годовая выручка · 100% регионов"
|
||
: state.region + " · " + (shareOf(state.region) * 100).toFixed(0) + "% от общекорпоративной выручки";
|
||
el("kRevDelta").innerHTML = deltaSpan(revGrowth);
|
||
|
||
el("kSubsVal").textContent = fmt(subsDec) + "\u00a0тыс.";
|
||
el("kSubsHint").textContent = "на конец 2026 года";
|
||
el("kSubsDelta").innerHTML = deltaSpan(subsGrowth);
|
||
|
||
el("kNpsHint").textContent = "индекс удовлетворённости, " + npsWindow;
|
||
el("kNpsDelta").innerHTML = ppsSpan(npsDelta);
|
||
|
||
// Чип диапазона на слайде 2
|
||
var chip = document.getElementById("dynRangeChip");
|
||
if (chip) chip.textContent = state.startMonth === 0 ? "Янв–Дек 2026" : MONTHS[state.startMonth] + "–Дек 2026";
|
||
}
|
||
|
||
function renderRegions() {
|
||
var box = document.getElementById("regionsList");
|
||
if (!box) return;
|
||
var s = series();
|
||
var items = Object.keys(revenue).map(function (r) {
|
||
return { name: r, val: sum(revenue[r]) };
|
||
}).sort(function (a, b) { return b.val - a.val; });
|
||
box.innerHTML = items.map(function (r) {
|
||
var active = state.region === r.name ? " is-active" : "";
|
||
return '<button class="region-row' + active + '" data-region="' + r.name + '">' +
|
||
'<span class="r-name">' + r.name + "</span>" +
|
||
'<span class="r-val">' + fmt(r.val) + " млрд ₸ за 2026</span>" +
|
||
"</button>";
|
||
}).join("");
|
||
box.querySelectorAll(".region-row").forEach(function (b) {
|
||
b.addEventListener("click", function () {
|
||
state.region = b.getAttribute("data-region");
|
||
persist();
|
||
renderRegions();
|
||
renderKpi();
|
||
});
|
||
});
|
||
}
|
||
|
||
// ------- Слайд 2: динамика -------
|
||
function renderDynamics() {
|
||
var start = state.startMonth;
|
||
var monthKeys = Object.keys(revenue);
|
||
var sources = state.region === "Все регионы" ? monthKeys : [state.region];
|
||
|
||
var winRev = MONTHS.slice(start).map(function (_, idx) {
|
||
var m = start + idx;
|
||
var t = 0;
|
||
sources.forEach(function (r) { t += revenue[r][m]; });
|
||
return t;
|
||
});
|
||
var winSubs = MONTHS.slice(start).map(function (_, idx) {
|
||
var m = start + idx;
|
||
var t = 0;
|
||
sources.forEach(function (r) { t += subsPerMonth[r][m]; });
|
||
return t;
|
||
});
|
||
var winNps = npsAll.slice(start);
|
||
var labels = MONTHS.slice(start);
|
||
|
||
var box = document.getElementById("barChart");
|
||
if (!box) return;
|
||
|
||
var max = 0;
|
||
winRev.forEach(function (v) { if (v > max) max = v; });
|
||
max = max || 1;
|
||
|
||
box.innerHTML = winRev.map(function (v, i) {
|
||
var h = Math.round((v / max) * 100);
|
||
var dim = i % 2 === 1 ? " is-dim" : "";
|
||
var tip = JSON.stringify({
|
||
title: labels[i] + " 2026",
|
||
rows: [
|
||
{ label: "Выручка", value: v.toLocaleString("ru-RU", { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + " млрд ₸" },
|
||
{ label: "Абоненты", value: fmt(winSubs[i]) + " тыс." },
|
||
{ label: "NPS", value: String(winNps[i]) }
|
||
]
|
||
});
|
||
return '<div class="cc-bar' + dim + '" tabindex="0" style="height:' + h + '%" data-kt-tip="' + attrQuote(tip) + '"></div>';
|
||
}).join("");
|
||
box.parentElement.querySelector(".cc-labels").innerHTML =
|
||
labels.map(function (l) { return "<span>" + l + "</span>"; }).join("");
|
||
|
||
// Горизонтальные полосы регионов
|
||
var hBox = document.getElementById("regionBars");
|
||
if (hBox) {
|
||
var total = Object.keys(revenue).map(function (r) { return { name: r, val: sum(revenue[r]) }; })
|
||
.sort(function (a, b) { return b.val - a.val; });
|
||
var maxR = total.length ? total[0].val : 1;
|
||
hBox.innerHTML = total.map(function (r) {
|
||
var w = Math.round((r.val / maxR) * 100);
|
||
var tip = JSON.stringify({ title: r.name, rows: [{ label: "Выручка за 2026", value: r.val.toLocaleString("ru-RU", { minimumFractionDigits: 1, maximumFractionDigits: 1 }) + " млрд ₸" }] });
|
||
return '<div class="cc-hbar-row"><span class="h-name">' + r.name + "</span>" +
|
||
'<div class="cc-hbar-track"><div class="cc-hbar-fill" tabindex="0" style="width:' + w + '%" data-kt-tip="' + attrQuote(tip) + '"></div></div>' +
|
||
'<span class="h-val">' + r.val.toFixed(1) + " млрд</span></div>";
|
||
});
|
||
}
|
||
|
||
// Слайдер окна
|
||
var slider = document.getElementById("rangeSlider");
|
||
if (slider) {
|
||
slider.value = String(start);
|
||
slider.max = "11";
|
||
var scale = document.getElementById("sliderScale");
|
||
if (scale) scale.innerHTML = "<span>" + labels[0] + "</span><span>" + labels[labels.length - 1] + "</span>";
|
||
}
|
||
|
||
var note = document.getElementById("dynNote");
|
||
if (note) {
|
||
var peak = winRev.indexOf(Math.max.apply(null, winRev));
|
||
note.textContent = "Пик выручки — " + labels[peak] + " 2026: " +
|
||
winRev[peak].toLocaleString("ru-RU", { minimumFractionDigits: 1, maximumFractionDigits: 1 }) +
|
||
" млрд ₸. Окно: " + labels[0] + "–Дек 2026.";
|
||
}
|
||
}
|
||
|
||
// ------- Слайд 3: услуги -------
|
||
function renderServices() {
|
||
var cards = [
|
||
{ id: "svcInternet", icon: "link", name: "Интернет", share: 52, rev: 165.5, tone: "var(--kt-ai-chart-blue)" },
|
||
{ id: "svcMob", icon: "send", name: "Мобильная связь", share: 34, rev: 108.2, tone: "var(--kt-ai-chart-green)" },
|
||
{ id: "svcTv", icon: "megaphone", name: "Телевидение", share: 14, rev: 44.6, tone: "var(--kt-ai-chart-orange)" }
|
||
];
|
||
|
||
cards.forEach(function (c) {
|
||
var box = document.getElementById(c.id);
|
||
if (!box) return;
|
||
// дуга: полукруг, r=40, длина дуги ~ PI*r
|
||
var half = 40 * Math.PI;
|
||
var offset = half - half * (c.share / 100);
|
||
box.innerHTML =
|
||
'<div class="arc">' +
|
||
'<svg viewBox="0 0 100 56" role="img" aria-label="' + c.name + ": " + c.share + '%">' +
|
||
'<path class="arc-bg" d="M 10 50 A 40 40 0 0 1 90 50"></path>' +
|
||
'<path class="arc-fg" style="stroke:' + c.tone + '" d="M 10 50 A 40 40 0 0 1 90 50" stroke-dasharray="' + half.toFixed(1) + '" stroke-dashoffset="' + offset.toFixed(1) + '"></path>' +
|
||
"</svg></div>" +
|
||
'<div class="arc-info"><span class="a-val">' + c.share + '%</span>' +
|
||
'<span class="a-sub">' + fmt(c.rev, 1) + " млрд ₸ за 2026</span></div>";
|
||
});
|
||
|
||
var sumShare = cards.reduce(function (t, c) { return t + c.share; }, 0);
|
||
var note = document.getElementById("svcNote");
|
||
if (note) note.textContent = "Совокупная доля трёх направлений — " + sumShare + "%; остальные услуги — " + (100 - sumShare) + "%.";
|
||
}
|
||
|
||
// ------- Навигация -------
|
||
function showSlide(n) {
|
||
n = Math.max(0, Math.min(2, n));
|
||
state.slide = n;
|
||
persist();
|
||
document.querySelectorAll(".kt-slide").forEach(function (el, i) {
|
||
el.classList.toggle("is-active", i === n);
|
||
});
|
||
document.querySelectorAll(".deck-dot").forEach(function (d, i) {
|
||
d.classList.toggle("is-active", i === n);
|
||
d.setAttribute("aria-current", i === n ? "true" : "false");
|
||
});
|
||
document.getElementById("deckPrev").disabled = n === 0;
|
||
document.getElementById("deckNext").disabled = n === 2;
|
||
document.getElementById("deckCounter").textContent = (n + 1) + " / 3";
|
||
|
||
if (n === 1) renderDynamics();
|
||
if (n === 2) renderServices();
|
||
window.scrollTo({ top: 0, behavior: "instant" });
|
||
}
|
||
|
||
function initNav() {
|
||
document.querySelectorAll(".deck-dot").forEach(function (d) {
|
||
d.addEventListener("click", function () { showSlide(+d.getAttribute("data-slide")); });
|
||
});
|
||
document.getElementById("deckPrev").addEventListener("click", function () { showSlide(state.slide - 1); });
|
||
document.getElementById("deckNext").addEventListener("click", function () { showSlide(state.slide + 1); });
|
||
document.addEventListener("keydown", function (e) {
|
||
if (e.key === "ArrowRight") showSlide(state.slide + 1);
|
||
if (e.key === "ArrowLeft") showSlide(state.slide - 1);
|
||
});
|
||
}
|
||
|
||
function initSlider() {
|
||
var slider = document.getElementById("rangeSlider");
|
||
if (!slider) return;
|
||
slider.addEventListener("input", function () {
|
||
state.startMonth = +slider.value;
|
||
persist();
|
||
renderKpi();
|
||
renderRegions();
|
||
renderDynamics();
|
||
});
|
||
}
|
||
|
||
function initPeriod() {
|
||
var chip = document.getElementById("periodChip");
|
||
if (!chip) return;
|
||
chip.addEventListener("click", function () {
|
||
// переключение года: 2025 / 2026 (в демо меняем подписи)
|
||
var yearEl = chip.querySelector(".year");
|
||
if (!yearEl) return;
|
||
var y = yearEl.textContent === "2026" ? "2025" : "2026";
|
||
yearEl.textContent = y;
|
||
});
|
||
}
|
||
|
||
function init() {
|
||
restore();
|
||
renderRegions();
|
||
renderKpi();
|
||
initNav();
|
||
initSlider();
|
||
initPeriod();
|
||
renderDynamics();
|
||
renderServices();
|
||
showSlide(state.slide);
|
||
}
|
||
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", init);
|
||
} else {
|
||
init();
|
||
}
|
||
})();
|