"use client"; /** * ConceptVariantsResult — variant tabs + per-variant placement map, ТЭП KPIs, * financial model, and export controls. Driven by the ConceptOutput returned * from POST /concepts. The map is lazy-mounted (Leaflet uses `window`). */ import dynamic from "next/dynamic"; import { useState } from "react"; import { Box, Map as MapIcon } from "lucide-react"; import { KpiCard } from "@/components/analytics/KpiCard"; import { Section } from "@/components/analytics/Section"; import { Badge } from "@/components/ui/Badge"; import { priceSourceCaption, STRATEGY_HINTS, STRATEGY_LABELS, type ConceptVariant, type FinancialModel, } from "@/lib/concept-api"; import type { Polygon } from "geojson"; import { ConceptExportButtons } from "./ConceptExportButtons"; const ConceptResultMap = dynamic( () => import("./ConceptResultMap").then((m) => ({ default: m.ConceptResultMap })), { ssr: false, loading: () => (
Загрузка карты…
), }, ); // 3D massing viewer — Three.js, client-only (ssr:false), lazy so it never bloats // the report bundle and only the active placement tab mounts its engine. const Massing3DViewer = dynamic( () => import("./Massing3DViewer").then((m) => ({ default: m.Massing3DViewer })), { ssr: false, loading: () => (
Загрузка 3D-сцены…
), }, ); /** Desktop defaults to 3D; phones (<768px) default to the lighter 2D plan. */ function defaultPlacementView(): "2d" | "3d" { if (typeof window === "undefined") return "3d"; return window.innerWidth < 768 ? "2d" : "3d"; } // ── Formatters (ru microcopy) ───────────────────────────────────────────────── 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)); } /** * Число корпусов на участке = count of placed building footprints (each Feature * in buildings_geojson is one корпус). Guards a missing FeatureCollection. */ function buildingCount(variant: ConceptVariant): number { return variant.buildings_geojson?.features?.length ?? 0; } /** * A program variant is built from a `building_program` (not a 1b strategy): the * backend tags every placed feature `properties.strategy === "program"` while * reporting the variant under the fixed "balanced" strategy slot. Detect it from * the first feature so we can use the honest «Ваша программа» label instead of * STRATEGY_LABELS["balanced"] («Баланс»), which would mislabel it. */ function isProgramVariant(variant: ConceptVariant): boolean { const first = variant.buildings_geojson?.features?.[0]; return first?.properties?.strategy === "program"; } /** Display label: «Ваша программа» for program variants, else strategy label. */ function variantLabel(variant: ConceptVariant): string { return isProgramVariant(variant) ? "Ваша программа" : STRATEGY_LABELS[variant.strategy]; } /** * Variant subtitle hint: program variants get a program-appropriate sentence * (the 1b STRATEGY_HINTS speak of «приоритет площади/инсоляции/баланс», which * misdescribes a user-defined program reported under the "balanced" slot). */ function variantHint(variant: ConceptVariant): string { return isProgramVariant(variant) ? "Раскладка заданной программы по геометрии участка." : STRATEGY_HINTS[variant.strategy]; } /** Russian plural for «корпус» (1 корпус / 2–4 корпуса / 5+ корпусов). */ function pluralizeCorpus(n: number): string { const mod100 = n % 100; const mod10 = n % 10; if (mod100 >= 11 && mod100 <= 14) return "корпусов"; if (mod10 === 1) return "корпус"; if (mod10 >= 2 && mod10 <= 4) return "корпуса"; return "корпусов"; } /** «3 корпуса» / «1 корпус» / «12 корпусов» — count + agreed plural. */ function formatCorpuses(n: number): string { return `${formatInt(n)} ${pluralizeCorpus(n)}`; } function formatPct(fraction: number): string { return `${(fraction * 100).toFixed(1)}%`; } /** * Знаковый процент — для метрик, которые честно могут быть отрицательными * (IRR на собственные средства при негативном леверидже). Отрицательное число * печатается с Unicode-минусом «−» (U+2212), не ASCII «-» (ui-microcopy). */ function formatSignedPct(fraction: number): string { const body = `${(Math.abs(fraction) * 100).toFixed(1)}%`; return fraction < 0 ? `−${body}` : body; } /** Срок окупаемости: «28.5 мес» или «не окупается» (null). */ function formatPayback(months: number | null): string { if (months === null) return "не окупается"; return `${months.toFixed(1)} мес`; } // ── Placement view tab (2D / 3D) ────────────────────────────────────────────── function PlacementTab({ active, icon, label, onClick, }: { active: boolean; icon: React.ReactNode; label: string; onClick: () => void; }) { return ( ); } // ── Variant panel ───────────────────────────────────────────────────────────── interface PanelProps { parcel: Polygon; variant: ConceptVariant; /** Visual floor height (m) for the 3D viewer; default 3.0 (backend constant). */ floorHeightM?: number; } function VariantPanel({ parcel, variant, floorHeightM = 3.0 }: PanelProps) { const { teap, financial } = variant; // Placement view: «План (2D)» (Leaflet) ↔ «Объём (3D)» (Three.js). Desktop // defaults to 3D; phones default to 2D and mount 3D only on tap. Only the // active tab is rendered, so the inactive engine frees its GPU/Leaflet // resources via its teardown effect on switch. const [view, setView] = useState<"2d" | "3d">(defaultPlacementView); const netPositive = financial.net_profit_rub > 0 ? true : financial.net_profit_rub < 0 ? false : null; // Stage 3b (#1965) — partial-fit honest note: program mode (requested_count // != null) where fewer sections fit than were requested. Neutral, not an // error: the user asked for M, the parcel takes N < M. Greedy / full-fit → null. const partialFit = variant.requested_count != null && variant.placed_count != null && variant.placed_count < variant.requested_count ? { placed: variant.placed_count, requested: variant.requested_count } : null; return (
{/* Headline-bar — one-sentence JTBD verdict for the variant. */}
{variantLabel(variant)}: {formatCorpuses(buildingCount(variant))} ·{" "} {formatInt(teap.total_floor_area_sqm)} м² надземной площади ·{" "} {formatInt(teap.apartments_count)} квартир · чистая прибыль{" "} {formatMoneyCompact(financial.net_profit_rub)} · ROI{" "} {formatPct(financial.roi)}
{variantHint(variant)} NPV {formatMoneyCompact(financial.npv_rub)} · IRR {formatPct(financial.irr)} {financial.irr_is_proxy ? " (оценочный)" : ""} · окупаемость{" "} {formatPayback(financial.payback_months)} · плотность (FAR){" "} {teap.density.toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2, })} .
{/* Stage 3b — partial-fit note: program asked for more sections than fit. */} {partialFit && (
Размещено {formatInt(partialFit.placed)} из{" "} {formatCorpuses(partialFit.requested)} — участок вмещает меньше, чем в заданной программе. ТЭП и финмодель рассчитаны по фактически размещённым корпусам.
)} {/* Placement — 2D plan / 3D massing tab switch (same FeatureCollection). */}
} > {view === "2d" ? ( ) : ( )} {/* ТЭП — KPI grid */}
{/* Финмодель */}
} >
0 ? true : financial.npv_rub < 0 ? false : null, }} /> financial.discount_rate_used ? true : false, }} />
{/* Каскад затрат + БДР — под аккордеоном (плотность > выкладки) */}
Подробнее — каскад затрат и расчёт прибыли

