"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. */}
{/* Stage 3b — partial-fit note: program asked for more sections than fit. */}
{partialFit && (
Размещено {formatInt(partialFit.placed)} из{" "}
{formatCorpuses(partialFit.requested)} — участок вмещает меньше, чем в
заданной программе. ТЭП и финмодель рассчитаны по фактически
размещённым корпусам.
{/* Каскад затрат + БДР — под аккордеоном (плотность > выкладки) */}
Подробнее — каскад затрат и расчёт прибыли
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."
: "")
: ""}
{/* #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 (