// Дашборд розничных продаж по городам — статика + 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();
});