247 lines
9.7 KiB
JavaScript
247 lines
9.7 KiB
JavaScript
(function () {
|
||
"use strict";
|
||
|
||
var DATA = [];
|
||
var cat = null; // активный фильтр категории (null = все)
|
||
var q = ""; // поисковый запрос
|
||
var sortKey = "f2025"; // текущая сортировка
|
||
var sortNum = true; // сортировать по числу или строке
|
||
var sortDir = -1; // -1 убыв, 1 возр
|
||
var cats = [];
|
||
|
||
function num(v) {
|
||
return v == null ? null : Number(v || 0);
|
||
}
|
||
function fmt(v) {
|
||
if (v == null) return "—";
|
||
return v.toLocaleString("ru-RU", { maximumFractionDigits: 0 });
|
||
}
|
||
function fmtPct(v) {
|
||
if (v == null) return "—";
|
||
var s = v > 0 ? "+" : "";
|
||
return s + v.toFixed(1) + " %";
|
||
}
|
||
function cls(v) {
|
||
if (v == null) return "";
|
||
return v > 0 ? "up" : (v < 0 ? "down" : "");
|
||
}
|
||
function esc(s) {
|
||
return String(s == null ? "" : s)
|
||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
function formatBars() {
|
||
var rows = document.querySelectorAll("#tbl tbody tr[data-bar]");
|
||
rows.forEach(function (tr) {
|
||
var val = parseFloat(tr.dataset.bar || "0");
|
||
var max = parseFloat(tr.dataset.barmax || "1");
|
||
tr.querySelector(".bar").style.width = (max ? (val / max) * 100 : 0) + "%";
|
||
});
|
||
}
|
||
|
||
function renderTable(rows) {
|
||
var visible = rows.filter(function (r) {
|
||
if (cat && r.cat !== cat) return false;
|
||
if (q && r.name.toLowerCase().indexOf(q) < 0) return false;
|
||
return true;
|
||
});
|
||
|
||
var maxVal = 1;
|
||
visible.forEach(function (r) {
|
||
var v = num(r[sortKey]);
|
||
if (v != null && v > maxVal) maxVal = v;
|
||
});
|
||
|
||
visible.sort(function (a, b) {
|
||
var av = num(a[sortKey]);
|
||
var bv = num(b[sortKey]);
|
||
var c;
|
||
if (sortNum) {
|
||
var an = av == null ? -Infinity : av;
|
||
var bn = bv == null ? -Infinity : bv;
|
||
c = an - bn;
|
||
} else {
|
||
c = String(a[sortKey] || "").localeCompare(String(b[sortKey] || ""), "ru");
|
||
}
|
||
return c * sortDir;
|
||
});
|
||
|
||
var body = document.getElementById("tbl").querySelector("tbody");
|
||
body.innerHTML = visible.map(function (r) {
|
||
var d25 = num(r.f2025), d26 = num(r.f2026);
|
||
var delta = d25 != null && d26 != null ? d26 - d25 : null;
|
||
var deltaP = d25 != null && d26 != null && d25 !== 0 ? ((d26 - d25) / Math.abs(d25)) * 100 : null;
|
||
var barVal = sortNum ? (num(r[sortKey]) || 0) : (d25 || 0);
|
||
return "<tr data-bar=\"" + barVal + "\" data-barmax=\"" + maxVal + "\">" +
|
||
"<td><strong>" + esc(r.name) + "</strong></td>" +
|
||
"<td><span class=\"kt-ai-chip\">" + esc(r.cat) + "</span></td>" +
|
||
"<td class=\"num\">" + fmt(d25) + "</td>" +
|
||
"<td class=\"num\">" + fmt(d26) + "</td>" +
|
||
"<td class=\"num " + cls(delta) + "\">" + fmt(delta) + "</td>" +
|
||
"<td class=\"num " + cls(deltaP) + "\">" + fmtPct(deltaP) + "</td>" +
|
||
"<td class=\"bar-cell\"><div class=\"bar-bg\"><div class=\"bar\"></div></div></td>" +
|
||
"</tr>";
|
||
}).join("");
|
||
|
||
document.getElementById("count").textContent =
|
||
"Позиций показано: " + visible.length + " из " + rows.length;
|
||
formatBars();
|
||
}
|
||
|
||
function sumOf(rows, key) {
|
||
return rows.reduce(function (acc, r) {
|
||
var v = num(r[key]);
|
||
return v == null ? acc : acc + v;
|
||
}, 0);
|
||
}
|
||
|
||
function renderKpis(rows) {
|
||
var s25 = sumOf(rows, "f2025");
|
||
var s26 = sumOf(rows, "f2026");
|
||
var n25 = rows.filter(function (r) { return num(r.f2025) != null; }).length;
|
||
var n26 = rows.filter(function (r) { return num(r.f2026) != null; }).length;
|
||
|
||
var tot26 = s26 * 2; // экстраполяция полугодия на год — только ориентир
|
||
document.getElementById("kpis").innerHTML =
|
||
"<div class=\"kpi\"><div class=\"k\">Итого 2025</div><div class=\"v\">" + fmt(s25) + "</div>" +
|
||
"<div class=\"d\">₸ · " + n25 + " позиций</div></div>" +
|
||
"<div class=\"kpi\"><div class=\"k\">Итого 6 мес 2026</div><div class=\"v\">" + fmt(s26) + "</div>" +
|
||
"<div class=\"d\">₸ · " + n26 + " позиций</div></div>" +
|
||
"<div class=\"kpi\"><div class=\"k\">2025 vs 2025 (полугодие)</div><div class=\"v\">" + fmt(s25 / 2) + "</div>" +
|
||
"<div class=\"d\">оценка среднего полугодия 2025</div></div>" +
|
||
"<div class=\"kpi\"><div class=\"k\">Крупнейшая позиция 2025</div><div class=\"v\">" + fmt(s25Max(rows)) + "</div>" +
|
||
"<div class=\"d\">" + esc(topName(rows, "f2025")) + "</div></div>";
|
||
}
|
||
|
||
function s25Max(rows) {
|
||
var m = 0;
|
||
rows.forEach(function (r) { var v = num(r.f2025); if (v != null && v > m) m = v; });
|
||
return m;
|
||
}
|
||
function topName(rows, key) {
|
||
var best = null, bv = -1;
|
||
rows.forEach(function (r) { var v = num(r[key]); if (v != null && v > bv) { bv = v; best = r; } });
|
||
return best ? best.name : "";
|
||
}
|
||
|
||
function renderCats(rows) {
|
||
var by = {};
|
||
rows.forEach(function (r) {
|
||
if (!by[r.cat]) by[r.cat] = { name: r.cat, s25: 0, s26: 0 };
|
||
var v25 = num(r.f2025), v26 = num(r.f2026);
|
||
if (v25 != null) by[r.cat].s25 += v25;
|
||
if (v26 != null) by[r.cat].s26 += v26;
|
||
});
|
||
var total = Object.keys(by).reduce(function (a, k) { return a + by[k].s25; }, 0) || 1;
|
||
Object.keys(by).sort(function (a, b) { return by[b].s25 - by[a].s25; }).forEach(function (k) {
|
||
var c = by[k];
|
||
var pct = (c.s25 / total) * 100;
|
||
var el = document.createElement("div");
|
||
el.innerHTML =
|
||
"<div class=\"cat-top\"><strong>" + esc(c.name) + "</strong>" +
|
||
"<span class=\"muted\">" + fmt(c.s25) + " · " + (c.s26 != null ? fmt(c.s26) + " (26)" : "—") + "</span></div>" +
|
||
"<div class=\"cat-bar\"><div style=\"width:" + pct.toFixed(1) + "%;height:8px;border-radius:999px;background:var(--kt-ai-primary)\"></div></div>";
|
||
document.getElementById("cats").appendChild(el);
|
||
});
|
||
}
|
||
|
||
function renderTop(rows, key, elId) {
|
||
var sorted = rows.slice().filter(function (r) { return num(r[key]) != null; })
|
||
.sort(function (a, b) { return num(b[key]) - num(a[key]); }).slice(0, 10);
|
||
var max = num(sorted[0] ? sorted[0][key] : 1) || 1;
|
||
document.getElementById(elId).innerHTML = sorted.map(function (r) {
|
||
var v = num(r[key]);
|
||
return "<div style=\"display:flex;flex-direction:column;gap:4px;margin-bottom:var(--kt-ai-space-4)\">" +
|
||
"<div class=\"cat-top\"><span>" + esc(r.name) + "</span><strong>" + fmt(v) + "</strong></div>" +
|
||
"<div class=\"bar-bg\" style=\"height:8px\"><div class=\"bar\" style=\"width:" + ((v / max) * 100).toFixed(1) + "%\"></div></div>" +
|
||
"</div>";
|
||
}).join("");
|
||
}
|
||
|
||
function renderCatFilters(rows) {
|
||
cats = [];
|
||
rows.forEach(function (r) { if (cats.indexOf(r.cat) < 0) cats.push(r.cat); });
|
||
cats.sort();
|
||
var wrap = document.getElementById("catFilter");
|
||
wrap.innerHTML = "<button class=\"kt-ai-btn chip\" data-cat=\"\" data-size=\"sm\">Все</button>" +
|
||
cats.map(function (c) {
|
||
return "<button class=\"kt-ai-btn chip\" data-cat=\"" + esc(c) + "\" data-size=\"sm\">" + esc(c) + "</button>";
|
||
}).join("");
|
||
markActiveCat();
|
||
}
|
||
|
||
function markActiveCat() {
|
||
document.querySelectorAll("#catFilter .chip").forEach(function (b) {
|
||
var active = (b.dataset.cat || "") === (cat || "");
|
||
b.classList.toggle("active", active);
|
||
if (active) b.style.borderColor = "var(--kt-ai-primary)";
|
||
else b.style.borderColor = "";
|
||
});
|
||
}
|
||
|
||
function exportCsv(rows) {
|
||
var head = ["Позиция", "Категория", "2025, KZT", "6 мес 2026, KZT", "Изменение, KZT", "Изменение, %"];
|
||
var body = rows.map(function (r) {
|
||
var d25 = num(r.f2025), d26 = num(r.f2026);
|
||
var delta = d25 != null && d26 != null ? d26 - d25 : "";
|
||
var deltaP = d25 != null && d26 != null && d25 !== 0 ? (((d26 - d25) / Math.abs(d25)) * 100).toFixed(1) : "";
|
||
return [r.name, r.cat, d25 == null ? "" : d25, d26 == null ? "" : d26, delta, deltaP]
|
||
.map(function (v) { return '"' + String(v).replace(/"/g, '""') + '"'; }).join(";");
|
||
}).join("\n");
|
||
var csv = "\uFEFF" + [head.join(";"), body].join("\n");
|
||
var a = document.createElement("a");
|
||
a.href = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
|
||
a.download = "reviya_servisnyh_kontraktov.csv";
|
||
a.click();
|
||
}
|
||
|
||
function init() {
|
||
var rows = DATA;
|
||
|
||
renderKpis(rows);
|
||
renderCats(rows);
|
||
renderTop(rows, "f2025", "top2025");
|
||
renderCatFilters(rows);
|
||
renderTable(rows);
|
||
|
||
document.getElementById("q").addEventListener("input", function (e) {
|
||
q = e.target.value.trim().toLowerCase();
|
||
renderTable(rows);
|
||
});
|
||
|
||
document.getElementById("catFilter").addEventListener("click", function (e) {
|
||
var b = e.target.closest(".chip");
|
||
if (!b) return;
|
||
cat = b.dataset.cat ? b.dataset.cat : null;
|
||
markActiveCat();
|
||
renderTable(rows);
|
||
});
|
||
|
||
document.getElementById("btnExport").addEventListener("click", function () {
|
||
exportCsv(rows);
|
||
});
|
||
|
||
document.querySelectorAll("#tbl th").forEach(function (th) {
|
||
th.addEventListener("click", function () {
|
||
var k = th.dataset.k;
|
||
var wasNum = th.dataset.num;
|
||
if (sortKey === k) sortDir = -sortDir;
|
||
else {
|
||
sortKey = k;
|
||
sortDir = k === "name" ? 1 : -1;
|
||
}
|
||
sortNum = wasNum === "1";
|
||
renderTable(rows);
|
||
});
|
||
});
|
||
}
|
||
|
||
fetch("data.json").then(function (r) { return r.json(); }).then(function (rows) {
|
||
DATA = rows;
|
||
init();
|
||
}).catch(function (err) {
|
||
document.getElementById("count").textContent = "Ошибка загрузки данных: " + err.message;
|
||
});
|
||
})();
|