dashbord-prodazh-po-regionam/script.js

434 lines
15 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.

// Дашборд: состояние, карта, KPI, график, таблица, фильтры.
// Интерактив (период, поиск, выбор региона, сортировка) — в localStorage.
// Показатели за месяцы детерминированы сидом — стабильны между сессиями.
// ---------- Состояние ----------
var state = {
offset: 0, // 0 = текущий месяц, 1..11 = назад
selected: null, // id выбранного региона (клик по карте)
search: '',
sort: 'actual', // 'actual' | 'percent' | 'name'
seed: ''
};
try {
var ss = localStorage.getItem('kt_dash_seed');
state.seed = ss || String(Date.now());
localStorage.setItem('kt_dash_seed', state.seed);
} catch (e) { state.seed = String(Date.now()); }
window.KT_SEED = state.seed;
try {
var so = parseInt(localStorage.getItem('kt_dash_offset') || '0', 10);
if (!isNaN(so) && so >= 0 && so <= 11) state.offset = so;
state.selected = localStorage.getItem('kt_dash_selected') || null;
state.search = localStorage.getItem('kt_dash_search') || '';
state.sort = localStorage.getItem('kt_dash_sort') || 'actual';
} catch (e) {}
function saveState() {
try {
localStorage.setItem('kt_dash_seed', state.seed);
localStorage.setItem('kt_dash_offset', String(state.offset));
localStorage.setItem('kt_dash_selected', state.selected || '');
localStorage.setItem('kt_dash_search', state.search);
localStorage.setItem('kt_dash_sort', state.sort);
} catch (e) {}
}
// ---------- Данные ----------
var currentData = null;
function loadData() {
currentData = ktMonthData(state.offset);
}
// ---------- Связывание контролов ----------
function bindMonthSelect() {
var sel = document.getElementById('monthSelect');
if (!sel) return;
for (var i = 0; i <= 11; i++) {
var op = document.createElement('option');
op.value = String(i);
op.textContent = ktMonthLabel(i);
sel.appendChild(op);
}
sel.value = String(state.offset);
sel.addEventListener('change', function() {
state.offset = parseInt(sel.value, 10) || 0;
saveState();
render();
});
}
function bindRefresh() {
var btn = document.getElementById('refreshBtn');
if (!btn) return;
var label = btn.innerHTML;
btn.addEventListener('click', function() {
// новый сид -> показатели пересчитываются, дальше снова стабильны
state.seed = String(Date.now());
window.KT_SEED = state.seed;
saveState();
btn.disabled = true;
document.body.style.cursor = 'progress';
setTimeout(function() {
loadData();
render();
btn.disabled = false;
document.body.style.cursor = '';
}, 450);
});
}
function bindSearch() {
var inp = document.getElementById('searchInput');
var clear = document.getElementById('clearSearch');
if (inp) {
inp.value = state.search;
inp.addEventListener('input', function() {
state.search = inp.value;
saveState();
renderTable();
});
}
if (clear) {
clear.addEventListener('click', function() {
state.search = '';
if (inp) inp.value = '';
saveState();
renderTable();
});
}
}
function markSortButtons() {
var btns = document.querySelectorAll('.dash-sort .kt-ai-btn');
for (var i = 0; i < btns.length; i++) {
if (btns[i].getAttribute('data-sort') === state.sort) btns[i].classList.add('is-on');
else btns[i].classList.remove('is-on');
}
}
function bindSort() {
var btns = document.querySelectorAll('.dash-sort .kt-ai-btn');
for (var i = 0; i < btns.length; i++) {
btns[i].addEventListener('click', function() {
state.sort = this.getAttribute('data-sort');
saveState();
markSortButtons();
renderTable();
});
}
markSortButtons();
}
// ---------- Rендер: KPI ----------
function renderKpis() {
var k = currentData.kpi;
var rev = document.getElementById('kpiRevenue');
var plan = document.getElementById('kpiPlan');
var deals = document.getElementById('kpiDeals');
var leaders = document.getElementById('kpiLeaders');
if (rev) rev.textContent = ktFormatMoney(k.fact);
if (plan) plan.textContent = k.percent + '%';
if (deals) deals.textContent = String(k.deals);
if (leaders) leaders.textContent = String(k.leaders);
var rh = document.getElementById('kpiRevenueHint');
var ph = document.getElementById('kpiPlanHint');
var dh = document.getElementById('kpiDealsHint');
if (rh) rh.textContent = currentData.label;
if (ph) ph.textContent = ktFormatMillion(k.fact) + ' / ' + ktFormatMillion(k.plan);
if (dh) dh.textContent = currentData.label;
}
// ---------- Rендер: карта ----------
function buildMap() {
var mapEl = document.getElementById('kzMap');
if (!mapEl) return;
var ns = 'http://www.w3.org/2000/svg';
var frag = document.createDocumentFragment();
for (var i = 0; i < KT_REGIONS.length; i++) {
var r = KT_REGIONS[i];
var pts = KT_MAP[r.id];
if (!pts) continue;
var poly = document.createElementNS(ns, 'polygon');
var str = '';
for (var p = 0; p < pts.length; p++) str += pts[p][0] + ',' + pts[p][1] + ' ';
poly.setAttribute('points', str);
poly.setAttribute('class', 'dash-map-region');
poly.setAttribute('data-id', r.id);
poly.setAttribute('tabindex', '0');
poly.setAttribute('role', 'button');
poly.setAttribute('aria-label', r.name);
frag.appendChild(poly);
}
for (var k = 0; k < KT_REGIONS.length; k++) {
var rr = KT_REGIONS[k];
if (!rr.cx) continue;
var lab = document.createElementNS(ns, 'text');
lab.setAttribute('class', 'dash-map-label');
lab.setAttribute('x', String(rr.cx));
lab.setAttribute('y', String(rr.cy));
lab.textContent = rr.short;
frag.appendChild(lab);
}
mapEl.innerHTML = '';
mapEl.appendChild(frag);
var polys = mapEl.querySelectorAll('.dash-map-region');
for (var b = 0; b < polys.length; b++) {
(function(p) {
var id = p.getAttribute('data-id');
var doSel = function() {
state.selected = (state.selected === id) ? null : id;
saveState();
renderMap();
renderTable();
};
p.addEventListener('click', doSel);
p.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); doSel(); }
});
})(polys[b]);
}
}
function renderMap() {
var mapEl = document.getElementById('kzMap');
if (!mapEl) return;
var dataById = {};
for (var i = 0; i < currentData.rows.length; i++) {
dataById[currentData.rows[i].id] = currentData.rows[i];
}
var polys = mapEl.querySelectorAll('.dash-map-region');
for (var j = 0; j < polys.length; j++) {
var p = polys[j];
var id = p.getAttribute('data-id');
var d = dataById[id];
if (!d) continue;
p.setAttribute('data-tone', d.tone);
if (state.selected === id) p.setAttribute('data-selected', 'true');
else p.removeAttribute('data-selected');
var tip = JSON.stringify({
title: d.name + ' — ' + currentData.label,
rows: [
{ label: 'Факт', value: ktFormatMoney(d.fact) },
{ label: 'План', value: ktFormatMoney(d.plan) },
{ label: '% плана', value: d.percent + '%' }
]
}).replace(/"/g, '&quot;');
p.setAttribute('data-kt-tip', tip);
}
renderMapSelectedBlock();
}
function renderMapSelectedBlock() {
var box = document.getElementById('mapSelected');
if (!box) return;
if (!state.selected) {
box.innerHTML = 'Выберите регион на карте — его показатели подсветятся в списке, и график покажет его детализацию.';
return;
}
var d = null;
for (var i = 0; i < currentData.rows.length; i++) {
if (currentData.rows[i].id === state.selected) { d = currentData.rows[i]; break; }
}
if (!d) { state.selected = null; saveState(); renderMap(); return; }
var st = d.tone === 'ok' ? 'выполнен' : d.tone === 'warn' ? 'близко к плану' : 'ниже плана';
box.innerHTML =
'<strong>' + d.name + '</strong> — ' + d.percent + '% плана ' +
'(факт ' + ktFormatMoney(d.fact) + ', план ' + ktFormatMoney(d.plan) + '), ' +
d.deals + (d.deals === 1 ? ' сделка' : (d.deals < 5 ? ' сделки' : ' сделок')) +
', статус: ' + st;
}
// ---------- Rендер: график ----------
function renderChart() {
var box = document.getElementById('chartBars');
if (!box) return;
var rows;
var hintText;
if (state.selected) {
var one = [];
for (var s = 0; s < currentData.rows.length; s++) {
if (currentData.rows[s].id === state.selected) one.push(currentData.rows[s]);
}
// выбран регион: показываем его столбцы за последние 6 месяцев
var months = [];
for (var mm = 5; mm >= 0; mm--) {
months.push(ktRegionMonth(state.selected, mm));
}
rows = months;
hintText = one.length ? one[0].name + ' · последние 6 месяцев' : '';
} else {
rows = currentData.rows.slice(0, 8);
hintText = 'топ-8 регионов · ' + currentData.label;
}
if (rows.length === 0) { box.innerHTML = ''; return; }
var W = 520, H = 220, padL = 52, padR = 8, padT = 12, padB = 52;
var plotW = W - padL - padR;
var plotH = H - padT - padB;
var maxVal = 0;
for (var i = 0; i < rows.length; i++) if (rows[i].fact > maxVal) maxVal = rows[i].fact;
maxVal = Math.ceil(maxVal / 100) * 100;
if (maxVal < 100) maxVal = 100;
var slot = plotW / rows.length;
var bw = Math.min(26, Math.max(14, slot * 0.5));
var lines = ['<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + W + ' ' + H + '">'];
var steps = 4;
for (var g = 0; g <= steps; g++) {
var gy = (padT + plotH - (plotH * g / steps)).toFixed(1);
var gv = Math.round(maxVal * g / steps);
lines.push('<line class="dcc-grid" x1="' + padL + '" y1="' + gy + '" x2="' + (W - padR) + '" y2="' + gy + '"/>');
lines.push('<text class="dcc-axis" x="' + (padL - 6) + '" y="' + (gy + 3) + '" text-anchor="end">' +
(gv >= 1000 ? (Math.round(gv / 10) / 100).toFixed(1).replace('.', ',') + ' млрд' : gv + ' млн') +
'</text>');
}
for (var b = 0; b < rows.length; b++) {
var r = rows[b];
var bx = (padL + slot * b + (slot - bw) / 2).toFixed(1);
var bh = Math.max(2, plotH * (r.fact / maxVal));
var by = (padT + plotH - bh).toFixed(1);
var fill = r.tone === 'ok' ? 'var(--kt-ai-chart-green)'
: r.tone === 'warn' ? 'var(--kt-ai-chart-orange)'
: 'var(--kt-ai-chart-blue)';
var label = r.short || r.name;
var tipTitle = state.selected ? label + ' · ' + ktMonthLabel(rows.length - 1 - b) : label + ' · ' + currentData.label;
var tip = JSON.stringify({
title: tipTitle,
rows: [
{ label: 'Факт', value: ktFormatMoney(r.fact) },
{ label: 'План', value: ktFormatMoney(r.plan) },
{ label: '% плана', value: r.percent + '%' }
]
}).replace(/"/g, '&quot;');
lines.push('<rect class="dcc-bar" tabindex="0" data-kt-tip="' + tip + '" ' +
'x="' + bx + '" y="' + by + '" width="' + bw.toFixed(1) + '" height="' + bh.toFixed(1) + '" rx="2" fill="' + fill + '"/>');
var lx = (padL + slot * b + slot / 2).toFixed(1);
var ly = H - padB + 16;
lines.push('<text class="dcc-axis" x="' + lx + '" y="' + ly + '" text-anchor="end" transform="rotate(-32 ' + lx + ' ' + ly + ')">' +
(state.selected ? (KT_MONTHS[new Date().getMonth() - (rows.length - 1 - b)] || '').slice(0, 3) : label) + '</text>');
}
lines.push('</svg>');
box.innerHTML = lines.join('');
var hint = document.getElementById('chartHint');
if (hint) hint.textContent = hintText;
}
// ---------- Rендер: таблица ----------
function renderTable() {
var tbody = document.getElementById('regionsTable');
if (!tbody) return;
var countEl = document.getElementById('tableCount');
var empty = document.getElementById('tableEmpty');
var rows = currentData.rows.slice();
var q = state.search.trim().toLowerCase();
if (q) {
rows = rows.filter(function(r) {
return (r.name + ' ' + r.short).toLowerCase().indexOf(q) !== -1;
});
}
if (state.sort === 'name') rows.sort(function(a, b) { return a.name.localeCompare(b.name, 'ru'); });
else if (state.sort === 'percent') rows.sort(function(a, b) { return b.percent - a.percent; });
else rows.sort(function(a, b) { return b.fact - a.fact; });
if (rows.length === 0) {
tbody.innerHTML = '';
if (empty) empty.hidden = false;
if (countEl) countEl.textContent = '0 из ' + currentData.rows.length;
return;
}
if (empty) empty.hidden = true;
if (countEl) countEl.textContent = rows.length + ' из ' + currentData.rows.length;
var html = [];
for (var i = 0; i < rows.length; i++) {
var r = rows[i];
var stText = r.tone === 'ok' ? 'Выполнен' : r.tone === 'warn' ? 'Близко к плану' : 'Ниже плана';
var barW = Math.min(100, r.percent);
var selAttr = (state.selected === r.id) ? ' data-sel="1"' : '';
html.push(
'<tr' + selAttr + ' data-row="' + r.id + '" style="cursor:pointer">' +
'<td class="kt-ai-sticky-first">' + r.name + '</td>' +
'<td class="dash-num">' + ktFormatMoney(r.plan) + '</td>' +
'<td class="dash-num">' + ktFormatMoney(r.fact) + '</td>' +
'<td class="dash-num dash-pct">' + r.percent + '%' +
'<span class="dash-progress" aria-hidden="true"><i data-tone="' + r.tone + '" style="width:' + barW + '%"></i></span></td>' +
'<td class="dash-num">' + r.deals + '</td>' +
'<td><span class="kt-ai-status-pill" data-status="' + r.tone + '">' + stText + '</span></td>' +
'</tr>'
);
}
tbody.innerHTML = html.join('');
// Клик по строке — выбирает регион (синхронно с картой)
var trs = tbody.querySelectorAll('tr');
for (var t = 0; t < trs.length; t++) {
(function(tr) {
tr.addEventListener('click', function() {
var id = tr.getAttribute('data-row');
state.selected = (state.selected === id) ? null : id;
saveState();
renderMap();
renderChart();
renderTable();
});
})(trs[t]);
}
}
// Подсветка выбранной строки таблицы
function markSelectedRow() {
var trs = document.querySelectorAll('#regionsTable tr');
for (var i = 0; i < trs.length; i++) {
if (trs[i].getAttribute('data-row') === state.selected) {
trs[i].setAttribute('data-active', 'true');
} else {
trs[i].removeAttribute('data-active');
}
}
}
// ---------- Rендер всего ----------
function render() {
renderKpis();
renderMap();
renderChart();
renderTable();
}
// ---------- Старт ----------
function init() {
bindMonthSelect();
bindRefresh();
bindSearch();
bindSort();
buildMap();
loadData();
render();
}
document.addEventListener('DOMContentLoaded', init);