// Дашборд: состояние, карта, 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 = '' + d.name + ' — ' + 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 = ['']; 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(''); lines.push('' + (gv >= 1000 ? (Math.round(gv / 10) / 100).toFixed(1).replace('.', ',') + ' млрд' : gv + ' млн') + ''); } 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(''); var lx = (padL + slot * b + slot / 2).toFixed(1); var ly = H - padB + 16; lines.push('' + (state.selected ? (KT_MONTHS[new Date().getMonth() - (rows.length - 1 - b)] || '').slice(0, 3) : label) + ''); } lines.push(''); 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( '' + '' + r.name + '' + '' + ktFormatMoney(r.plan) + '' + '' + ktFormatMoney(r.fact) + '' + '' + r.percent + '%' + '' + '' + r.deals + '' + '' + stText + '' + '' ); } 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);