/* The centerpiece: interactive "What if?" dashboard.
Looks like a real working tool. Driven by 4 sliders that recompute KPIs + chart live. */
const R = window.Recharts;
const MONTHS = ["Янв","Фев","Мар","Апр","Май","Июн","Июл","Авг","Сен","Окт","Ноя","Дек"];
/* Base business state (rounded, plausible for ~25-50 person company) */
const BASE = {
monthlyRevenue: 4_200_000, // ₽
revenueGrowthMoM: 0.012, // 1.2% mom
cogsRatio: 0.42, // себестоимость
fixedOpex: 1_350_000, // ФОТ + аренда + прочее
marketingBase: 280_000, // маркетинг
avgCheck: 84_000,
seasonality: [0.92,0.95,1.02,1.05,1.08,1.10,0.96,0.93,1.04,1.07,1.04,0.98],
taxRate: 0.06, // УСН 6% упрощённо
};
function buildSeries({ revenueDelta, marketingDelta, hires, checkDelta }) {
const data = [];
let cashBalance = 1_800_000;
let scnCashBalance = 1_800_000;
const newOpexFromHires = hires * 110_000; // средняя стоимость найма + ФОТ в месяц
for (let i = 0; i < 12; i++) {
const seasonalFactor = BASE.seasonality[i];
const growth = Math.pow(1 + BASE.revenueGrowthMoM, i);
// base
const bRev = BASE.monthlyRevenue * growth * seasonalFactor;
const bCogs = bRev * BASE.cogsRatio;
const bOpex = BASE.fixedOpex + BASE.marketingBase;
const bGross = bRev - bCogs;
const bEbit = bGross - BASE.fixedOpex - BASE.marketingBase;
const bTax = bRev * BASE.taxRate;
const bNet = bEbit - bTax;
cashBalance += bNet;
// scenario
const checkBoost = 1 + (checkDelta / 100) * 0.6; // повышение чека не масштабируется один-к-одному
const sRev = bRev * (1 + revenueDelta / 100) * checkBoost;
const sCogs = sRev * BASE.cogsRatio;
const sMarketing = BASE.marketingBase * (1 + marketingDelta / 100);
const sOpex = BASE.fixedOpex + sMarketing + newOpexFromHires;
const sGross = sRev - sCogs;
const sEbit = sGross - sOpex;
const sTax = sRev * BASE.taxRate;
const sNet = sEbit - sTax;
scnCashBalance += sNet;
data.push({
m: MONTHS[i],
base: Math.round(bNet),
scn: Math.round(sNet),
baseCash: Math.round(cashBalance),
scnCash: Math.round(scnCashBalance),
rev: Math.round(sRev),
gross: Math.round(sGross),
});
}
return data;
}
function sum(arr, key) { return arr.reduce((a, r) => a + r[key], 0); }
const Slider = ({ label, value, setValue, min, max, step, suffix = "%", help }) => (
{value > 0 ? "+" : ""}{value}{suffix}
setValue(parseFloat(e.target.value))} aria-label={label} />
{min}{suffix} 0 +{max}{suffix}
);
function KPI({ label, base, scn, format = fmtK, suffix = "₽", positive = true }) {
const delta = scn - base;
const pct = base === 0 ? 0 : (delta / Math.abs(base)) * 100;
const goodSign = positive ? delta >= 0 : delta <= 0;
return (
{label}
{delta >= 0 ? "▲" : "▼"} {pct.toFixed(1).replace('.', ',')}%
vs база
);
}
function WhatIfDashboard() {
const [revenueDelta, setRevenueDelta] = useState(20);
const [marketingDelta, setMarketingDelta] = useState(15);
const [hires, setHires] = useState(1);
const [checkDelta, setCheckDelta] = useState(8);
const [tab, setTab] = useState("cash"); // cash | pl | scenarios
const base = useMemo(() => buildSeries({ revenueDelta: 0, marketingDelta: 0, hires: 0, checkDelta: 0 }), []);
const scn = useMemo(() => buildSeries({ revenueDelta, marketingDelta, hires, checkDelta }), [revenueDelta, marketingDelta, hires, checkDelta]);
const merged = base.map((r, i) => ({ ...r, base: r.base, baseCash: r.baseCash, scnCash: scn[i].scnCash, scn: scn[i].scn, rev: scn[i].rev, gross: scn[i].gross }));
const kpis = {
revBase: sum(base, "base") + sum(base, "base") * 0, // placeholder for revenue (use rev from each)
};
const annualRevBase = base.reduce((a, r) => a + (r.base + (BASE.fixedOpex + BASE.marketingBase) + r.base*0), 0); // not used
const annualRev = {
base: base.reduce((a) => a, 0) // placeholder
};
// Better KPI math: recompute totals directly
const totBase = base.reduce((a, r) => ({ rev: a.rev + 0, net: a.net + r.base, cash: r.baseCash }), { rev: 0, net: 0, cash: base[base.length-1].baseCash });
const totScn = scn .reduce((a, r) => ({ rev: a.rev + r.rev, net: a.net + r.scn, cash: r.scnCash }), { rev: 0, net: 0, cash: scn[scn.length-1].scnCash });
// recompute base revenue:
let baseRev = 0, baseGross = 0;
for (let i = 0; i < 12; i++) {
const r = BASE.monthlyRevenue * Math.pow(1 + BASE.revenueGrowthMoM, i) * BASE.seasonality[i];
baseRev += r; baseGross += r * (1 - BASE.cogsRatio);
}
totBase.rev = baseRev;
const scnGross = scn.reduce((a, r) => a + r.gross, 0);
const cashFloor = (arr, key) => Math.min(...arr.map(r => r[key]));
const dipBase = cashFloor(base, "baseCash");
const dipScn = cashFloor(scn, "scnCash");
/* Scenario presets */
const presets = [
{ id: "base", label: "Базовый", set: () => { setRevenueDelta(0); setMarketingDelta(0); setHires(0); setCheckDelta(0); } },
{ id: "growth", label: "+20% продаж", set: () => { setRevenueDelta(20); setMarketingDelta(15); setHires(1); setCheckDelta(8); } },
{ id: "stress", label: "Стресс −15%", set: () => { setRevenueDelta(-15); setMarketingDelta(-10); setHires(0); setCheckDelta(-5); } },
{ id: "scale", label: "Масштаб", set: () => { setRevenueDelta(40); setMarketingDelta(30); setHires(3); setCheckDelta(12); } },
];
const activePreset =
revenueDelta===0 && marketingDelta===0 && hires===0 && checkDelta===0 ? "base"
: revenueDelta===20 && marketingDelta===15 && hires===1 && checkDelta===8 ? "growth"
: revenueDelta===-15 && marketingDelta===-10 && hires===0 && checkDelta===-5 ? "stress"
: revenueDelta===40 && marketingDelta===30 && hires===3 && checkDelta===12 ? "scale"
: "custom";
return (
{/* Top chrome — looks like a real app */}
/
модель
/
ремонт-сервис · 2026
{["cash","pl","scenarios"].map((t) => (
setTab(t)}
className={"relative px-3 py-1.5 rounded-md transition-colors " + (tab === t ? "tab-active" : "text-paper/55 hover:text-paper/85")}>
{t === "cash" ? "Денежный поток" : t === "pl" ? "P&L по месяцам" : "Сравнение сценариев"}
))}
{/* KPIs */}
{/* Chart + controls */}
{/* preset chips + period */}
{presets.map(p => (
{p.label}
))}
{activePreset === "custom" && (
· свой сценарий
)}
База
Сценарий
{tab === "cash" ? (
(v/1_000_000).toFixed(1) + " млн"} axisLine={false} tickLine={false} />
fmt(v) + " ₽"} labelClassName="text-mute" contentStyle={{ background: "#fff" }}/>
) : tab === "pl" ? (
(v/1_000_000).toFixed(1) + " млн"} axisLine={false} tickLine={false} />
fmt(v) + " ₽"} />
) : (
(v/1_000_000).toFixed(0) + " млн"} axisLine={false} tickLine={false} />
fmt(v) + " ₽"} />
)}
{/* footnote */}
●
Модель пересчитывает 12 месяцев на лету. Это упрощённый демо-расчёт; в реальной работе модель строится
на ваших данных из 1С, банков и Excel — учитывает сезонность, кассовые разрывы и налоговый режим.
{/* Controls */}
Параметры сценария
Двигайте бегунки — KPI и графики пересчитаются мгновенно.
Δ Чистая прибыль
= 0 ? "text-emerald-700" : "text-danger")}>
{totScn.net - totBase.net >= 0 ? "+" : "−"}{fmt(Math.abs(totScn.net - totBase.net))} ₽
Кассовый разрыв
= 0 ? "text-emerald-700" : "text-danger")}>
{dipScn >= 0 ? "не возникает" : "− " + fmtK(Math.abs(dipScn)) + " ₽"}
);
}
window.WhatIfDashboard = WhatIfDashboard;