"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 { 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: () => (
Загрузка карты…
),
},
);
// ── 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));
}
function formatPct(fraction: number): string {
return `${(fraction * 100).toFixed(1)}%`;
}
/** Срок окупаемости: «28.5 мес» или «не окупается» (null). */
function formatPayback(months: number | null): string {
if (months === null) return "не окупается";
return `${months.toFixed(1)} мес`;
}
// ── Variant panel ─────────────────────────────────────────────────────────────
interface PanelProps {
parcel: Polygon;
variant: ConceptVariant;
}
function VariantPanel({ parcel, variant }: PanelProps) {
const { teap, financial } = variant;
const netPositive =
financial.net_profit_rub > 0
? true
: financial.net_profit_rub < 0
? false
: null;
return (
{/* Headline-bar — one-sentence JTBD verdict for the variant. */}
{STRATEGY_LABELS[variant.strategy]}:{" "}
{formatInt(teap.total_floor_area_sqm)} м² надземной площади ·{" "}
{formatInt(teap.apartments_count)} квартир · чистая прибыль{" "}
{formatMoneyCompact(financial.net_profit_rub)} · ROI{" "}
{formatPct(financial.roi)}
{STRATEGY_HINTS[variant.strategy]} 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,
})}
.
{/* Placement map */}
{/* ТЭП — 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. "
: ""}
НДС — упрощённая оценка (встроенный НДС в положительной марже, не
полная механика входного/выходного НДС НК РФ); привязан к
признанию выручки. Налог на прибыль — 25% (с 2025 года). Цена
продажи жилья —{" "}
{financial.price_is_calibrated
? `калибрована по рынку (${priceSourceCaption(financial)})`
: "норматив класса (нет рыночных данных по участку)"}
; себестоимость СМР и цена паркинга — нормативные ориентиры.
Коммерческие и офисные площади не учитываются.
{financial.financing_enabled && financial.financing_is_simplified
? " Финансирование упрощено: весь кассовый разрыв покрыт " +
"кредитом по ставке-нормативу, проценты капитализируются, " +
"эскроу не моделируется точно. Проценты — реальная стоимость " +
"(чистая прибыль после финансирования ниже прибыли без него)."
: ""}
);
}
// ── 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 ? (
<>
>
) : null}
);
}
// ── Component ─────────────────────────────────────────────────────────────────
interface Props {
parcel: Polygon;
variants: ConceptVariant[];
}
export function ConceptVariantsResult({ parcel, variants }: Props) {
const [active, setActive] = useState(0);
if (variants.length === 0) {
return (
Движок не вернул ни одного варианта застройки.
);
}
const activeVariant = variants[Math.min(active, variants.length - 1)];
return (
{/* Variant tabs */}
{variants.map((v, i) => {
const isActive = i === active;
return (
);
})}
);
}