302 lines
14 KiB
JavaScript
302 lines
14 KiB
JavaScript
// Дашборд розничных продаж по городам — статика + 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 = `
|
||
<div class="kt-ai-kpi">
|
||
<span class="label">Выручка за месяц</span>
|
||
<span class="value" data-long="true">${fmtRev(k.cur)}</span>
|
||
<span class="kpi-delta ${deltaCls}">${pct(k.delta)} к июлю</span>
|
||
</div>
|
||
<div class="kt-ai-kpi">
|
||
<span class="label">Точек розницы</span>
|
||
<span class="value">${nf.format(k.stores)}</span>
|
||
<span class="hint">${cities.length} города</span>
|
||
</div>
|
||
<div class="kt-ai-kpi">
|
||
<span class="label">На точку</span>
|
||
<span class="value" data-long="true">${nf.format(Math.round(k.avgChek))} ₸</span>
|
||
<span class="hint">выручка в месяц</span>
|
||
</div>
|
||
<div class="kt-ai-kpi">
|
||
<span class="label">Лидер</span>
|
||
<span class="value" data-long="true">${top ? top.name : "—"}</span>
|
||
<span class="hint">${top ? nf1.format(topShare) + "% выручки" : ""}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// --- Рендер графика (столбцы)
|
||
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 `<div class="cc-bar-col">
|
||
<div class="cc-bar" style="height:${h}%" tabindex="0"
|
||
data-kt-tip='${JSON.stringify({ title: s.title, rows: [{ label: "Выручка", value: s.tip }] })}'></div>
|
||
</div>`;
|
||
}).join("");
|
||
const labels = series.map(s => `<div class="cc-label">${s.label}</div>`).join("");
|
||
el.innerHTML = `<div class="cc-bars">${bars}</div><div class="cc-labels">${labels}</div>`;
|
||
}
|
||
|
||
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 `<tr data-city="${c.id}">
|
||
<td><div class="city-name">${c.name}</div><div class="city-region">${c.region}</div></td>
|
||
<td class="city-region">${c.region}</td>
|
||
<td class="num">${fmtRev(rev)}</td>
|
||
<td class="num"><span class="delta-cell ${dCls}">${pct(d)}</span></td>
|
||
<td class="num"><span class="share-bar"><div style="width:${Math.min(100, share)}%"></div></span>${nf1.format(share)}%</td>
|
||
<td class="num">${nf.format(Math.round(avg))} ₸</td>
|
||
</tr>`;
|
||
}).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 = `
|
||
<span><span class="dot up-dot"></span>${ups} растут к июлю</span>
|
||
<span><span class="dot down-dot"></span>${downs} снижаются</span>
|
||
<span class="kt-ai-spacer"></span>
|
||
<span>Показано ${cities.length} из ${CITIES.length} городов</span>
|
||
`;
|
||
}
|
||
|
||
// --- Вывод раздела
|
||
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 = `
|
||
<div class="kt-ai-kpi"><span class="label">Выручка</span><span class="value" data-long="true">${fmtRev(rev)}</span><span class="kpi-delta ${dCls}">${pct(d)} к июлю</span></div>
|
||
<div class="kt-ai-kpi"><span class="label">Точек</span><span class="value">${c.stores}</span></div>
|
||
<div class="kt-ai-kpi"><span class="label">На точку</span><span class="value" data-long="true">${nf.format(Math.round(avg))} ₸</span></div>
|
||
<div class="kt-ai-kpi"><span class="label">Год всего</span><span class="value" data-long="true">${fmtRev(c.revenue.reduce((a,b)=>a+b,0))}</span></div>
|
||
`;
|
||
// мини-график города: последние 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();
|
||
});
|