433 lines
15 KiB
JavaScript
433 lines
15 KiB
JavaScript
"use strict";
|
||
|
||
/* ---------- Данные ---------- */
|
||
|
||
var REGIONS = [
|
||
{ name: "г. Алматы", amount: 1284.6 },
|
||
{ name: "г. Астана", amount: 947.2 },
|
||
{ name: "Алматинская область", amount: 318.4 },
|
||
{ name: "Жамбылская область", amount: 264.8 },
|
||
{ name: "Акмолинская область", amount: 212.5 },
|
||
{ name: "Костанайская область", amount: 189.3 },
|
||
{ name: "Восточно-Казахстанская область", amount: 176.9 },
|
||
{ name: "Кызылординская область", amount: 141.7 },
|
||
{ name: "Карагандинская область", amount: 128.4 },
|
||
{ name: "Атырауская область", amount: 117.2 },
|
||
{ name: "Южно-Казахстанская область", amount: 98.6 },
|
||
{ name: "Туркестанская область", amount: 84.3 },
|
||
{ name: "Актюбинская область", amount: 76.9 },
|
||
{ name: "Мангистауская область", amount: 62.4 },
|
||
{ name: "Северо-Казахстанская область", amount: 58.1 },
|
||
{ name: "Усть-Каменогорский край", amount: 47.6 },
|
||
{ name: "Павлодарская область", amount: 33.8 }
|
||
];
|
||
|
||
var CHART_COLORS = "var(--kt-ai-chart-blue)";
|
||
|
||
var LS_KEY = "top-sales-by-region-v1";
|
||
var PERIOD = "2026 год, 9 месяцев";
|
||
|
||
/* ---------- Состояние ---------- */
|
||
|
||
var state = {
|
||
sortDesc: true,
|
||
search: "",
|
||
top3Only: false,
|
||
pinned: []
|
||
};
|
||
|
||
function loadState() {
|
||
try {
|
||
var raw = localStorage.getItem(LS_KEY);
|
||
if (raw) {
|
||
var saved = JSON.parse(raw);
|
||
if (saved && typeof saved === "object") {
|
||
if (typeof saved.sortDesc === "boolean") state.sortDesc = saved.sortDesc;
|
||
if (typeof saved.search === "string") state.search = saved.search;
|
||
if (typeof saved.top3Only === "boolean") state.top3Only = saved.top3Only;
|
||
if (saved.pinned && Array.isArray(saved.pinned)) state.pinned = saved.pinned;
|
||
}
|
||
}
|
||
} catch (e) { /* повреждённые данные — начинаем с чистого состояния */ }
|
||
}
|
||
|
||
function saveState() {
|
||
try {
|
||
localStorage.setItem(LS_KEY, JSON.stringify(state));
|
||
} catch (e) { /* приватный режим — молча пропускаем */ }
|
||
}
|
||
|
||
/* ---------- Утилиты ---------- */
|
||
|
||
function escapeHtml(value) {
|
||
return String(value)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
function formatNumber(value, digits) {
|
||
var n = Number(value);
|
||
if (!isFinite(n)) return "—";
|
||
return n.toLocaleString("ru-RU", {
|
||
minimumFractionDigits: digits,
|
||
maximumFractionDigits: digits
|
||
});
|
||
}
|
||
|
||
function formatFull(value) {
|
||
var n = Number(value);
|
||
if (!isFinite(n)) return "—";
|
||
return n.toLocaleString("ru-RU", { maximumFractionDigits: 1 });
|
||
}
|
||
|
||
function getEl(id) {
|
||
return document.getElementById(id);
|
||
}
|
||
|
||
function shortName(name) {
|
||
if (name.indexOf("область") !== -1) return name.replace("область", "обл.").replace("Восточно-Казахстанская", "Вост.-Казахст.");
|
||
return name;
|
||
}
|
||
|
||
/* ---------- Выборка ---------- */
|
||
|
||
function totalAmount() {
|
||
var t = 0;
|
||
for (var i = 0; i < REGIONS.length; i++) t += REGIONS[i].amount;
|
||
return t;
|
||
}
|
||
|
||
function currentData() {
|
||
var q = state.search.trim().toLowerCase();
|
||
var byAmount = REGIONS.slice().sort(function (a, b) { return b.amount - a.amount; });
|
||
var top3Names = [];
|
||
for (var t = 0; t < 3; t++) top3Names.push(byAmount[t].name);
|
||
var max = byAmount[0] ? byAmount[0].amount : 0;
|
||
var grandTotal = totalAmount();
|
||
|
||
var allTotal = 0;
|
||
for (var a = 0; a < REGIONS.length; a++) allTotal += REGIONS[a].amount;
|
||
|
||
var rows = [];
|
||
for (var k = 0; k < REGIONS.length; k++) {
|
||
var r = REGIONS[k];
|
||
var matches = !q || r.name.toLowerCase().indexOf(q) !== -1;
|
||
var isPinned = state.pinned.indexOf(r.name) !== -1;
|
||
if (state.top3Only && top3Names.indexOf(r.name) === -1 && !isPinned) matches = false;
|
||
if (!matches) continue;
|
||
var rank = null;
|
||
for (var x = 0; x < byAmount.length; x++) {
|
||
if (byAmount[x].name === r.name) { rank = x + 1; break; }
|
||
}
|
||
var share = grandTotal > 0 ? (r.amount / grandTotal) * 100 : 0;
|
||
rows.push({
|
||
name: r.name,
|
||
amount: r.amount,
|
||
rank: rank,
|
||
share: share,
|
||
top: top3Names.indexOf(r.name) !== -1,
|
||
pinned: isPinned,
|
||
max: max
|
||
});
|
||
}
|
||
rows.sort(function (a, b) {
|
||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||
if (a.name !== b.name) {
|
||
var an = a.name.toLowerCase();
|
||
var bn = b.name.toLowerCase();
|
||
if (an < bn) return -1;
|
||
if (an > bn) return 1;
|
||
return 0;
|
||
}
|
||
if (state.sortDesc) return b.amount - a.amount;
|
||
return a.amount - b.amount;
|
||
});
|
||
return rows;
|
||
}
|
||
|
||
/* ---------- Рендер ---------- */
|
||
|
||
function renderMeta(rows) {
|
||
var meta = getEl("dash-meta");
|
||
if (meta) meta.textContent = PERIOD + " · млн тенге";
|
||
}
|
||
|
||
function renderKpis(rows) {
|
||
var strip = getEl("kpi-strip");
|
||
if (!strip) return;
|
||
var total = 0;
|
||
var best = null;
|
||
var visibleCount = rows.length;
|
||
for (var i = 0; i < rows.length; i++) {
|
||
total += rows[i].amount;
|
||
if (!best || rows[i].amount > best.amount) best = rows[i];
|
||
}
|
||
var allTotal = 0;
|
||
for (var k = 0; k < REGIONS.length; k++) allTotal += REGIONS[k].amount;
|
||
|
||
var top3Names = [];
|
||
var byAmount = REGIONS.slice().sort(function (a, b) { return b.amount - a.amount; }).slice(0, 3);
|
||
var top3Sum = 0;
|
||
for (var j = 0; j < byAmount.length; j++) top3Sum += byAmount[j].amount;
|
||
|
||
var kpis = [];
|
||
kpis.push({ label: "Всего областей", value: String(REGIONS.length), hint: "в рейтинге" });
|
||
kpis.push({ label: "Общий объём продаж", value: formatNumber(allTotal, 1), hint: "млн тг, все области" });
|
||
kpis.push({ label: "Лидер продаж", value: best ? best.name : "—", hint: best ? formatNumber(best.amount, 1) + " млн тг" : "", long: true });
|
||
kpis.push({ label: "Объём топ-3", value: formatNumber(top3Sum, 1), hint: "млн тг, 3 области" });
|
||
|
||
var html = "";
|
||
for (var m = 0; m < kpis.length; m++) {
|
||
html += '<div class="kt-ai-kpi">';
|
||
html += '<span class="label">' + escapeHtml(kpis[m].label) + "</span>";
|
||
html += '<span class="value"' + (kpis[m].long ? ' data-long="true"' : "") + ">" + escapeHtml(kpis[m].value) + "</span>";
|
||
if (kpis[m].hint) html += '<span class="hint">' + escapeHtml(kpis[m].hint) + "</span>";
|
||
html += "</div>";
|
||
}
|
||
strip.innerHTML = html;
|
||
}
|
||
|
||
function renderChart(rows) {
|
||
var barsBox = getEl("chart-bars");
|
||
var axisBox = getEl("chart-axis");
|
||
if (!barsBox || !axisBox) return;
|
||
var show = rows.slice().sort(function (a, b) { return b.amount - a.amount; }).slice(0, 10);
|
||
var html = "";
|
||
var axis = "";
|
||
var maxVal = 0;
|
||
for (var i = 0; i < show.length; i++) if (show[i].amount > maxVal) maxVal = show[i].amount;
|
||
for (var j = 0; j < show.length; j++) {
|
||
var r = show[j];
|
||
var h = maxVal > 0 ? Math.round((r.amount / maxVal) * 100) : 0;
|
||
if (h < 2) h = 2;
|
||
var top3 = (j < 3);
|
||
var tip = "{"title":"" + r.name + "","rows":[{"label":"Объём продаж","value":"" + formatFull(r.amount) + " млн тг"},{"label":"Доля","value":"" + formatNumber(r.share, 1) + "%"}]}";
|
||
html += '<div class="cc-bar"' + (top3 ? ' data-top="true"' : "") + ' data-kt-tip="' + tip + '" tabindex="0"><i style="height:' + h + '%;' + (top3 ? "--cc-color:" + CHART_COLORS + ";" : "") + '"></i></div>';
|
||
axis += '<span title="' + escapeHtml(r.name) + '">' + escapeHtml(shortName(r.name)) + "</span>";
|
||
}
|
||
barsBox.innerHTML = html;
|
||
axisBox.innerHTML = axis;
|
||
}
|
||
|
||
function renderFilters() {
|
||
var chips = getEl("filter-chips");
|
||
if (!chips) return;
|
||
var html = "";
|
||
var count = getEl("chip-count");
|
||
if (state.pinned.length > 0) {
|
||
for (var i = 0; i < state.pinned.length; i++) {
|
||
html += '<span class="kt-ai-filter-chip" data-active="true"><span class="k">' + escapeHtml("Закреплено") + "</span> " +
|
||
escapeHtml(state.pinned[i]) +
|
||
'<span class="remove" data-region="' + escapeHtml(state.pinned[i]) + '" title="Открепить"><svg class="kt-icon" aria-hidden="true"><use href="#x"></use></svg></span></span>';
|
||
}
|
||
}
|
||
if (state.top3Only) {
|
||
html += '<span class="kt-ai-filter-chip" data-active="true"><span class="k">Фильтр</span> Топ-3<span class="remove" data-clear-top3="1" title="Снять"><svg class="kt-icon" aria-hidden="true"><use href="#x"></use></svg></span></span>';
|
||
}
|
||
chips.innerHTML = html;
|
||
if (count) {
|
||
var rows = currentData();
|
||
count.textContent = "Показано: " + rows.length + " из " + REGIONS.length;
|
||
}
|
||
var searchInput = getEl("search");
|
||
if (searchInput && searchInput.value !== state.search) searchInput.value = state.search;
|
||
var t3 = getEl("btn-top3");
|
||
if (t3) t3.textContent = state.top3Only ? "Снять: Топ-3" : "Топ-3";
|
||
|
||
var sortBtn = getEl("btn-sort");
|
||
if (sortBtn) sortBtn.setAttribute("aria-sort", state.sortDesc ? "descending" : "ascending");
|
||
}
|
||
|
||
function renderTable(rows) {
|
||
var tbody = getEl("table-body");
|
||
var empty = getEl("empty");
|
||
if (!tbody) return;
|
||
if (rows.length === 0) {
|
||
tbody.innerHTML = "";
|
||
if (empty) empty.hidden = false;
|
||
return;
|
||
}
|
||
if (empty) empty.hidden = true;
|
||
var html = "";
|
||
var maxAmount = 0;
|
||
for (var i = 0; i < rows.length; i++) if (rows[i].amount > maxAmount) maxAmount = rows[i].amount;
|
||
for (var j = 0; j < rows.length; j++) {
|
||
var r = rows[j];
|
||
var pct = maxAmount > 0 ? Math.round((r.amount / maxAmount) * 100) : 0;
|
||
var rankCell = '<span class="rank">' + (r.rank ? r.rank : "—") + "</span>";
|
||
if (r.pinned) rankCell += ' <svg class="kt-icon" style="color:var(--kt-ai-primary)" aria-hidden="true" title="Закреплено"><use href="#pin"></use></svg>';
|
||
html += '<tr data-top="' + (r.top ? "true" : "false") + '">';
|
||
html += "<td>" + rankCell + "</td>";
|
||
html += '<td class="row-bar"><i style="width:' + pct + '%"></i><span class="ct">' + escapeHtml(r.name) + "</span></td>";
|
||
html += '<td class="num">' + formatNumber(r.amount, 1) + "</td>";
|
||
html += '<td class="num"><span class="share">' + formatNumber(r.share, 1) + "%</span></td>";
|
||
html += "</tr>";
|
||
}
|
||
tbody.innerHTML = html;
|
||
}
|
||
|
||
function renderSummary(rows) {
|
||
var box = getEl("summary");
|
||
if (!box) return;
|
||
if (rows.length === 0) {
|
||
box.hidden = true;
|
||
return;
|
||
}
|
||
var sorted = rows.slice().sort(function (a, b) { return b.amount - a.amount; });
|
||
var best = sorted[0];
|
||
var second = sorted[1];
|
||
var total = 0;
|
||
for (var i = 0; i < rows.length; i++) total += rows[i].amount;
|
||
var allTotal = 0;
|
||
for (var k = 0; k < REGIONS.length; k++) allTotal += REGIONS[k].amount;
|
||
var bestShare = best && allTotal > 0 ? (best.amount / allTotal) * 100 : 0;
|
||
var text = "Лидер — " + best.name + ": " + formatNumber(best.amount, 1) + " млн тг, доля " + formatNumber(bestShare, 1) + "%";
|
||
if (second && second.name !== best.name) {
|
||
text += ", " + second.name + " — " + formatNumber(second.amount, 1) + " млн тг";
|
||
}
|
||
text += ". Итого по отфильтрованному списку: " + formatNumber(total, 1) + " млн тг.";
|
||
box.textContent = text;
|
||
box.hidden = false;
|
||
}
|
||
|
||
function renderSortControl() {
|
||
var ic = getEl("sort-ic");
|
||
if (ic) ic.setAttribute("data-dir", state.sortDesc ? "desc" : "asc");
|
||
}
|
||
|
||
function render() {
|
||
var rows = currentData();
|
||
renderMeta(rows);
|
||
renderKpis(rows);
|
||
renderChart(rows);
|
||
renderFilters();
|
||
renderTable(rows);
|
||
renderSummary(rows);
|
||
renderSortControl();
|
||
saveState();
|
||
}
|
||
|
||
/* ---------- События ---------- */
|
||
|
||
function toast(message) {
|
||
var stack = document.querySelector(".kt-ai-toast-stack");
|
||
if (!stack) return;
|
||
var el = document.createElement("div");
|
||
el.className = "kt-ai-toast";
|
||
el.textContent = message;
|
||
stack.appendChild(el);
|
||
setTimeout(function () {
|
||
if (el.parentNode) el.parentNode.removeChild(el);
|
||
}, 2500);
|
||
}
|
||
|
||
function resetAll() {
|
||
state.sortDesc = true;
|
||
state.search = "";
|
||
state.top3Only = false;
|
||
state.pinned = [];
|
||
var s = getEl("search");
|
||
if (s) s.value = "";
|
||
render();
|
||
}
|
||
|
||
function wireEvents() {
|
||
var sortBtn = getEl("btn-sort");
|
||
if (sortBtn) {
|
||
sortBtn.addEventListener("click", function () {
|
||
state.sortDesc = !state.sortDesc;
|
||
render();
|
||
});
|
||
}
|
||
|
||
var search = getEl("search");
|
||
if (search) {
|
||
search.addEventListener("input", function () {
|
||
state.search = search.value;
|
||
render();
|
||
});
|
||
}
|
||
|
||
var top3Btn = getEl("btn-top3");
|
||
if (top3Btn) {
|
||
top3Btn.addEventListener("click", function () {
|
||
state.top3Only = !state.top3Only;
|
||
render();
|
||
});
|
||
}
|
||
|
||
var resetBtn = getEl("btn-refresh");
|
||
if (resetBtn) {
|
||
resetBtn.addEventListener("click", function () {
|
||
try { localStorage.removeItem(LS_KEY); } catch (e) { /* приватный режим */ }
|
||
resetAll();
|
||
toast("Данные обновлены");
|
||
});
|
||
}
|
||
var emptyReset = getEl("btn-empty-reset");
|
||
if (emptyReset) emptyReset.addEventListener("click", resetAll);
|
||
|
||
var chips = getEl("filter-chips");
|
||
if (chips) {
|
||
chips.addEventListener("click", function (ev) {
|
||
var target = ev.target;
|
||
while (target && target !== chips) {
|
||
if (target.classList && target.classList.contains("remove")) break;
|
||
target = target.parentNode;
|
||
}
|
||
if (!target || target === chips) return;
|
||
var region = target.getAttribute ? target.getAttribute("data-region") : null;
|
||
var clearTop3 = target.getAttribute ? target.getAttribute("data-clear-top3") : null;
|
||
if (region) {
|
||
state.pinned = state.pinned.filter(function (p) { return p !== region; });
|
||
render();
|
||
} else if (clearTop3) {
|
||
state.top3Only = false;
|
||
render();
|
||
}
|
||
});
|
||
}
|
||
|
||
var tbody = getEl("table-body");
|
||
if (tbody) {
|
||
tbody.addEventListener("click", function (ev) {
|
||
var target = ev.target;
|
||
while (target && target !== tbody) {
|
||
if (target.tagName === "TR") break;
|
||
target = target.parentNode;
|
||
}
|
||
if (!target || target === tbody) return;
|
||
var cells = target.querySelectorAll("td");
|
||
if (cells.length < 2) return;
|
||
var name = cells[1].textContent.trim();
|
||
if (!name) return;
|
||
var idx = state.pinned.indexOf(name);
|
||
if (idx === -1) {
|
||
state.pinned.push(name);
|
||
toast("Закреплено: " + name);
|
||
} else {
|
||
state.pinned.splice(idx, 1);
|
||
toast("Откреплено: " + name);
|
||
}
|
||
render();
|
||
});
|
||
}
|
||
}
|
||
|
||
function init() {
|
||
loadState();
|
||
var s = getEl("search");
|
||
if (s) s.value = state.search;
|
||
wireEvents();
|
||
render();
|
||
}
|
||
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", init);
|
||
} else {
|
||
init();
|
||
}
|