/* Дашборд розничных продаж по городам: данные, фильтры, два экрана */ (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, "&").replace(//g, ">").replace(/"/g, """); } 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 '+' + g.toFixed(1) + "%"; } if (g < 0) { return '' + g.toFixed(1) + "%"; } return '0,0%'; } /* продажи города по месяцам, млн тг */ 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( '
Выручка' + '' + fmtNum(sumT) + " млн ₸" + all.length + (all.length === 1 || (all.length >= 2 && all.length <= 4) ? " город" : " городов") + "
" + '
Доля в сети' + '' + (top ? top.name : "—") + "лидер продаж
" + '
Средний город' + "" + fmtNum(avg) + " млн ₸по " + all.length + " городам
" + '
Растут к прош. периоду' + "" + cnt + " из " + all.length + "" + (all.length ? Math.round(cnt / all.length * 100) : 0) + "% городов
" + (revisit ? '
Повторно посетите' + '' + esc(revisit.name) + "" + 'продажи изменились с прошлого просмотра
' : "") ); } 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 += '' + '
' + esc(c.name) + "" + '' + esc(c.region) + " · топ: " + esc(c.top) + "
" + '
' + '' + fmtNum(c.salesT) + " млн ₸" + '
' + "" + growthPill(c.growth) + "" + '' + fmtNum(c.avgT) + " ₸" + '' + share.toFixed(1) + "%" + '' + ""; } $("cities-tbody").innerHTML = rows; $("cities-count").textContent = list.length + " из " + all.length + " городов"; var empty = $("cities-empty"); if (!list.length) { empty.innerHTML = '
' + '' + "

Ничего не найдено

" + '' + "
"; 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 = '
'; 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 += "
"; $("filters-cities").innerHTML = h; } function chip(label, value, active) { return '"; } /* ================= ДЕТАЛЬНЫЙ ЭКРАН ================= */ 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 += '
'; } var axis = ""; for (var a = 0; a < MONTHS_SHORT.length; a++) { axis += "" + esc(MONTHS_SHORT[a]) + ""; } var cats = cityCats(c); var catRows = ""; for (var k = 0; k < cats.length; k++) { catRows += "" + '
' + esc(cats[k].label) + "
" + '' + fmtNum(cats[k].share, 1) + "%" + ""; } var tops = topItems(c); var topRows = ""; for (var t = 0; t < tops.length; t++) { topRows += "" + "" + (t + 1) + "" + "" + esc(tops[t].name) + "
" + ""; } 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 = '
' + '' + '

' + esc(c.name) + "

" + '

' + esc(c.region) + " · розничные продажи за отчётный период

" + "
" + '
' + '
Выручка' + fmtNum(sumT) + " млн ₸прошлый период: " + fmtNum(sumT2) + " млн ₸
" + '
Динамика' + (diff >= 0 ? "+" : "") + fmtNum(diff) + " млн ₸" + diff >= 0 ? "рост" : "снижение" + "
" + '
Чексредний' + fmtNum(c.avgT) + " ₸" + fmtNum(c.ticketsT) + " тыс. покупок
" + "
" + '
' + '
' + '
Продажи по месяцам
' + '
млн ₸ · ' + esc(c.name) + "
" + '
' + bars + "
" + '
' + axis + "
" + "
" + '
' + '
Топ позиций
' + '
доля позиции в продажах города
' + topRows ? '
' + topRows + "
" : "" + "
" + "
" + '
' + '
Продажи по товарным категориям
' + '
доля категории в продажах города
' + '
' + '' + "" + catRows + "
КатегорияДоля
" + "
" + '
' + 'Что показывают эти данные' + '' + esc(trend) + " Ведущая позиция города — " + esc(c.top.toLowerCase()) + ". " + (c.growth > 0 ? "Динамика положительная: " : "Динамика отрицательная: ") + fmtNum(Math.abs(c.growth), 1) + "% к прошлому периоду." + "
"; } /* ================= НАВИГАЦИЯ / СОБЫТИЯ ================= */ 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 = "Розничные продажи / Города"; window.scrollTo(0, 0); } else { main.hidden = true; detail.hidden = false; crumb.innerHTML = "Розничные продажи / " + esc(state.city || "") + ""; 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(); } })();