dashbord-roznichnyh-prodazh-2/script.js

489 lines
21 KiB
JavaScript
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.

/* Дашборд розничных продаж по городам: данные, фильтры, два экрана */
(function () {
"use strict";
/* ================= ДАННЫЕ ================= */
var MONTHS = ["январь", "февраль", "март", "апрель", "май", "июнь", "июль", "август"];
var MONTHS_SHORT = ["янв", "фев", "мар", "апр", "май", "июн", "июл", "авг"];
var CITIES = [
{ name: "Алматы", region: "Юг", salesT2: 486, salesT: 512, ticketsT: 78.4, avgT: 6530, growth: 5.3, top: "Мобильные устройства" },
{ name: "Астана", region: "Север", salesT2: 342, salesT: 358, ticketsT: 52.1, avgT: 6870, growth: 4.7, top: "Телевизоры" },
{ name: "Шымкент", region: "Юг", salesT2: 176, salesT: 189, ticketsT: 34.2, avgT: 5530, growth: 7.4, top: "Бытовая техника" },
{ name: "Караганда", region: "Север", salesT2: 148, salesT: 142, ticketsT: 27.9, avgT: 5090, growth: -4.1, top: "Домашний интернет" },
{ name: "Актау", region: "Запад", salesT2: 121, salesT: 134, ticketsT: 21.6, avgT: 6200, growth: 10.7, top: "Электроника" },
{ name: "Акмола", region: "Север", salesT2: 114, salesT: 121, ticketsT: 23.8, avgT: 5080, growth: 6.1, top: "Домашний интернет" },
{ name: "Актобе", region: "Запад", salesT2: 108, salesT: 112, ticketsT: 22.4, avgT: 5000, growth: 3.7, top: "Бытовая техника" },
{ name: "Костанай", region: "Север", salesT2: 96, salesT: 101, ticketsT: 20.3, avgT: 4970, growth: 5.2, top: "Телефония" },
{ name: "Павлодар", region: "Север", salesT2: 88, salesT: 84, ticketsT: 17.1, avgT: 4910, growth: -4.5, top: "Телевизоры" },
{ name: "Усть-Каменогорск", region: "Восток", salesT2: 72, salesT: 78, ticketsT: 14.9, avgT: 5230, growth: 8.3, top: "Бытовая техника" }
];
/* товарные категории и базовые веса долей */
var CATS = [
{ key: "phone", label: "Телефония", w: 0.24 },
{ key: "mob", label: "Мобильные устройства", w: 0.19 },
{ key: "tv", label: "Телевизоры", w: 0.12 },
{ key: "bt", label: "Бытовая техника", w: 0.11 },
{ key: "elec", label: "Электроника", w: 0.10 },
{ key: "home", label: "Домашний интернет", w: 0.08 },
{ key: "acc", label: "Аксессуары", w: 0.06 },
{ key: "other", label: "Прочее", w: 0.10 }
];
var SEEDS = {
"Алматы": [1.05, 1.10, 0.95, 0.80, 0.60, 0.90, 1.00, 1.00],
"Астана": [0.90, 1.00, 0.70, 1.15, 0.65, 0.85, 1.05, 1.00],
"Шымкент": [1.10, 0.95, 0.90, 0.75, 0.85, 1.05, 0.95, 1.00],
"Караганда": [0.85, 0.95, 1.05, 0.90, 0.70, 0.80, 1.00, 1.00],
"Актау": [1.00, 0.90, 1.15, 0.85, 0.75, 0.95, 0.90, 1.00],
"Акмола": [0.95, 0.80, 1.05, 1.00, 0.70, 0.85, 0.95, 1.00],
"Актобе": [0.80, 1.05, 0.90, 0.85, 0.95, 0.75, 1.10, 1.00],
"Костанай": [1.00, 0.85, 0.90, 1.05, 0.80, 0.95, 1.00, 1.00],
"Павлодар": [0.90, 1.00, 0.75, 0.85, 1.05, 0.70, 0.90, 1.00],
"Усть-Каменогорск": [0.95, 1.05, 0.80, 0.90, 1.00, 0.85, 0.70, 1.00]
};
var state = {
q: "",
region: "all",
sort: "sales",
viewed: {}
};
var el = {};
var LS_KEY = "kt-dash-rozn-2";
function saveState() {
try {
localStorage.setItem(LS_KEY, JSON.stringify({
q: state.q,
region: state.region,
sort: state.sort,
viewed: state.viewed
}));
} catch (e) { /* приватный режим — молча */ }
}
function loadState() {
try {
var raw = localStorage.getItem(LS_KEY);
if (!raw) { return; }
var saved = JSON.parse(raw);
if (typeof saved.q === "string") { state.q = saved.q.slice(0, 40); }
if (typeof saved.region === "string") { state.region = saved.region; }
if (["sales", "growth", "avg", "share"].indexOf(saved.sort) !== -1) { state.sort = saved.sort; }
if (saved.viewed && typeof saved.viewed === "object") { state.viewed = saved.viewed; }
} catch (e) { /* битые данные — начинаем с чистого */ }
}
/* ================= ХЕЛПЕРЫ ================= */
function $(id) { return document.getElementById(id); }
function fmtNum(n, dec) {
dec = dec || 0;
var s = String(Number(n.toFixed(dec)));
var parts = s.split(".");
var int = parts[0];
var out = "";
while (int.length > 3) {
out = " " + int.slice(-3) + out;
int = int.slice(0, -3);
}
out = int + out;
return parts.length > 1 ? out + "," + parts[1] : out;
}
function esc(s) {
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function totalSales(list, field) {
var t = 0;
for (var i = 0; i < list.length; i++) { t += list[i][field]; }
return t;
}
function cityByIdx(name) {
for (var i = 0; i < CITIES.length; i++) {
if (CITIES[i].name === name) { return CITIES[i]; }
}
return null;
}
function growthPill(g) {
if (g > 0) { return '<span class="kt-ai-status-pill" data-status="ok">+' + g.toFixed(1) + "%</span>"; }
if (g < 0) { return '<span class="kt-ai-status-pill" data-status="risk">' + g.toFixed(1) + "%</span>"; }
return '<span class="kt-ai-status-pill" data-status="muted">0,0%</span>';
}
/* продажи города по месяцам, млн тг */
function monthlySales(c) {
var s = SEEDS[c.name] || [];
var arr = [];
for (var i = 0; i < MONTHS.length; i++) {
arr.push(Math.round(c.salesT * (s[i] || 1) * 10) / 10);
}
return arr;
}
/* доли товарных категорий, % */
function cityCats(c) {
var s = SEEDS[c.name] || [];
var arr = [];
for (var i = 0; i < CATS.length; i++) {
var m = s[i % s.length] || 1;
arr.push({ label: CATS[i].label, share: Math.round(CATS[i].w * m * 100) / 100 });
}
return arr;
}
/* топ-позиции: имя + доля в продажах */
function topItems(c) {
var cats = cityCats(c);
var names = {
"phone": { a: "Абонентский план «Безлимит 50»", b: "Абонентский план «Семейный»" },
"mob": { a: "Смартфон Galaxy S24", b: "Смартфон iPhone 15" },
"tv": { a: "Телевизор 55\" 4K", b: "Телевизор 43\" FHD" },
"bt": { a: "Холодильник No Frost", b: "Микроволновая печь" },
"elec": { a: "Ноутбук 15\"", b: "Монитор 27\"" },
"home": { a: "Домашний интернет 300 Мбит/с", b: "Домашний интернет 100 Мбит/с" },
"acc": { a: "Беспроводные наушники", b: "Зарядное устройство USB-C" },
"other": { a: "Услуга аренды линии", b: "Подписка на видеосервис" }
};
var sorted = cats.slice().sort(function (a, b) { return b.share - a.share; });
var out = [];
out.push({ name: c.top, share: null });
var max = 0;
for (var i = 0; i < sorted.length; i++) {
if (sorted[i].label === c.top) { out[0].share = sorted[i].share; max = Math.max(max, sorted[i].share); }
}
for (var j = 0; j < sorted.length && out.length < 4; j++) {
if (sorted[j].label === c.top) { continue; }
var nm = names[CATS[j].key];
var pick = (j % 2 === 0) ? nm.a : nm.b;
out.push({ name: pick, share: Math.round(sorted[j].share * 0.8 * 10) / 10 });
max = Math.max(max, out[out.length - 1].share);
}
for (var k = 0; k < out.length; k++) {
var s10 = Math.round(out[k].share) * 10;
out[k].pct = (out[k].share && s10 > 0) ? Math.max(8, Math.min(100, Math.round(s10 / 2))) : 100;
}
return out;
}
/* ================= ГЛАВНЫЙ ЭКРАН ================= */
function filteredCities() {
var q = state.q.toLowerCase();
var out = [];
for (var i = 0; i < CITIES.length; i++) {
var c = CITIES[i];
if (state.region !== "all" && c.region !== state.region) { continue; }
if (q && c.name.toLowerCase().indexOf(q) === -1) { continue; }
out.push(c);
}
var key = state.sort;
out.sort(function (a, b) {
if (key === "growth") { return b.growth - a.growth; }
if (key === "avg") { return b.avgT - a.avgT; }
if (key === "share") { return b.salesT - a.salesT; }
return b.salesT - a.salesT;
});
return out;
}
function renderKpi(all) {
var sumT = totalSales(all, "salesT");
var sumT2 = totalSales(all, "salesT2");
var cnt = 0;
for (var i = 0; i < all.length; i++) {
if (all[i].growth > 0) { cnt++; }
}
var top = all.length ? all.slice().sort(function (a, b) { return b.salesT - a.salesT; })[0] : null;
var avg = sumT ? Math.round(sumT / all.length) : 0;
var revisit = null;
for (var v = 0; v < all.length; v++) {
var seen = state.viewed[all[v].name];
if (seen && seen.sales != null && all[v].salesT !== seen.sales) {
if (!revisit || all[v].salesT < revisit.salesT) { revisit = all[v]; }
}
}
html(
'<div class="kt-ai-kpi"><span class="label">Выручка</span>' +
'<span class="value">' + fmtNum(sumT) + " млн ₸</span><span class=\"hint\">" + all.length + (all.length === 1 || (all.length >= 2 && all.length <= 4) ? " город" : " городов") + "</span></div>" +
'<div class="kt-ai-kpi"><span class="label">Доля в сети</span>' +
'<span class="value">' + (top ? top.name : "—") + "</span><span class=\"hint\">лидер продаж</span></div>" +
'<div class=\"kt-ai-kpi\"><span class=\"label\">Средний город</span>' +
"<span class=\"value\">" + fmtNum(avg) + " млн ₸</span><span class=\"hint\">по " + all.length + " городам</span></div>" +
'<div class="kt-ai-kpi"><span class="label">Растут к прош. периоду</span>' +
"<span class=\"value\">" + cnt + " из " + all.length + "</span><span class=\"hint\">" + (all.length ? Math.round(cnt / all.length * 100) : 0) + "% городов</span></div>" +
(revisit
? '<div class="kt-ai-kpi"><span class="label">Повторно посетите</span>' +
'<span class="value" data-revisit="' + esc(revisit.name) + '" title="Открыть ' + esc(revisit.name) + '">' + esc(revisit.name) + "</span>" +
'<span class="hint">продажи изменились с прошлого просмотра</span></div>'
: "")
);
}
function renderCities() {
var list = filteredCities();
var all = CITIES;
renderKpi(list.length ? list : all);
var maxSum = 0;
var sum = totalSales(all, "salesT");
for (var i = 0; i < all.length; i++) {
maxSum = Math.max(maxSum, all[i].salesT);
}
var rows = "";
for (var r = 0; r < list.length; r++) {
var c = list[r];
var share = sum ? (c.salesT / sum * 100) : 0;
var barW = maxSum ? Math.max(4, Math.round(c.salesT / maxSum * 100)) : 0;
rows +=
'<tr data-city="' + esc(c.name) + '" style="cursor:pointer">' +
'<td><div class="city-cell"><span class="city-name">' + esc(c.name) + "</span>" +
'<span class="city-meta">' + esc(c.region) + " · топ: " + esc(c.top) + "</span></div></td>" +
'<td class="num"><div class="share-cell">' +
'<span class="share-value">' + fmtNum(c.salesT) + " млн ₸</span>" +
'<span class="share-track"><span class="share-fill" style="width:' + barW + '%"></span></span></div></td>' +
"<td class=\"num\">" + growthPill(c.growth) + "</td>" +
'<td class="num">' + fmtNum(c.avgT) + " ₸</td>" +
'<td class="num">' + share.toFixed(1) + "%</td>" +
'<td><span class="row-go" aria-hidden="true">›</span></td>' +
"</tr>";
}
$("cities-tbody").innerHTML = rows;
$("cities-count").textContent = list.length + " из " + all.length + " городов";
var empty = $("cities-empty");
if (!list.length) {
empty.innerHTML =
'<div class="kt-ai-empty">' +
'<svg class="kt-icon kt-icon-lg"><use href="#search"></use></svg>' +
"<p>Ничего не найдено</p>" +
'<button class="kt-ai-btn" data-size="sm" id="btn-reset">Сбросить фильтры</button>' +
"</div>";
var b = $("btn-reset");
if (b) { b.addEventListener("click", function () { resetFilters(); }); }
} else {
empty.innerHTML = "";
}
}
function resetFilters() {
state.q = "";
state.region = "all";
if (el.search) { el.search.value = ""; }
renderCities();
}
function renderRegionChips() {
var regions = [];
for (var i = 0; i < CITIES.length; i++) {
var rg = CITIES[i].region;
if (regions.indexOf(rg) === -1) { regions.push(rg); }
}
var h = '<div class="chips-row kt-ai-filterbar">';
h += chip("Все", "all", state.region === "all");
for (var j = 0; j < regions.length; j++) {
h += chip(regions[j], regions[j], state.region === regions[j]);
}
h += "</div>";
$("filters-cities").innerHTML = h;
}
function chip(label, value, active) {
return '<button class="kt-ai-btn" data-size="sm" data-region="' + esc(value) + '" data-variant="' + (active ? "primary" : "ghost") + '">' + esc(label) + "</button>";
}
/* ================= ДЕТАЛЬНЫЙ ЭКРАН ================= */
function renderCity(name) {
var c = cityByIdx(name);
if (!c) { return; }
state.viewed[c.name] = { sales: c.salesT };
saveState();
var monthly = monthlySales(c);
var maxM = 0;
for (var i = 0; i < monthly.length; i++) { maxM = Math.max(maxM, monthly[i]); }
var bars = "";
for (var b = 0; b < monthly.length; b++) {
var h = maxM ? Math.max(3, Math.round(monthly[b] / maxM * 100)) : 3;
bars += '<div class="cc-bar" tabindex="0" data-kt-tip="' + esc(MONTHS[b]) + ": " + fmtNum(monthly[b]) + ' млн ₸"><i style="height:' + h + '%"></i></div>';
}
var axis = "";
for (var a = 0; a < MONTHS_SHORT.length; a++) { axis += "<span>" + esc(MONTHS_SHORT[a]) + "</span>"; }
var cats = cityCats(c);
var catRows = "";
for (var k = 0; k < cats.length; k++) {
catRows +=
"<tr>" +
'<td><div class="cat-cell"><span class="cat-name">' + esc(cats[k].label) + "</span></div></td>" +
'<td class="num">' + fmtNum(cats[k].share, 1) + "%</td>" +
"</tr>";
}
var tops = topItems(c);
var topRows = "";
for (var t = 0; t < tops.length; t++) {
topRows +=
"<tr>" +
"<td>" + (t + 1) + "</td>" +
"<td>" + esc(tops[t].name) + "<div class=\"cta-bar\"><span style=\"width:" + tops[t].pct + "%\"></span></div></td>" +
"</tr>";
}
var sumT = c.salesT;
var sumT2 = c.salesT2;
var diff = sumT - sumT2;
var trend = diff >= 0
? "Продажи выросли на " + fmtNum(Math.abs(diff)) + " млн ₸ к прошлому периоду."
: "Продажи снизились на " + fmtNum(Math.abs(diff)) + " млн ₸ к прошлому периоду.";
$("city-content").innerHTML =
'<div class="kt-ai-page-title-block">' +
'<button class="kt-ai-btn" data-variant="ghost" data-size="sm" id="btn-back" style="margin-bottom:var(--kt-ai-space-2)"><svg class="kt-icon kt-icon-sm"><use href="#chevronLeft"></use></svg>К городам</button>' +
'<h1 class="kt-ai-h1">' + esc(c.name) + "</h1>" +
'<p class="kt-ai-sub">' + esc(c.region) + " · розничные продажи за отчётный период</p>" +
"</div>" +
'<div class="kt-ai-kpi-strip">' +
'<div class="kt-ai-kpi"><span class="label">Выручка</span><span class="value">' + fmtNum(sumT) + " млн ₸</span><span class=\"hint\">прошлый период: " + fmtNum(sumT2) + " млн ₸</span></div>" +
'<div class="kt-ai-kpi"><span class="label">Динамика</span><span class="value">' + (diff >= 0 ? "+" : "") + fmtNum(diff) + " млн ₸</span><span class=\"hint\">" + diff >= 0 ? "рост" : "снижение" + "</span></div>" +
'<div class="kt-ai-kpi"><span class="label">Чексредний</span><span class="value">' + fmtNum(c.avgT) + " ₸</span><span class=\"hint\">" + fmtNum(c.ticketsT) + " тыс. покупок</span></div>" +
"</div>" +
'<div class="city-grid">' +
'<section class="kt-ai-chart-card">' +
'<div class="cc-head"><div><div class="cc-title">Продажи по месяцам</div>' +
'<div class="cc-sub">млн ₸ · ' + esc(c.name) + "</div></div></div>" +
'<div class="cc-bars">' + bars + "</div>" +
'<div class="cc-axis">' + axis + "</div>" +
"</section>" +
'<section class="kt-ai-chart-card" style="--cc-color:var(--kt-ai-chart-green)">' +
'<div class="cc-head"><div><div class="cc-title">Топ позиций</div>' +
'<div class="cc-sub">доля позиции в продажах города</div></div></div>' +
topRows ? '<div class="kt-ai-table-wrap" data-framed="false"><table class="kt-ai-table" data-density="compact"><tbody>' + topRows + "</tbody></table></div>" : "" +
"</section>" +
"</div>" +
'<section class="kt-ai-chart-card">' +
'<div class="cc-head"><div><div class="cc-title">Продажи по товарным категориям</div>' +
'<div class="cc-sub">доля категории в продажах города</div></div></div>' +
'<div class="kt-ai-table-wrap">' +
'<table class="kt-ai-table"><thead><tr><th>Категория</th><th class="num">Доля</th></tr></thead>' +
"<tbody>" + catRows + "</tbody></table>" +
"</div></section>" +
'<section class="kt-ai-conclusion">' +
'<span class="title">Что показывают эти данные</span>' +
'<span class="text">' + esc(trend) + " Ведущая позиция города — " + esc(c.top.toLowerCase()) + ". " +
(c.growth > 0 ? "Динамика положительная: " : "Динамика отрицательная: ") +
fmtNum(Math.abs(c.growth), 1) + "% к прошлому периоду.</span>" +
"</section>";
}
/* ================= НАВИГАЦИЯ / СОБЫТИЯ ================= */
function setView(v) {
state.view = v;
state.city = v === "city" ? state.city : null;
var main = $("view-cities");
var detail = $("view-city");
var crumb = document.querySelector("[data-crumb]");
if (v === "cities") {
main.hidden = false;
detail.hidden = true;
crumb.innerHTML = "Розничные продажи <strong>/</strong> <strong>Города</strong>";
window.scrollTo(0, 0);
} else {
main.hidden = true;
detail.hidden = false;
crumb.innerHTML = "Розничные продажи <strong>/</strong> <strong>" + esc(state.city || "") + "</strong>";
renderCity(state.city);
window.scrollTo(0, 0);
}
}
function init() {
loadState();
el.search = $("search-cities");
el.cities = $("view-cities");
el.search.addEventListener("input", function () {
state.q = el.search.value.trim();
renderCities();
saveState();
});
var sortWrap = $("sort-cities");
sortWrap.addEventListener("click", function (e) {
var btn = e.target.closest(".seg");
if (!btn) { return; }
state.sort = btn.getAttribute("data-sort");
updateSort();
renderCities();
saveState();
});
el.cities.addEventListener("click", function (e) {
var row = e.target.closest("tr[data-city]");
if (row) {
state.city = row.getAttribute("data-city");
setView("city");
return;
}
var reg = e.target.closest("[data-region]");
if (reg) {
state.region = reg.getAttribute("data-region");
renderRegionChips();
renderCities();
saveState();
}
var revisit = e.target.closest("[data-revisit]");
if (revisit) {
state.city = revisit.getAttribute("data-revisit");
setView("city");
}
});
$("view-city").addEventListener("click", function (e) {
if (e.target.closest("#btn-back")) { setView("cities"); }
});
if (el.search) { el.search.value = state.q; }
if (["sales", "growth", "avg", "share"].indexOf(state.sort) === -1) { state.sort = "sales"; }
renderRegionChips();
renderCities();
updateSort();
}
function updateSort() {
var btns = document.querySelectorAll("#sort-cities .seg");
for (var i = 0; i < btns.length; i++) {
var active = btns[i].getAttribute("data-sort") === state.sort;
btns[i].setAttribute("data-active", active ? "true" : "false");
}
}
function html(s) {
$("kpi-cities").innerHTML = s;
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();