NPV / IRR / окупаемость рассчитаны помесячным DCF.{" "} {financial.schedule_is_default ? `График фаз и темп продаж — типовые допущения (ПИР 6 мес → СМР по типу застройки → распродажа 30 мес, дисконт ${formatPct( financial.discount_rate_used, )} годовых); метрики рассчитаны DCF по этому графику, точность зависит от графика конкретного проекта. ` : ""} {financial.irr_is_proxy ? "IRR помечен как оценочный: денежный поток вырожденный (нет смены знака), показан аннуализированный ROI вместо DCF-IRR. " : ""} НДС: жильё и услуги застройщика по ДДУ освобождены (ст. 149 НК РФ); НДС начисляется только на паркинг (нежилые машиноместа). Входной НДС по строительству уже учтён в себестоимости. Налог на прибыль — 25% (с 2025 года). Цена продажи жилья —{" "} {financial.price_is_calibrated ? `калибрована по рынку (${priceSourceCaption(financial)})` : "норматив класса (нет рыночных данных по участку)"} ; себестоимость СМР и цена паркинга — нормативные ориентиры. Коммерческие и офисные площади не учитываются. {financial.financing_enabled && financial.financing_is_simplified ? " Финансирование упрощено: весь кассовый разрыв покрыт " + "кредитом по ставке-нормативу, проценты капитализируются, " + "эскроу не моделируется точно. Проценты — реальная стоимость " + "(чистая прибыль после финансирования ниже прибыли без него)." : ""} {financial.financing_enabled ? " IRR на собственные средства — доходность на собственные " + "средства инвестора при кредите LTC 70 %. Может быть выше " + "проектной IRR при положительном леверидже или ниже неё " + "(вплоть до отрицательной), если проект не покрывает стоимость " + "долга." + (financial.levered_irr_is_proxy ? " Помечена как оценочная: посчитана по вырожденному " + "equity-потоку (proxy), а не DCF." : "") : ""}

); } // ── Cost cascade / P&L detail table ──────────────────────────────────────────── function CascadeRow({ label, value, emphasis = false, }: { label: string; value: string; emphasis?: boolean; }) { return ( {label} {value} ); } function FinancialCascadeTable({ financial }: { financial: FinancialModel }) { return ( {/* #1881 PR-5 — финансирование (кредит). Показываем только когда движок смоделировал финансирование (financing_enabled). */} {financial.financing_enabled ? ( <> {/* IRR на собственные средства (equity) при LTC 70 %. Может быть выше проектной IRR (положительный леверидж) или ниже / отрицательной (проект не покрывает стоимость долга) — знак показываем честно. */} ) : null}
); } // ── Component ───────────────────────────────────────────────────────────────── interface Props { parcel: Polygon; variants: ConceptVariant[]; /** * Visual floor height (m) for the 3D massing viewer; default 3.0 = backend's * `_FLOOR_HEIGHT`. Optional so Phase 2 can thread the form's live knob without * a contract change (the ТЭП / финмодель stay computed at 3.0 m/этаж). */ floorHeightM?: number; } export function ConceptVariantsResult({ parcel, variants, floorHeightM = 3.0, }: Props) { const [active, setActive] = useState(0); if (variants.length === 0) { return (

Движок не вернул ни одного варианта застройки.

); } const activeVariant = variants[Math.min(active, variants.length - 1)]; // A lone tab is noise (common for program mode → one variant). Hide the tab // bar entirely with a single variant; keep it as-is when there's a choice. const showTabs = variants.length > 1; return (
{/* Variant tabs */} {showTabs && (
{variants.map((v, i) => { const isActive = i === active; return ( ); })}
)}
); }