dashbord-roznichnyh-prodazh/script.js

302 lines
14 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.

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