// screens-roi.jsx — ROI Calculator screen (CP-16) // // Calculation flow (migrated to Python backend): // 1. User changes any input → debounce fires after 400 ms. // 2. Keeper.api.calculateRoi(inputs) sends POST /api/v1/roi/calculate to the // api_gateway, which proxies to the reporting_service Python domain logic. // 3. On success the backend result replaces the local result state. // 4. On network/backend error the screen falls back silently to the local // Keeper.roi.calculate() formula so the UI remains interactive. // // Local formula (Keeper.roi.calculate) is still used for: // - The initial synchronous render before the first API response arrives. // - Any transient backend failure (debounce cycle restores backend path). // // The formula note at the bottom now reflects the backend source. // // Pattern mirrors other screens (screens-overview.jsx, screens-work.jsx): // - React.useState / React.useEffect for local state // - Keeper.api.calculateRoi for async backend calculation // - Keeper.roi.calculate as synchronous fallback // - Keeper.roi.formatNumber() for consistent number display // - Exported to window.RoiCalculator so App.jsx can reference it /* ─── CP-16 ROI Calculator ────────────────────────────────── */ function RoiCalculator() { // ── Demo defaults (snake_case keys match the backend POST body) ────────── const DEFAULTS = { hectares: 4, yield_per_ha: 280, price_per_ton: 900, gain_pct: 6, cost_per_ha: 38000, }; const [inputs, setInputs] = React.useState(DEFAULTS); // result: current calculation output. // Initialised synchronously from the local formula so the first render is // instant; replaced by the backend response once the first API call returns. const [result, setResult] = React.useState(() => _localCalc(DEFAULTS)); // Loading / error state — user feedback without blocking the UI. const [loading, setLoading] = React.useState(false); const [backendError, setBackendError] = React.useState(null); const fmt = Keeper.roi.formatNumber; // ── Local fallback helper ───────────────────────────────────────────────── // Adapts snake_case inputs → roi.js camelCase params and returns the result // in the display shape (camelCase) expected by the JSX below. function _localCalc(inp) { return Keeper.roi.calculate({ hectares: inp.hectares, yieldPerHa: inp.yield_per_ha, pricePerTon: inp.price_per_ton, gainPct: inp.gain_pct, costPerHa: inp.cost_per_ha, }); } // ── Backend → display shape normalisation ──────────────────────────────── // The backend returns snake_case; the JSX was originally written against the // roi.js camelCase shape. We adapt here so both paths share one render tree. function _normaliseBackendResult(r) { return { baselineRevenue: r.baseline_revenue, incrementalRevenue: r.incremental_revenue, implementationCost: r.implementation_cost, paybackRatio: r.payback_ratio, payback: r.payback ? { status: r.payback.status, months: r.payback.months, years: r.payback.years, reason: r.payback.reason, } : null, breakdown: r.breakdown ? { revenue: (r.breakdown.revenue || []).map(item => ({ id: item.id, label: item.label, value: item.value, sharePct: item.share_pct, })), cost: r.breakdown.cost || [], chart: { incrementalVsBaselinePct: r.breakdown.chart ? r.breakdown.chart.incremental_vs_baseline_pct : 0, }, totals: r.breakdown.totals || { revenue: 0, cost: 0 }, } : null, }; } // ── Backend calculation (debounced 400 ms) ──────────────────────────────── React.useEffect(() => { let cancelled = false; const timer = setTimeout(async () => { if (cancelled) return; // Guard: skip if API module is not available (e.g. tests or offline dev). if (!window.Keeper || !window.Keeper.api || typeof window.Keeper.api.calculateRoi !== "function") { setResult(_localCalc(inputs)); return; } setLoading(true); setBackendError(null); try { const backendResult = await Keeper.api.calculateRoi(inputs); if (!cancelled) { setResult(_normaliseBackendResult(backendResult)); setLoading(false); } } catch (err) { if (!cancelled) { // Silently fall back — the user still sees numbers, just from the // local formula. A small banner tells them backend is unavailable. setResult(_localCalc(inputs)); setBackendError( (err && err.backendMessage) ? err.backendMessage : "Backend unavailable — using local calculation." ); setLoading(false); } } }, 400); return () => { cancelled = true; clearTimeout(timer); }; }, [inputs]); function handleChange(field, raw) { const value = parseFloat(raw) || 0; setInputs(prev => ({ ...prev, [field]: value })); } const paybackMonths = result.payback ? result.payback.months : null; return (
ROI-калькулятор
Прогноз дополнительной выручки и окупаемости на основе агрономических параметров
{loading && ( Расчёт… )}
{backendError && (
{backendError}
)}
{/* ── Input panel ─────────────────────────────────────────────────── */}

