434 lines
15 KiB
JavaScript
434 lines
15 KiB
JavaScript
// Дашборд: состояние, карта, 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, '"');
|
||
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, '"');
|
||
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);
|