// Дашборд розничных продаж по городам — статика + localStorage. // Данные — правдоподобные примеры для демонстрации устройства экрана. const LS_KEY = "kt-retail-dashboard-v1"; // Месяцы: 12 последних, завершающихся августом 2026 const MONTHS = [ { key: "2025-09", label: "Сен 25" }, { key: "2025-10", label: "Окт 25" }, { key: "2025-11", label: "Ноя 25" }, { key: "2025-12", label: "Дек 25" }, { key: "2026-01", label: "Янв 26" }, { key: "2026-02", label: "Фев 26" }, { key: "2026-03", label: "Мар 26" }, { key: "2026-04", label: "Апр 26" }, { key: "2026-05", label: "Май 26" }, { key: "2026-06", label: "Июн 26" }, { key: "2026-07", label: "Июл 26" }, { key: "2026-08", label: "Авг 26" }, ]; // Розничные продажи по городам. revenue в тг, по месяцам (12 значений). // orders — количество точек (розничных салонов), avg — средний чек (вычислим). const CITIES = [ { id: "almaty", name: "Алматы", region: "Алматинский регион", stores: 34, revenue: [218.4, 242.1, 265.8, 312.0, 228.5, 235.2, 251.7, 268.9, 275.4, 289.6, 301.2, 318.7] }, { id: "astana", name: "Астана", region: "Астанинский регион", stores: 28, revenue: [152.6, 168.3, 182.5, 214.8, 161.2, 165.9, 178.4, 189.2, 194.7, 203.5, 211.8, 226.3] }, { id: "shymkent", name: "Шымкент", region: "Южно-Казахстанская область", stores: 18, revenue: [98.2, 104.7, 110.3, 128.6, 99.5, 102.1, 108.9, 114.3, 117.8, 121.5, 126.9, 134.2] }, { id: "karaganda", name: "Караганда", region: "Карагандинская область", stores: 12, revenue: [54.8, 58.1, 61.4, 70.9, 55.2, 56.7, 60.3, 63.8, 65.4, 67.9, 70.2, 74.5] }, { id: "aktobe", name: "Актобе", region: "Актюбинская область", stores: 9, revenue: [38.4, 41.2, 43.6, 50.8, 38.9, 40.1, 42.7, 44.9, 46.1, 47.8, 49.6, 52.3] }, { id: "taraz", name: "Тараз", region: "Жамбылская область", stores: 8, revenue: [32.1, 34.6, 36.8, 42.9, 32.5, 33.7, 35.9, 37.8, 38.9, 40.3, 41.9, 44.6] }, { id: "pavlodar", name: "Павлодар", region: "Павлодарская область", stores: 7, revenue: [26.5, 28.3, 30.1, 35.2, 26.8, 27.6, 29.4, 31.0, 32.0, 33.4, 34.7, 36.8] }, { id: "ust-kamenogorsk", name: "Усть-Каменогорск", region: "Восточно-Казахстанская область", stores: 6, revenue: [19.7, 21.2, 22.8, 26.6, 19.9, 20.6, 22.1, 23.4, 24.2, 25.3, 26.4, 27.9] }, ]; // --- Форматирование const nf = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 }); const nf1 = new Intl.NumberFormat("ru-RU", { minimumFractionDigits: 1, maximumFractionDigits: 1 }); function fmtRev(v) { // млн тг return nf1.format(v) + " млн ₸"; } function pct(v) { return (v >= 0 ? "+" : "") + nf1.format(v) + "%"; } function lastIdx() { return MONTHS.length - 1; } function prevIdx() { return MONTHS.length - 2; } // --- Состояние function loadState() { try { const raw = localStorage.getItem(LS_KEY); if (raw) return Object.assign({ region: "all", period: "12m" }, JSON.parse(raw)); } catch (e) { /* ignore */ } return { region: "all", period: "12m" }; } function saveState(st) { try { localStorage.setItem(LS_KEY, JSON.stringify(st)); } catch (e) { /* ignore */ } } let state = loadState(); // --- Вычисления function citiesByRegion(region) { if (!region || region === "all") return CITIES; return CITIES.filter(c => c.region === region); } function kpisFor(cities) { const cur = cities.reduce((s, c) => s + c.revenue[lastIdx()], 0); const prev = cities.reduce((s, c) => s + c.revenue[prevIdx()], 0); const delta = prev ? ((cur - prev) / prev) * 100 : 0; const stores = cities.reduce((s, c) => s + c.stores, 0); // средний чек = выручка / (оценка заказов). orders ≈ stores * 240 в месяц. const orders = stores * 240; const avgChek = orders ? (cur * 1_000_000) / orders : 0; return { cur, prev, delta, stores, avgChek }; } function shareFor(city, total) { return total ? (city.revenue[lastIdx()] / total) * 100 : 0; } function cityDelta(city) { const c = city.revenue[lastIdx()], p = city.revenue[prevIdx()]; return p ? ((c - p) / p) * 100 : 0; } // --- Рендер KPI function renderKPIs(cities) { const k = kpisFor(cities); const strip = document.getElementById("kpi-strip"); const top = [...cities].sort((a,b) => b.revenue[lastIdx()] - a.revenue[lastIdx()])[0]; const topShare = shareFor(top, k.cur); const deltaCls = k.delta > 0.05 ? "up" : (k.delta < -0.05 ? "down" : "flat"); strip.innerHTML = `
Выручка за месяц ${fmtRev(k.cur)} ${pct(k.delta)} к июлю
Точек розницы ${nf.format(k.stores)} ${cities.length} города
На точку ${nf.format(Math.round(k.avgChek))} ₸ выручка в месяц
Лидер ${top ? top.name : "—"} ${top ? nf1.format(topShare) + "% выручки" : ""}
`; } // --- Рендер графика (столбцы) function renderChart(elId, series, opts) { const el = document.getElementById(elId); if (!el || !series.length) { if (el) el.innerHTML = ""; return; } const max = Math.max(...series.map(s => s.value), 1); const bars = series.map(s => { const h = Math.max(2, (s.value / max) * 100); return `
`; }).join(""); const labels = series.map(s => `
${s.label}
`).join(""); el.innerHTML = `
${bars}
${labels}
`; } function buildPeriodSeries(cities, period) { const n = period === "3m" ? 3 : period === "6m" ? 6 : 12; const start = MONTHS.length - n; return MONTHS.slice(start).map((m, i) => { const v = cities.reduce((s, c) => s + c.revenue[start + i], 0); const prev = start + i > 0 ? cities.reduce((s, c) => s + c.revenue[start + i - 1], 0) : null; const delta = prev ? ((v - prev) / prev) * 100 : null; return { label: m.label, title: m.label, value: v, tip: fmtRev(v) + (delta !== null ? ` · ${pct(delta)}` : ""), }; }); } // --- Рендер таблицы function renderTable(cities, total) { const tbody = document.getElementById("cities-tbody"); const sorted = [...cities].sort((a,b) => b.revenue[lastIdx()] - a.revenue[lastIdx()]); tbody.innerHTML = sorted.map(c => { const rev = c.revenue[lastIdx()]; const d = cityDelta(c); const dCls = d > 0.05 ? "up" : (d < -0.05 ? "down" : "flat"); const share = shareFor(c, total); const orders = c.stores * 240; const avg = orders ? (rev * 1_000_000) / orders : 0; return `
${c.name}
${c.region}
${c.region} ${fmtRev(rev)} ${pct(d)}
${nf1.format(share)}% ${nf.format(Math.round(avg))} ₸ `; }).join(""); return sorted; } function renderTableFoot(cities) { const foot = document.getElementById("table-foot"); const ups = cities.filter(c => cityDelta(c) > 0.05).length; const downs = cities.filter(c => cityDelta(c) < -0.05).length; foot.innerHTML = ` ${ups} растут к июлю ${downs} снижаются Показано ${cities.length} из ${CITIES.length} городов `; } // --- Вывод раздела function renderConclusion(cities, total) { const sorted = [...cities].sort((a,b) => b.revenue[lastIdx()] - a.revenue[lastIdx()]); const top = sorted[0]; const second = sorted[1]; const best = [...cities].sort((a,b) => cityDelta(b) - cityDelta(a))[0]; const totalShare = top ? shareFor(top, total) : 0; const top2 = (top && second) ? shareFor(top, total) + shareFor(second, total) : totalShare; document.getElementById("conclusion-text").textContent = `${top ? top.name : "Город"} даёт ${nf1.format(totalShare)}% розничной выручки, ` + `${top && second ? "вместе со " + second.name + " — " + nf1.format(top2) + "% " : ""}` + `остальных ${cities.length - 2 >= 1 ? cities.length - 2 : cities.length} городов. ` + `Быстрее всех растёт ${best ? best.name : "—"} (${pct(cityDelta(best))} к июлю).`; } // --- Регионы для фильтра function populateRegions() { const sel = document.getElementById("region-filter"); const regions = [...new Set(CITIES.map(c => c.region))].sort((a,b) => a.localeCompare(b, "ru")); regions.forEach(r => { const o = document.createElement("option"); o.value = r; o.textContent = r; sel.appendChild(o); }); } // --- Модалка города let modalOpen = false; function openCityDetail(id) { const c = CITIES.find(x => x.id === id); if (!c) return; const tpl = document.getElementById("city-detail-tpl"); const html = tpl.innerHTML; const overlay = document.createElement("div"); overlay.className = "dash-modal-overlay dash-modal"; overlay.innerHTML = html; document.body.appendChild(overlay); overlay.querySelector("#d-title").textContent = c.name; overlay.querySelector("#d-region").textContent = c.region + " · " + c.stores + " точек розницы"; const kpis = overlay.querySelector("#d-kpis"); const rev = c.revenue[lastIdx()]; const d = cityDelta(c); const dCls = d > 0.05 ? "up" : (d < -0.05 ? "down" : "flat"); const orders = c.stores * 240; const avg = orders ? (rev * 1_000_000) / orders : 0; kpis.innerHTML = `
Выручка${fmtRev(rev)}${pct(d)} к июлю
Точек${c.stores}
На точку${nf.format(Math.round(avg))} ₸
Год всего${fmtRev(c.revenue.reduce((a,b)=>a+b,0))}
`; // мини-график города: последние 12 мес const series = MONTHS.map((m, i) => { const v = c.revenue[i]; const prev = i > 0 ? c.revenue[i-1] : null; const delta = prev ? ((v - prev) / prev) * 100 : null; return { label: m.label, title: m.label, value: v, tip: fmtRev(v) + (delta !== null ? " · " + pct(delta) : "") }; }); renderChart("d-chart", series); const close = () => { overlay.remove(); document.removeEventListener("keydown", onKey); modalOpen = false; }; const onKey = (e) => { if (e.key === "Escape") close(); }; overlay.querySelector("[data-close]").addEventListener("click", close); overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); document.addEventListener("keydown", onKey); modalOpen = true; } // --- Главный рендер function render() { const cities = citiesByRegion(state.region); const total = kpisFor(cities).cur; renderKPIs(cities); renderChart("chart", buildPeriodSeries(cities, state.period)); const sorted = renderTable(cities, total); renderTableFoot(cities); renderConclusion(cities, total); // scope line const regionLabel = state.region === "all" ? "6 регионов" : "1 регион"; document.getElementById("scope-line").textContent = `${cities.length} город${cities.length === 1 ? "" : cities.length < 5 ? "а" : "ов"} · ${regionLabel} · август 2026`; // sync controls document.getElementById("region-filter").value = state.region; document.querySelectorAll("#period-seg .seg").forEach(s => { s.dataset.active = s.dataset.period === state.period ? "true" : "false"; }); } // --- События function bind() { document.getElementById("region-filter").addEventListener("change", (e) => { state.region = e.target.value; saveState(state); render(); }); document.querySelectorAll("#period-seg .seg").forEach(seg => { seg.addEventListener("click", () => { state.period = seg.dataset.period; saveState(state); render(); }); }); document.getElementById("cities-tbody").addEventListener("click", (e) => { const tr = e.target.closest("tr[data-city]"); if (tr) openCityDetail(tr.dataset.city); }); } // --- Init document.addEventListener("DOMContentLoaded", () => { populateRegions(); bind(); render(); });