(function () { 'use strict'; var E = window.BudgetEngine; var LS_KEY = 'bs-demo-params'; var reducedMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; var state = { currency: 'RUB', scenario: 'base', params: E.sanitize({}) }; try { var saved = JSON.parse(localStorage.getItem(LS_KEY) || '{}'); if (saved.params) { state.params = E.sanitize(saved.params); state.currency = saved.currency || 'RUB'; } } catch (e) { /* повреждённый localStorage — работаем на дефолтах */ } /* ── Ползунки ────────────────────────────────────────── */ var CONTROLS = [ { k: 'revenue', label: 'Выручка в месяц', min: 1e6, max: 5e7, step: 1e5, kind: 'money' }, { k: 'prepayShare', label: 'Предоплата клиентов', min: 0, max: 1, step: 0.05, kind: 'pct' }, { k: 'clientDelay', label: 'Отсрочка клиентов', min: 0, max: 90, step: 1, kind: 'days' }, { k: 'purchaseShare', label: 'Закупки, % от выручки', min: 0.1, max: 0.9, step: 0.01, kind: 'pct' }, { k: 'supplierDelay', label: 'Отсрочка поставщикам', min: 0, max: 90, step: 1, kind: 'days' }, { k: 'payroll', label: 'ФОТ в месяц', min: 0, max: 1e7, step: 5e4, kind: 'money' }, { k: 'rent', label: 'Аренда и накладные', min: 0, max: 5e6, step: 5e4, kind: 'money' }, { k: 'taxRate', label: 'Налог с прибыли', min: 0, max: 0.5, step: 0.01, kind: 'pct' }, { k: 'marketing', label: 'Маркетинг в месяц', min: 0, max: 5e6, step: 5e4, kind: 'money' }, { k: 'capex', label: 'Капвложения (в мес. 3)', min: 0, max: 2e7, step: 1e5, kind: 'money' }, { k: 'initialBalance', label: 'Начальный остаток', min: 0, max: 2e7, step: 5e4, kind: 'money' } ]; function fmtMoney(rub, opts) { return E.formatMoney(rub, state.currency, opts); } function fmtCompact(rub) { var v = E.convert(rub, state.currency), s = E.SYMBOLS[state.currency]; var suffix = state.currency === 'RUB' ? ' ₽' : ' ' + s; if (Math.abs(v) >= 1e6) return (v / 1e6).toLocaleString('ru-RU', { maximumFractionDigits: 1 }) + ' млн' + suffix; if (Math.abs(v) >= 1e3) return (v / 1e3).toLocaleString('ru-RU', { maximumFractionDigits: 0 }) + ' тыс' + suffix; return v.toLocaleString('ru-RU', { maximumFractionDigits: 0 }) + suffix; } function fmtVal(kind, v) { if (kind === 'money') return fmtMoney(v); if (kind === 'pct') return Math.round(v * 100) + '%'; return v + ' дн.'; } var controlsBox = document.getElementById('bsControls'); CONTROLS.forEach(function (c) { var div = document.createElement('div'); div.className = 'bs-ctl'; div.innerHTML = '' + ''; controlsBox.appendChild(div); var input = div.querySelector('input'), out = div.querySelector('output'); input.addEventListener('input', function () { state.params[c.k] = parseFloat(input.value); out.textContent = fmtVal(c.kind, state.params[c.k]); state.scenario = 'custom'; syncScenarioButtons(); schedule(); }); }); document.getElementById('bsCredit').addEventListener('change', function () { state.params.creditLine = this.checked; schedule(); }); document.getElementById('bsReset').addEventListener('click', function () { state.params = E.sanitize({}); state.scenario = 'base'; state.currency = 'RUB'; try { localStorage.removeItem(LS_KEY); } catch (e) {} fillInputs(); syncCurrency(); syncScenarioButtons(); schedule(); }); function fillInputs() { CONTROLS.forEach(function (c) { var input = document.getElementById('bs-in-' + c.k); input.value = state.params[c.k]; document.getElementById('bs-out-' + c.k).textContent = fmtVal(c.kind, state.params[c.k]); }); document.getElementById('bsCredit').checked = !!state.params.creditLine; } /* ── Переключатели сценариев и валют ──────────────────── */ var scnBox = document.getElementById('bsScenarios'); function syncScenarioButtons() { [].forEach.call(scnBox.children, function (b) { b.setAttribute('aria-pressed', b.dataset.scn === state.scenario ? 'true' : 'false'); }); } scnBox.addEventListener('click', function (ev) { var b = ev.target.closest('button'); if (!b) return; state.scenario = b.dataset.scn; state.params = E.scenario(state.scenario, state.params.creditLine ? { creditLine: true } : {}); fillInputs(); syncScenarioButtons(); schedule(true); }); var curBox = document.getElementById('bsCurrency'); function syncCurrency() { [].forEach.call(curBox.children, function (b) { b.setAttribute('aria-pressed', b.dataset.cur === state.currency ? 'true' : 'false'); }); } curBox.addEventListener('click', function (ev) { var b = ev.target.closest('button'); if (!b) return; state.currency = b.dataset.cur; syncCurrency(); fillInputs(); schedule(); }); syncCurrency(); syncScenarioButtons(); /* ── Пересчёт с накоплением вызовов (слайдер дёргают часто). rAF выравнивает по кадру, но в свёрнутой/окклюзированной вкладке кадры не производятся — поэтому таймаут 32 мс как запасной путь (отрабатывает тот, кто первым; второй вызов нейтрализуется флагом pending). ── */ var pending = false, rafId = null, timerId = null; function schedule(animate) { pending = true; if (rafId) cancelAnimationFrame(rafId); if (timerId) clearTimeout(timerId); var run = function () { if (!pending) return; pending = false; render(animate); }; rafId = requestAnimationFrame(run); timerId = setTimeout(run, 32); } /* ── Плавная интерполяция чисел карточек (0,4 с, ТЗ §3-7) ── */ var tweens = {}; function tweenText(el, target, fmt) { var from = tweens[el.id] || 0; tweens[el.id] = target; if (reducedMotion || !isFinite(from) || from === 0) { el.textContent = fmt(target); return; } var t0 = performance.now(), DUR = 380; (function step(now) { var k = Math.min(1, (now - t0) / DUR); var v = from + (target - from) * (1 - Math.pow(1 - k, 3)); // easeOutCubic el.textContent = fmt(v); if (k < 1 && tweens[el.id] === target) requestAnimationFrame(step); })(t0); } /* ── Отрисовка ────────────────────────────────────────── */ function render(animate) { var t0 = performance.now(); var res = E.simulate(state.params); var ms = performance.now() - t0; try { localStorage.setItem(LS_KEY, JSON.stringify({ params: state.params, currency: state.currency })); } catch (e) { /* приватный режим — просто не сохраняем */ } renderCards(res, ms); renderMonthly(res); renderWeekly(res); renderTables(res); } function renderCards(res, ms) { var t = res.totals, cards = [ { id: 'np', k: 'Чистая прибыль (год)', v: t.netProfit, cls: t.netProfit >= 0 ? 'pos' : 'neg', s: 'выручка ' + fmtCompact(t.revenue) }, { id: 'of', k: 'Операционный поток', v: t.opFlow, cls: t.opFlow >= 0 ? 'pos' : 'neg', s: 'за год, БДДС' }, { id: 'min', k: 'Мин. остаток (без кредита)', v: t.minRaw === Infinity ? 0 : t.minRaw, cls: t.minRaw < 0 ? 'neg' : 'pos', s: 'по дням года' }, { id: 'gap', k: 'Кассовый разрыв', text: t.hasGap ? ('мес. ' + t.gapMonths.join(', ')) : 'нет', cls: t.hasGap ? 'neg' : 'pos', s: t.hasGap ? 'остаток уходил ниже нуля' : 'остаток положительный весь год' }, { id: 'loan', k: 'Пик долга по линии', v: state.params.creditLine ? t.maxLoan : 0, cls: 'cred', s: state.params.creditLine ? ('выдачи ' + fmtCompact(t.loanDraw)) : 'линия выключена', money: true }, { id: 'int', k: 'Проценты банку', v: t.interest, cls: t.interest > 0 ? 'cred' : 'pos', s: 'за год, 18% годовых' } ]; var box = document.getElementById('bsCards'); if (!box.children.length) { cards.forEach(function (c) { var d = document.createElement('div'); d.className = 'bs-card'; d.innerHTML = '
' + c.k + '
'; box.appendChild(d); }); } cards.forEach(function (c, i) { var el = document.getElementById('bs-tw-' + c.id); el.className = 'bs-v ' + c.cls; if (c.text != null) el.textContent = c.text; else tweenText(el, c.v, function (v) { return fmtCompact(v); }); box.children[i].querySelector('.bs-s').textContent = c.s; }); document.getElementById('bsLegendCredit').hidden = !state.params.creditLine; } /* Общий конструктор столбчатого SVG-графика */ function barChart(rows, opts) { var n = rows.length, W = opts.width, H = opts.height, mid = H / 2; var maxV = 1; rows.forEach(function (r) { maxV = Math.max(maxV, r.up, r.down, Math.abs(r.line), Math.abs(r.line2 || 0)); }); var k = (H / 2 - 26) / maxV; var colW = W / n, barW = Math.max(3, colW * 0.32); var parts = []; parts.push(''); // подписи шкалы [[mid - 20, maxV], [mid + 20, -maxV]].forEach(function (p) { parts.push('' + fmtCompact(p[1]) + ''); }); var linePts = [], line2Pts = []; rows.forEach(function (r, i) { var x = i * colW + colW / 2; parts.push('' + r.tip + '' + '' + '' + (r.gap ? '' : '') + '' + r.label + ''); linePts.push(x.toFixed(1) + ',' + (mid - r.line * k).toFixed(1)); if (r.line2 != null) line2Pts.push(x.toFixed(1) + ',' + (mid - r.line2 * k).toFixed(1)); }); if (line2Pts.length) { parts.push(''); } parts.push(''); return '' + parts.join('') + ''; } function renderMonthly(res) { var rows = res.months.map(function (m) { return { label: 'М' + m.index, up: m.receipts, down: m.payments, line: m.rawBalanceEnd, line2: state.params.creditLine ? m.balanceEnd : null, gap: m.gap, tip: 'Месяц ' + m.index + '\nПоступления: ' + fmtMoney(m.receipts) + '\nПлатежи: ' + fmtMoney(m.payments) + '\nОстаток (без кредита): ' + fmtMoney(m.rawBalanceEnd) + (state.params.creditLine ? '\nОстаток (с линией): ' + fmtMoney(m.balanceEnd) + '\nДолг: ' + fmtMoney(m.loanEnd) : '') + '\nЧистая прибыль: ' + fmtMoney(m.netProfit) }; }); document.getElementById('bsChartMonthly').setAttribute('aria-label', 'Столбчатая диаграмма БДДС за 12 месяцев. Кассовые разрывы: ' + (res.totals.hasGap ? 'месяцы ' + res.totals.gapMonths.join(', ') : 'отсутствуют') + '. Точные значения — в таблице «БДДС по месяцам» ниже.'); document.getElementById('bsChartMonthly').innerHTML = barChart(rows, { width: 940, height: 320, upColor: '#10B981', downColor: '#DC2626', lineColor: '#5EEAD4', line2Color: '#60A5FA', gapColor: '#F87171' }); } function renderWeekly(res) { var rows = res.weeks.map(function (w) { return { label: w.index % 4 === 0 ? String(w.index) : '', up: w.receipts, down: w.payments, line: w.minRaw === Infinity ? 0 : w.minRaw, // худший остаток внутри недели line2: state.params.creditLine ? w.balanceEnd : null, gap: w.gap, tip: 'Неделя ' + w.index + '\nПоступления: ' + fmtMoney(w.receipts) + '\nПлатежи: ' + fmtMoney(w.payments) + '\nМин. остаток недели (без кредита): ' + (w.minRaw === Infinity ? '—' : fmtMoney(w.minRaw)) }; }); document.getElementById('bsChartWeekly').setAttribute('aria-label', 'Платёжный календарь на 52 недели. Разрывы в неделях: ' + (res.totals.hasGap ? String(res.weeks.filter(function (w) { return w.gap; }).length) + ' нед.' : 'нет') + '.'); document.getElementById('bsChartWeekly').innerHTML = barChart(rows, { width: 1560, height: 280, upColor: '#10B981', downColor: '#DC2626', lineColor: '#5EEAD4', line2Color: '#60A5FA', gapColor: '#F87171' }); } function renderTables(res) { var t = res.totals; var pnl = [ ['Выручка', t.revenue, ''], ['Себестоимость закупок', -t.cogs, ''], ['Валовая прибыль', t.gross, t.gross >= 0 ? 'pos' : 'neg'], ['Коммерческие расходы (маркетинг)', -t.commercial, ''], ['Управленческие расходы (ФОТ + аренда)', -t.admin, ''], ['Операционная прибыль', t.opProfit, t.opProfit >= 0 ? 'pos' : 'neg'], ['Налоги', -t.tax, ''], ['Проценты по кредитной линии', -t.interest, ''], ['Чистая прибыль', t.netProfit, t.netProfit >= 0 ? 'pos' : 'neg'] ]; document.getElementById('bsPnlTable').innerHTML = 'БДР за годСумма' + pnl.map(function (r) { return '' + r[0] + '' + fmtMoney(Math.abs(r[1])) + ''; }).join('') + ''; document.getElementById('bsCfTable').innerHTML = 'Мес.ПоступленияПоставщикиФОТАрендаМаркет.' + 'НалогиКапвл.ПроцентыОстатокДолг' + res.months.map(function (m) { return '' + m.index + (m.gap ? ' ⚠' : '') + '' + '' + fmtMoney(m.receipts) + '' + '' + fmtMoney(m.paySuppliers) + '' + fmtMoney(m.payPayroll) + '' + '' + fmtMoney(m.payRent) + '' + fmtMoney(m.payMarketing) + '' + '' + fmtMoney(m.payTax) + '' + fmtMoney(m.payCapex) + '' + '' + fmtMoney(m.payInterest) + '' + '' + fmtMoney(m.rawBalanceEnd) + '' + '' + fmtMoney(m.loanEnd) + ''; }).join('') + ''; } fillInputs(); render(); })();