"use client"; /** * MassingEconomics — LIVE financial KPI strip for «7. Концепция» (#1965 Stage 2b, * epic #1953). * * Driven by the interactive 3D MassingScene: every time the user drags the * этажность / секций sliders, Section7Concept maps the scene's `computeModel` * result + the analysis context into a `MassingProgram` and hands it here via * `program`. We POST it to `/api/v1/concepts/recompute` (debounced ~250 ms) and * render the recomputed ТЭП + финмодель (NPV / IRR / выручка / себестоимость / * прибыль / ROI). * * Robustness: * • debounce — slider drags fire many programs; only the settled one is sent. * • latest-wins — an in-flight request is superseded by a newer one via a * monotonic request id; a stale response is dropped, never overwriting a * fresher result (mutateAsync + id guard, no UI flicker from out-of-order). * • last-good — on a failed recompute we keep the last successful values and * show a subtle inline note rather than blanking the panel. * • skeleton — a plain grey fade KPI grid while the FIRST recompute is in * flight (no shimmer, per ui-conventions). * * Light-theme only (Section7 is light): the 3D viewport stays dark-canvas, but * this strip uses the light KPI tokens via the shared KpiCard. */ import { useEffect, useRef, useState } from "react"; import { AlertTriangle } from "lucide-react"; import { KpiCard } from "@/components/analytics/KpiCard"; import { Section } from "@/components/analytics/Section"; import { priceSourceCaption, useRecomputeMassing, type FinancialModel, type MassingProgram, type MassingRecomputeOutput, type Teap, } from "@/lib/concept-api"; const DEBOUNCE_MS = 250; // ── Formatters (ru microcopy, shared shape with ConceptVariantsResult) ───────── const nf = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 }); /** Compact ₽ for headline figures: "2.4 млрд ₽", "145 млн ₽". */ function formatMoneyCompact(rub: number): string { const abs = Math.abs(rub); if (abs >= 1e9) return `${(rub / 1e9).toFixed(1)} млрд ₽`; if (abs >= 1e6) return `${(rub / 1e6).toFixed(0)} млн ₽`; return `${nf.format(Math.round(rub))} ₽`; } function formatInt(n: number): string { return nf.format(Math.round(n)); } function formatPct(fraction: number): string { return `${(fraction * 100).toFixed(1)}%`; } function formatFar(far: number): string { return far.toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2, }); } // ── KPI grid ─────────────────────────────────────────────────────────────────── interface KpiGridProps { teap: Teap; financial: FinancialModel; /** Регламентная КСИТ-цель (max_far) — to flag the КСИТ over-cap. */ farTarget: number; /** True → факт-КСИТ превышает регламентный потолок (model.over). */ ksitOver: boolean; /** Dim the strip while a fresher recompute is in flight (last-good values). */ stale: boolean; } function KpiGrid({ teap, financial, farTarget, ksitOver, stale, }: KpiGridProps) { const netPositive = financial.net_profit_rub > 0 ? true : financial.net_profit_rub < 0 ? false : null; return (
Не удалось пересчитать экономику по последнему изменению — показаны предыдущие значения. Измените параметры ещё раз для повторного расчёта.
) : null}