(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 = '