diff --git a/frontend/src/components/site-finder/analysis/MassingEconomics.tsx b/frontend/src/components/site-finder/analysis/MassingEconomics.tsx new file mode 100644 index 00000000..59fb7687 --- /dev/null +++ b/frontend/src/components/site-finder/analysis/MassingEconomics.tsx @@ -0,0 +1,352 @@ +"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 ( +
+ {/* ТЭП */} +
+ + + + +
+ + {/* Финмодель */} +
+ + + + + 0 + ? true + : financial.npv_rub < 0 + ? false + : null, + }} + /> + financial.discount_rate_used + ? true + : false, + }} + /> + +
+
+ ); +} + +// ── Skeleton (grey fade, no shimmer — ui-conventions) ────────────────────────── + +function SkeletonGrid() { + const cells = Array.from({ length: 7 }); + return ( +