Параметры расчёта

Площадь, га
Суммарная площадь теплиц под расчёт
handleChange("hectares", e.target.value)} style={{ height: 36, border: "1px solid var(--border)", borderRadius: 8, padding: "0 12px", font: "500 14px Inter", width: 140, textAlign: "right", }} />
Базовая урожайность, т/га
Плановый урожай без применения системы
handleChange("yield_per_ha", e.target.value)} style={{ height: 36, border: "1px solid var(--border)", borderRadius: 8, padding: "0 12px", font: "500 14px Inter", width: 140, textAlign: "right", }} />
Цена реализации, $/т
Средняя отпускная цена за тонну продукции
handleChange("price_per_ton", e.target.value)} style={{ height: 36, border: "1px solid var(--border)", borderRadius: 8, padding: "0 12px", font: "500 14px Inter", width: 140, textAlign: "right", }} />
Прирост урожайности, %
Ожидаемый прирост от оптимизации опыления
handleChange("gain_pct", e.target.value)} style={{ height: 36, border: "1px solid var(--border)", borderRadius: 8, padding: "0 12px", font: "500 14px Inter", width: 140, textAlign: "right", }} />
Стоимость внедрения, ₽/га
Единовременные затраты на подключение системы
handleChange("cost_per_ha", e.target.value)} style={{ height: 36, border: "1px solid var(--border)", borderRadius: 8, padding: "0 12px", font: "500 14px Inter", width: 140, textAlign: "right", }} />
{/* Formula note */}
Формула (Python backend · reporting_service)
baseline_revenue = га × т/га × $/т
incremental_revenue = baseline_revenue × (% / 100)
implementation_cost = га × ₽/га
payback_ratio = incremental_revenue / implementation_cost
{/* ── Results panel ───────────────────────────────────────────────── */}
{/* KPI row */}
Базовая выручка
${fmt(result.baselineRevenue)}
в год без системы
Доп. выручка
0 ? "var(--color-ok, #26972B)" : undefined }}> +${fmt(result.incrementalRevenue)}
ежегодный прирост
Стоимость внедрения
₽{fmt(result.implementationCost)}
единовременно
Коэффициент окупаемости
= 1 ? "var(--color-ok, #26972B)" : result.paybackRatio > 0 ? "#a04200" : undefined }}> {result.paybackRatio.toFixed(2)}x
= 1 ? "up" : ""}`}> доход / затраты
{/* Summary card */}

Итог расчёта

{result.payback && (
= 1 ? "rgba(38,151,43,.08)" : "rgba(160,66,0,.06)", border: `1px solid ${result.paybackRatio >= 1 ? "rgba(38,151,43,.3)" : "rgba(160,66,0,.25)"}`, borderRadius: 8, fontSize: 13, display: "flex", gap: 8, alignItems: "flex-start", }}> = 1 ? "ri-checkbox-circle-line" : "ri-information-line"} style={{ fontSize: 16, marginTop: 1, color: result.paybackRatio >= 1 ? "#26972B" : "#a04200" }} > {result.payback.status === "instant" ? "При текущих параметрах система окупается мгновенно (нулевые затраты на внедрение)." : result.paybackRatio >= 1 && paybackMonths !== null ? `При текущих параметрах система окупается приблизительно за ${paybackMonths} мес.` : "При текущих параметрах дополнительная выручка не перекрывает затраты на внедрение. Проверьте входные данные."}
)}
{/* Breakdown bar */} {result.baselineRevenue > 0 && result.breakdown && (

Структура выручки

Базовая выручка ${fmt(result.baselineRevenue)}
Прирост от системы +${fmt(result.incrementalRevenue)}
)}
); } window.RoiCalculator = RoiCalculator;