804 lines
29 KiB
TypeScript
804 lines
29 KiB
TypeScript
"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: () => (
|
||
<div
|
||
style={{
|
||
height: 360,
|
||
background: "var(--bg-card-alt)",
|
||
border: "1px solid var(--border-card)",
|
||
borderRadius: 12,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
color: "var(--fg-tertiary)",
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
Загрузка карты…
|
||
</div>
|
||
),
|
||
},
|
||
);
|
||
|
||
// 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: () => (
|
||
<div
|
||
style={{
|
||
height: 420,
|
||
background: "var(--bg-card-alt)",
|
||
border: "1px solid var(--border-card)",
|
||
borderRadius: 12,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
color: "var(--fg-tertiary)",
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
Загрузка 3D-сцены…
|
||
</div>
|
||
),
|
||
},
|
||
);
|
||
|
||
/** 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 (
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={active}
|
||
onClick={onClick}
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
padding: "6px 12px",
|
||
background: active ? "var(--accent)" : "transparent",
|
||
border: "none",
|
||
borderRadius: 6,
|
||
color: active ? "#fff" : "var(--fg-secondary)",
|
||
fontSize: 13,
|
||
fontWeight: active ? 600 : 500,
|
||
cursor: "pointer",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{icon}
|
||
{label}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
// ── 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 (
|
||
<div>
|
||
{/* Headline-bar — one-sentence JTBD verdict for the variant. */}
|
||
<div
|
||
style={{
|
||
background: "var(--bg-headline)",
|
||
color: "var(--fg-on-dark)",
|
||
borderRadius: 12,
|
||
padding: "14px 18px",
|
||
}}
|
||
>
|
||
<div style={{ fontSize: 15, fontWeight: 600 }}>
|
||
{variantLabel(variant)}: {formatCorpuses(buildingCount(variant))} ·{" "}
|
||
{formatInt(teap.total_floor_area_sqm)} м² надземной площади ·{" "}
|
||
{formatInt(teap.apartments_count)} квартир · чистая прибыль{" "}
|
||
{formatMoneyCompact(financial.net_profit_rub)} · ROI{" "}
|
||
{formatPct(financial.roi)}
|
||
</div>
|
||
<div
|
||
style={{
|
||
marginTop: 8,
|
||
paddingTop: 8,
|
||
borderTop: "1px solid rgba(255,255,255,0.12)",
|
||
fontSize: 12,
|
||
color: "var(--fg-on-dark-muted)",
|
||
}}
|
||
>
|
||
{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,
|
||
})}
|
||
.
|
||
</div>
|
||
</div>
|
||
|
||
{/* Stage 3b — partial-fit note: program asked for more sections than fit. */}
|
||
{partialFit && (
|
||
<div
|
||
role="note"
|
||
style={{
|
||
marginTop: 12,
|
||
padding: "12px 16px",
|
||
background: "var(--bg-card-alt)",
|
||
border: "1px solid var(--border-card)",
|
||
borderRadius: 12,
|
||
fontSize: 13,
|
||
lineHeight: "20px",
|
||
color: "var(--fg-secondary)",
|
||
}}
|
||
>
|
||
Размещено {formatInt(partialFit.placed)} из{" "}
|
||
{formatCorpuses(partialFit.requested)} — участок вмещает меньше, чем в
|
||
заданной программе. ТЭП и финмодель рассчитаны по фактически
|
||
размещённым корпусам.
|
||
</div>
|
||
)}
|
||
|
||
{/* Placement — 2D plan / 3D massing tab switch (same FeatureCollection). */}
|
||
<Section
|
||
title="Размещение застройки"
|
||
subtitle={`Полигон участка и ${formatCorpuses(
|
||
buildingCount(variant),
|
||
)} — 2D-план или объёмная модель по программе.`}
|
||
right={
|
||
<div
|
||
role="tablist"
|
||
aria-label="Вид размещения"
|
||
style={{
|
||
display: "inline-flex",
|
||
gap: 4,
|
||
padding: 4,
|
||
background: "var(--bg-card-alt)",
|
||
border: "1px solid var(--border-card)",
|
||
borderRadius: 8,
|
||
}}
|
||
>
|
||
<PlacementTab
|
||
active={view === "2d"}
|
||
icon={<MapIcon size={16} strokeWidth={1.5} aria-hidden="true" />}
|
||
label="План (2D)"
|
||
onClick={() => setView("2d")}
|
||
/>
|
||
<PlacementTab
|
||
active={view === "3d"}
|
||
icon={<Box size={16} strokeWidth={1.5} aria-hidden="true" />}
|
||
label="Объём (3D)"
|
||
onClick={() => setView("3d")}
|
||
/>
|
||
</div>
|
||
}
|
||
>
|
||
{view === "2d" ? (
|
||
<ConceptResultMap
|
||
parcel={parcel}
|
||
buildings={variant.buildings_geojson}
|
||
variantKey={variant.strategy}
|
||
/>
|
||
) : (
|
||
<Massing3DViewer
|
||
parcel={parcel}
|
||
variant={variant}
|
||
floorHeightM={floorHeightM}
|
||
/>
|
||
)}
|
||
</Section>
|
||
|
||
{/* ТЭП — KPI grid */}
|
||
<Section
|
||
title="ТЭП — технико-экономические показатели"
|
||
subtitle="Расчёт движка генерации по выбранной стратегии размещения."
|
||
>
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||
gap: 12,
|
||
}}
|
||
>
|
||
<KpiCard
|
||
label="Площадь застройки"
|
||
value={formatInt(teap.built_area_sqm)}
|
||
unit="м²"
|
||
/>
|
||
<KpiCard
|
||
label="Общая площадь"
|
||
value={formatInt(teap.total_floor_area_sqm)}
|
||
unit="м²"
|
||
/>
|
||
<KpiCard
|
||
label="Жилая площадь"
|
||
value={formatInt(teap.residential_area_sqm)}
|
||
unit="м²"
|
||
/>
|
||
<KpiCard
|
||
label="Квартир"
|
||
value={formatInt(teap.apartments_count)}
|
||
unit="шт"
|
||
/>
|
||
<KpiCard
|
||
label="Плотность (FAR)"
|
||
value={teap.density.toLocaleString("ru-RU", {
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2,
|
||
})}
|
||
/>
|
||
<KpiCard
|
||
label="Машино-мест"
|
||
value={formatInt(teap.parking_spaces)}
|
||
unit="шт"
|
||
/>
|
||
</div>
|
||
</Section>
|
||
|
||
{/* Финмодель */}
|
||
<Section
|
||
title="Финансовая модель"
|
||
subtitle="Девелоперский расчёт: выручка, каскад затрат, НДС и налог на прибыль → чистая прибыль и ROI; поверх — помесячный DCF (NPV, IRR, окупаемость)."
|
||
right={<ConceptExportButtons variant={variant} />}
|
||
>
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||
gap: 12,
|
||
}}
|
||
>
|
||
<KpiCard
|
||
label="Выручка (GDV)"
|
||
value={formatMoneyCompact(financial.revenue_rub)}
|
||
/>
|
||
<KpiCard
|
||
label="Итого затраты"
|
||
value={formatMoneyCompact(financial.cost_rub)}
|
||
/>
|
||
<KpiCard
|
||
label="Чистая прибыль"
|
||
value={formatMoneyCompact(financial.net_profit_rub)}
|
||
delta={{
|
||
value:
|
||
netPositive === true
|
||
? "Положительная (после НДС и налога на прибыль)"
|
||
: netPositive === false
|
||
? "Отрицательная (после НДС и налога на прибыль)"
|
||
: "Нулевая",
|
||
positive: netPositive,
|
||
}}
|
||
/>
|
||
<KpiCard
|
||
label="ROI на затраты"
|
||
value={formatPct(financial.roi)}
|
||
delta={{
|
||
value: `Чистая маржа на выручку ${formatPct(financial.margin_pct)}`,
|
||
positive: null,
|
||
}}
|
||
/>
|
||
<KpiCard
|
||
label="NPV (DCF)"
|
||
value={formatMoneyCompact(financial.npv_rub)}
|
||
delta={{
|
||
value: `Дисконт ${formatPct(financial.discount_rate_used)} годовых`,
|
||
positive:
|
||
financial.npv_rub > 0
|
||
? true
|
||
: financial.npv_rub < 0
|
||
? false
|
||
: null,
|
||
}}
|
||
/>
|
||
<KpiCard
|
||
label="IRR (DCF, годовой)"
|
||
value={formatPct(financial.irr)}
|
||
delta={{
|
||
value: financial.irr_is_proxy
|
||
? "Оценочный (вырожденный поток, не DCF)"
|
||
: `Окупаемость ${formatPayback(financial.payback_months)}`,
|
||
positive: financial.irr_is_proxy
|
||
? null
|
||
: financial.irr > financial.discount_rate_used
|
||
? true
|
||
: false,
|
||
}}
|
||
/>
|
||
<KpiCard
|
||
label="Цена продажи жилья"
|
||
value={`${formatInt(financial.price_per_sqm_used)} ₽/м²`}
|
||
delta={{
|
||
value: priceSourceCaption(financial),
|
||
positive: null,
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* Каскад затрат + БДР — под аккордеоном (плотность > выкладки) */}
|
||
<details style={{ marginTop: 12 }}>
|
||
<summary
|
||
style={{
|
||
cursor: "pointer",
|
||
fontSize: 13,
|
||
fontWeight: 500,
|
||
color: "var(--fg-secondary)",
|
||
}}
|
||
>
|
||
Подробнее — каскад затрат и расчёт прибыли
|
||
</summary>
|
||
<div style={{ marginTop: 12 }}>
|
||
<FinancialCascadeTable financial={financial} />
|
||
<p
|
||
style={{
|
||
marginTop: 12,
|
||
fontSize: 12,
|
||
color: "var(--fg-tertiary)",
|
||
}}
|
||
>
|
||
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."
|
||
: "")
|
||
: ""}
|
||
</p>
|
||
</div>
|
||
</details>
|
||
</Section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Cost cascade / P&L detail table ────────────────────────────────────────────
|
||
|
||
function CascadeRow({
|
||
label,
|
||
value,
|
||
emphasis = false,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
emphasis?: boolean;
|
||
}) {
|
||
return (
|
||
<tr>
|
||
<td
|
||
style={{
|
||
padding: "6px 8px",
|
||
borderBottom: "1px solid var(--border-soft)",
|
||
color: emphasis ? "var(--fg-primary)" : "var(--fg-secondary)",
|
||
fontWeight: emphasis ? 600 : 400,
|
||
}}
|
||
>
|
||
{label}
|
||
</td>
|
||
<td
|
||
style={{
|
||
padding: "6px 8px",
|
||
borderBottom: "1px solid var(--border-soft)",
|
||
textAlign: "right",
|
||
fontWeight: emphasis ? 600 : 400,
|
||
fontVariantNumeric: "tabular-nums",
|
||
}}
|
||
>
|
||
{value}
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
function FinancialCascadeTable({ financial }: { financial: FinancialModel }) {
|
||
return (
|
||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
||
<tbody>
|
||
<CascadeRow
|
||
label="Выручка — жильё"
|
||
value={formatMoneyCompact(financial.revenue_residential_rub)}
|
||
/>
|
||
<CascadeRow
|
||
label="Выручка — паркинг"
|
||
value={formatMoneyCompact(financial.revenue_parking_rub)}
|
||
/>
|
||
<CascadeRow
|
||
label="Выручка — нежилое (1-й этаж)"
|
||
value={formatMoneyCompact(financial.revenue_office_rub)}
|
||
/>
|
||
<CascadeRow
|
||
label="Выручка (GDV)"
|
||
value={formatMoneyCompact(financial.revenue_rub)}
|
||
emphasis
|
||
/>
|
||
<CascadeRow
|
||
label="СМР"
|
||
value={formatMoneyCompact(financial.construction_rub)}
|
||
/>
|
||
<CascadeRow
|
||
label="ПИР (проектирование)"
|
||
value={formatMoneyCompact(financial.pir_rub)}
|
||
/>
|
||
<CascadeRow
|
||
label="Сети (ТУ)"
|
||
value={formatMoneyCompact(financial.networks_rub)}
|
||
/>
|
||
<CascadeRow
|
||
label="Услуги заказчика"
|
||
value={formatMoneyCompact(financial.developer_services_rub)}
|
||
/>
|
||
<CascadeRow
|
||
label="Резерв"
|
||
value={formatMoneyCompact(financial.contingency_rub)}
|
||
/>
|
||
<CascadeRow
|
||
label="Маркетинг и риэлтор"
|
||
value={formatMoneyCompact(financial.marketing_rub)}
|
||
/>
|
||
<CascadeRow
|
||
label="Земля"
|
||
value={formatMoneyCompact(financial.land_rub)}
|
||
/>
|
||
<CascadeRow
|
||
label="Итого затраты"
|
||
value={formatMoneyCompact(financial.cost_rub)}
|
||
emphasis
|
||
/>
|
||
<CascadeRow
|
||
label="Валовая маржа"
|
||
value={formatMoneyCompact(financial.gross_margin_rub)}
|
||
/>
|
||
<CascadeRow
|
||
label="НДС (паркинг)"
|
||
value={formatMoneyCompact(financial.vat_rub)}
|
||
/>
|
||
<CascadeRow
|
||
label="Прибыль до налога"
|
||
value={formatMoneyCompact(financial.profit_before_tax_rub)}
|
||
/>
|
||
<CascadeRow
|
||
label="Налог на прибыль (25%)"
|
||
value={formatMoneyCompact(financial.profit_tax_rub)}
|
||
/>
|
||
<CascadeRow
|
||
label="Чистая прибыль"
|
||
value={formatMoneyCompact(financial.net_profit_rub)}
|
||
emphasis
|
||
/>
|
||
<CascadeRow
|
||
label={`NPV (DCF, дисконт ${formatPct(financial.discount_rate_used)})`}
|
||
value={formatMoneyCompact(financial.npv_rub)}
|
||
/>
|
||
<CascadeRow
|
||
label={`IRR (годовой)${financial.irr_is_proxy ? ", оценочный" : ""}`}
|
||
value={formatPct(financial.irr)}
|
||
/>
|
||
<CascadeRow
|
||
label="Окупаемость (PBP)"
|
||
value={formatPayback(financial.payback_months)}
|
||
/>
|
||
{/* #1881 PR-5 — финансирование (кредит). Показываем только когда движок
|
||
смоделировал финансирование (financing_enabled). */}
|
||
{financial.financing_enabled ? (
|
||
<>
|
||
<CascadeRow
|
||
label="Пиковый долг"
|
||
value={formatMoneyCompact(financial.peak_debt_rub)}
|
||
/>
|
||
<CascadeRow
|
||
label={`Проценты по кредиту (ставка ${formatPct(
|
||
financial.annual_rate_used,
|
||
)} годовых)`}
|
||
value={formatMoneyCompact(financial.total_interest_rub)}
|
||
/>
|
||
<CascadeRow
|
||
label="Чистая прибыль после финансирования"
|
||
value={formatMoneyCompact(
|
||
financial.net_profit_after_financing_rub,
|
||
)}
|
||
emphasis
|
||
/>
|
||
{/* IRR на собственные средства (equity) при LTC 70 %. Может быть выше
|
||
проектной IRR (положительный леверидж) или ниже / отрицательной
|
||
(проект не покрывает стоимость долга) — знак показываем честно. */}
|
||
<CascadeRow
|
||
label={`IRR на собственные средства (с учётом кредита, LTC 70%)${
|
||
financial.levered_irr_is_proxy ? ", оценочный" : ""
|
||
}`}
|
||
value={formatSignedPct(financial.levered_irr)}
|
||
/>
|
||
</>
|
||
) : null}
|
||
</tbody>
|
||
</table>
|
||
);
|
||
}
|
||
|
||
// ── 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 (
|
||
<p style={{ fontSize: 14, color: "var(--fg-secondary)" }}>
|
||
Движок не вернул ни одного варианта застройки.
|
||
</p>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div>
|
||
{/* Variant tabs */}
|
||
{showTabs && (
|
||
<div
|
||
role="tablist"
|
||
aria-label="Варианты застройки"
|
||
style={{
|
||
display: "flex",
|
||
gap: 8,
|
||
borderBottom: "1px solid var(--border-card)",
|
||
marginBottom: 4,
|
||
overflowX: "auto",
|
||
}}
|
||
>
|
||
{variants.map((v, i) => {
|
||
const isActive = i === active;
|
||
return (
|
||
<button
|
||
key={v.strategy}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={isActive}
|
||
onClick={() => setActive(i)}
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
padding: "10px 14px",
|
||
background: "transparent",
|
||
border: "none",
|
||
borderBottom: isActive
|
||
? "2px solid var(--accent)"
|
||
: "2px solid transparent",
|
||
color: isActive ? "var(--accent)" : "var(--fg-secondary)",
|
||
fontSize: 14,
|
||
fontWeight: isActive ? 600 : 500,
|
||
cursor: "pointer",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{variantLabel(v)}
|
||
<Badge variant={isActive ? "info" : "neutral"} size="sm">
|
||
{v.teap.apartments_count > 0
|
||
? `${formatInt(v.teap.apartments_count)} кв.`
|
||
: "—"}
|
||
</Badge>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
<VariantPanel
|
||
parcel={parcel}
|
||
variant={activeVariant}
|
||
floorHeightM={floorHeightM}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|