feat(concept): concept-компоненты (DrawMap/ParamsForm/VariantsResult/ResultMap/ExportButtons) (#57)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m45s
Deploy / deploy (push) Successful in 1m3s

Дополняет UI: 5 компонентов которые импортит page.tsx (Leaflet draw, форма ConceptInput,
табы вариантов, placement-карта, экспорт). Не вошли в предыдущий commit (untracked-dir).
ConceptResultMap: Leaflet stroke #D1D5DB (CSS var не резолвится в SVG).
This commit is contained in:
Light1YT 2026-06-13 21:33:23 +05:00 committed by bot-backend
parent 750d34d5cb
commit 3bbbf25412
5 changed files with 1102 additions and 0 deletions

View file

@ -0,0 +1,226 @@
"use client";
/**
* ConceptDrawMap Leaflet draw-polygon input for the parcel boundary.
*
* Uses the imperative `L.Draw.Polygon` handler (leaflet-draw) inside a
* `useMap()` child rather than the react-leaflet-draw wrapper (not a repo dep).
* The drawn ring is emitted upward as a GeoJSON Polygon (WGS84) for
* ConceptInput.parcel_geojson. Re-using the react-leaflet + OSM TileLayer
* pattern from EntryMap / SiteMap. Lazy-mounted (ssr:false) by the page
* Leaflet touches `window`.
*/
import { useEffect, useRef, useState } from "react";
import { MapContainer, TileLayer, GeoJSON, useMap } from "react-leaflet";
import L from "leaflet";
import "leaflet-draw";
import type { Polygon } from "geojson";
import { Pencil, Trash2 } from "lucide-react";
import "leaflet/dist/leaflet.css";
import "leaflet-draw/dist/leaflet.draw.css";
// Yekaterinburg center — matches EntryMap default view.
const EKB_CENTER: [number, number] = [56.8389, 60.6057];
const DEFAULT_ZOOM = 13;
// ── Draw handler child (must live inside MapContainer for useMap) ─────────────
interface DrawLayerProps {
/** Drawing requested by the parent toolbar (toggled on each click). */
drawTick: number;
/** Clear requested by the parent toolbar. */
clearTick: number;
onPolygon: (polygon: Polygon) => void;
onCleared: () => void;
}
function DrawLayer({
drawTick,
clearTick,
onPolygon,
onCleared,
}: DrawLayerProps) {
const map = useMap();
const groupRef = useRef<L.FeatureGroup | null>(null);
const handlerRef = useRef<L.Draw.Polygon | null>(null);
// One FeatureGroup holds the single drawn polygon; wire the CREATED event.
useEffect(() => {
const group = new L.FeatureGroup();
group.addTo(map);
groupRef.current = group;
const onCreated = (e: L.LeafletEvent) => {
const { layer } = e as L.DrawEvents.Created;
group.clearLayers();
group.addLayer(layer);
const gj = (layer as L.Polygon).toGeoJSON();
if (gj.type === "Feature" && gj.geometry.type === "Polygon") {
onPolygon(gj.geometry);
}
};
map.on(L.Draw.Event.CREATED, onCreated);
return () => {
map.off(L.Draw.Event.CREATED, onCreated);
group.remove();
};
// map identity is stable for the MapContainer lifetime; onPolygon is stable.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Start the polygon draw handler when the parent toolbar bumps drawTick.
useEffect(() => {
if (drawTick === 0) return;
handlerRef.current?.disable();
const handler = new L.Draw.Polygon(map as L.DrawMap, {
allowIntersection: false,
showArea: false,
shapeOptions: {
color: "#1d4ed8",
weight: 2.5,
fillColor: "#3b82f6",
fillOpacity: 0.2,
},
});
handlerRef.current = handler;
handler.enable();
return () => {
handler.disable();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [drawTick]);
// Clear the drawn polygon when the parent toolbar bumps clearTick.
useEffect(() => {
if (clearTick === 0) return;
handlerRef.current?.disable();
groupRef.current?.clearLayers();
onCleared();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clearTick]);
return null;
}
// ── Component ─────────────────────────────────────────────────────────────────
interface Props {
/** Externally provided polygon (e.g. resolved from a cadastre number). */
polygon: Polygon | null;
onChange: (polygon: Polygon | null) => void;
height?: number;
}
export function ConceptDrawMap({ polygon, onChange, height = 420 }: Props) {
// Tick counters bumped by the toolbar buttons drive the DrawLayer effects
// (start-draw / clear) without exposing a Leaflet ref to the parent.
const [drawTick, setDrawTick] = useState(0);
const [clearTick, setClearTick] = useState(0);
function handleDraw() {
setDrawTick((t) => t + 1);
}
function handleClear() {
setClearTick((t) => t + 1);
onChange(null);
}
return (
<div style={{ position: "relative", width: "100%", height }}>
<MapContainer
center={EKB_CENTER}
zoom={DEFAULT_ZOOM}
style={{ width: "100%", height: "100%", borderRadius: 12 }}
scrollWheelZoom
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* Externally supplied polygon (cadastre geom). Keyed on the ring so
react-leaflet remounts the layer when the geometry changes. */}
{polygon && (
<GeoJSON
key={JSON.stringify(polygon.coordinates[0]?.[0] ?? [])}
data={polygon}
style={{
color: "#1d4ed8",
weight: 2.5,
fillColor: "#3b82f6",
fillOpacity: 0.2,
}}
/>
)}
<DrawLayer
drawTick={drawTick}
clearTick={clearTick}
onPolygon={onChange}
onCleared={() => {}}
/>
</MapContainer>
{/* Toolbar — primary draw + clear; overlays the map top-left. */}
<div
style={{
position: "absolute",
top: 12,
left: 12,
zIndex: 1000,
display: "flex",
gap: 8,
}}
>
<button
type="button"
onClick={handleDraw}
aria-label="Нарисовать полигон участка"
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
height: 32,
background: "var(--accent)",
color: "#fff",
border: "none",
borderRadius: 6,
fontSize: 13,
fontWeight: 500,
cursor: "pointer",
}}
>
<Pencil size={14} strokeWidth={1.5} />
Нарисовать участок
</button>
{polygon && (
<button
type="button"
onClick={handleClear}
aria-label="Очистить полигон"
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
height: 32,
background: "var(--bg-card)",
color: "var(--fg-secondary)",
border: "1px solid var(--border-card)",
borderRadius: 6,
fontSize: 13,
fontWeight: 500,
cursor: "pointer",
}}
>
<Trash2 size={14} strokeWidth={1.5} />
Очистить
</button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,210 @@
"use client";
/**
* ConceptExportButtons export controls for one concept variant.
*
* The Stage-1a backend contract (`backend/app/api/v1/concepts.py`) ships only
* `POST /concepts`; no export endpoint exists yet. So:
* - GeoJSON (buildings) + CSV (ТЭП + финмодель) are generated client-side
* here, re-using `triggerDownload` and the CSV-injection-safe escaping from
* the Site Finder ExportButtons.
* - DXF / PDF are produced server-side (ezdxf / WeasyPrint per the stack).
* We POST the variant to the documented `/api/v1/concepts/export` path and
* download the blob; until that endpoint lands the button reports a clear
* "не готово" message instead of failing silently.
*/
import { useState } from "react";
import { Download, FileCode, FileText, Map as MapIcon } from "lucide-react";
import { API_BASE_URL } from "@/lib/api";
import { triggerDownload } from "@/lib/download";
import { STRATEGY_LABELS, type ConceptVariant } from "@/lib/concept-api";
// ── CSV helpers (mirrors site-finder ExportButtons) ───────────────────────────
function escapeCsvCell(cell: string): string {
// CSV-injection mitigation (OWASP / CWE-1236): cells starting with =, +, -, @,
// tab, or CR are evaluated as formulas by Excel/Calc/Sheets. Prefix dangerous
// starters with a leading apostrophe so the spreadsheet treats them as text.
let safe = cell;
if (/^[=+\-@\t\r]/.test(safe)) {
safe = "'" + safe;
}
const needsQuotes = /[",\n\r]/.test(safe);
if (needsQuotes) {
return `"${safe.replace(/"/g, '""')}"`;
}
return safe;
}
function buildCsvRows(variant: ConceptVariant): string[][] {
const t = variant.teap;
const f = variant.financial;
return [
["Показатель", "Значение"],
["Стратегия", STRATEGY_LABELS[variant.strategy]],
["Площадь застройки, м²", String(t.built_area_sqm)],
["Общая площадь, м²", String(t.total_floor_area_sqm)],
["Жилая площадь, м²", String(t.residential_area_sqm)],
["Квартир, шт", String(t.apartments_count)],
["Плотность", String(t.density)],
["Машино-мест", String(t.parking_spaces)],
["Выручка, ₽", String(f.revenue_rub)],
["Затраты, ₽", String(f.cost_rub)],
["Валовая прибыль, ₽", String(f.gross_margin_rub)],
["IRR", String(f.irr)],
];
}
function generateCsvBlob(rows: string[][]): Blob {
const csv = rows.map((row) => row.map(escapeCsvCell).join(",")).join("\r\n");
// UTF-8 BOM so Excel renders Cyrillic correctly.
return new Blob(["" + csv], { type: "text/csv;charset=utf-8" });
}
// ── Component ─────────────────────────────────────────────────────────────────
const today = () => new Date().toISOString().slice(0, 10);
interface Props {
variant: ConceptVariant;
}
export function ConceptExportButtons({ variant }: Props) {
const [serverLoading, setServerLoading] = useState<"dxf" | "pdf" | null>(
null,
);
const [error, setError] = useState<string | null>(null);
const fileStem = `gendesign_concept_${variant.strategy}_${today()}`;
function handleGeojson() {
setError(null);
const blob = new Blob(
[JSON.stringify(variant.buildings_geojson, null, 2)],
{
type: "application/geo+json",
},
);
triggerDownload(blob, `${fileStem}.geojson`);
}
function handleCsv() {
setError(null);
const blob = generateCsvBlob(buildCsvRows(variant));
triggerDownload(blob, `${fileStem}.csv`);
}
async function handleServerExport(format: "dxf" | "pdf") {
setServerLoading(format);
setError(null);
try {
const res = await fetch(
`${API_BASE_URL}/api/v1/concepts/export?format=${format}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(variant),
},
);
if (res.status === 404 || res.status === 405) {
setError(
format === "dxf"
? "Экспорт DXF появится после публикации движка генерации."
: "Экспорт PDF появится после публикации движка генерации.",
);
return;
}
if (!res.ok) {
throw new Error(`Ошибка сервера ${res.status}`);
}
const blob = await res.blob();
triggerDownload(blob, `${fileStem}.${format}`);
} catch (err) {
const msg =
err instanceof Error
? err.message
: `Ошибка экспорта ${format.toUpperCase()}`;
setError(msg);
} finally {
setServerLoading(null);
}
}
const secondaryBtn: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
height: 32,
background: "var(--accent-2)",
color: "#fff",
border: "none",
borderRadius: 6,
fontSize: 13,
fontWeight: 500,
cursor: "pointer",
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button
type="button"
onClick={handleGeojson}
aria-label="Скачать геометрию зданий GeoJSON"
style={secondaryBtn}
>
<MapIcon size={14} strokeWidth={1.5} />
GeoJSON
</button>
<button
type="button"
onClick={handleCsv}
aria-label="Скачать ТЭП и финмодель CSV"
style={secondaryBtn}
>
<Download size={14} strokeWidth={1.5} />
CSV
</button>
<button
type="button"
onClick={() => void handleServerExport("dxf")}
disabled={serverLoading !== null}
aria-label="Скачать DXF"
style={{
...secondaryBtn,
opacity: serverLoading !== null ? 0.6 : 1,
cursor: serverLoading !== null ? "not-allowed" : "pointer",
}}
>
<FileCode size={14} strokeWidth={1.5} />
{serverLoading === "dxf" ? "Экспорт…" : "DXF"}
</button>
<button
type="button"
onClick={() => void handleServerExport("pdf")}
disabled={serverLoading !== null}
aria-label="Скачать PDF"
style={{
...secondaryBtn,
opacity: serverLoading !== null ? 0.6 : 1,
cursor: serverLoading !== null ? "not-allowed" : "pointer",
}}
>
<FileText size={14} strokeWidth={1.5} />
{serverLoading === "pdf" ? "Экспорт…" : "PDF"}
</button>
</div>
{error && (
<p
style={{ margin: 0, fontSize: 12, color: "var(--warn)" }}
role="status"
>
{error}
</p>
)}
</div>
);
}

View file

@ -0,0 +1,232 @@
"use client";
/**
* ConceptParamsForm controlled form for the ConceptInput parameters
* (housing_class / target_floors / development_type / land_cost_rub). The
* parcel polygon is collected separately by the map; this form owns only the
* scalar fields and the submit CTA. Submit is disabled until the parent
* confirms a polygon is present (`hasPolygon`).
*/
import { Sparkles } from "lucide-react";
import {
DEVELOPMENT_TYPE_LABELS,
HOUSING_CLASS_LABELS,
type DevelopmentType,
type HousingClass,
} from "@/lib/concept-api";
export interface ConceptParams {
housing_class: HousingClass;
target_floors: number;
development_type: DevelopmentType;
land_cost_rub: number | null;
}
export const DEFAULT_PARAMS: ConceptParams = {
housing_class: "comfort",
target_floors: 9,
development_type: "mid_rise",
land_cost_rub: null,
};
const fieldLabelStyle: React.CSSProperties = {
display: "block",
fontSize: 12,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-secondary)",
marginBottom: 6,
};
const controlStyle: React.CSSProperties = {
width: "100%",
padding: "8px 12px",
fontSize: 14,
border: "1px solid var(--border-strong)",
borderRadius: 8,
outline: "none",
background: "var(--bg-card)",
color: "var(--fg-primary)",
};
interface Props {
params: ConceptParams;
onChange: (params: ConceptParams) => void;
onSubmit: () => void;
hasPolygon: boolean;
isPending: boolean;
}
export function ConceptParamsForm({
params,
onChange,
onSubmit,
hasPolygon,
isPending,
}: Props) {
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!hasPolygon || isPending) return;
onSubmit();
}
const disabled = !hasPolygon || isPending;
return (
<form onSubmit={handleSubmit} style={{ display: "grid", gap: 16 }}>
<div>
<label htmlFor="housing-class" style={fieldLabelStyle}>
Класс жилья
</label>
<select
id="housing-class"
value={params.housing_class}
onChange={(e) =>
onChange({
...params,
housing_class: e.target.value as HousingClass,
})
}
style={controlStyle}
>
{(Object.keys(HOUSING_CLASS_LABELS) as HousingClass[]).map((key) => (
<option key={key} value={key}>
{HOUSING_CLASS_LABELS[key]}
</option>
))}
</select>
</div>
<div>
<label htmlFor="dev-type" style={fieldLabelStyle}>
Тип застройки
</label>
<select
id="dev-type"
value={params.development_type}
onChange={(e) =>
onChange({
...params,
development_type: e.target.value as DevelopmentType,
})
}
style={controlStyle}
>
{(Object.keys(DEVELOPMENT_TYPE_LABELS) as DevelopmentType[]).map(
(key) => (
<option key={key} value={key}>
{DEVELOPMENT_TYPE_LABELS[key]}
</option>
),
)}
</select>
</div>
<div>
<label htmlFor="target-floors" style={fieldLabelStyle}>
Целевая этажность
</label>
<input
id="target-floors"
type="number"
min={1}
max={30}
value={params.target_floors}
onChange={(e) => {
const raw = Number(e.target.value);
const clamped = Number.isFinite(raw)
? Math.min(30, Math.max(1, Math.round(raw)))
: 1;
onChange({ ...params, target_floors: clamped });
}}
style={controlStyle}
/>
<p
style={{
margin: "4px 0 0",
fontSize: 11,
color: "var(--fg-tertiary)",
}}
>
От 1 до 30 этажей.
</p>
</div>
<div>
<label htmlFor="land-cost" style={fieldLabelStyle}>
Стоимость участка,
</label>
<input
id="land-cost"
type="number"
min={0}
step={1_000_000}
value={params.land_cost_rub ?? ""}
placeholder="Необязательно"
onChange={(e) => {
const raw = e.target.value.trim();
const value = raw === "" ? null : Number(raw);
onChange({
...params,
land_cost_rub:
value != null && Number.isFinite(value) && value >= 0
? value
: null,
});
}}
style={controlStyle}
/>
<p
style={{
margin: "4px 0 0",
fontSize: 11,
color: "var(--fg-tertiary)",
}}
>
Учитывается в финансовой модели как часть затрат.
</p>
</div>
<button
type="submit"
disabled={disabled}
aria-label="Рассчитать концепции застройки"
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: "10px 18px",
height: 44,
background: disabled ? "var(--bg-card-alt)" : "var(--accent)",
color: disabled ? "var(--fg-tertiary)" : "#fff",
border: disabled ? "1px solid var(--border-card)" : "none",
borderRadius: 8,
fontSize: 14,
fontWeight: 600,
cursor: disabled ? "not-allowed" : "pointer",
transition: "opacity 150ms",
}}
>
<Sparkles size={16} strokeWidth={1.5} />
{isPending ? "Расчёт концепций…" : "Рассчитать концепции"}
</button>
{!hasPolygon && (
<p
style={{
margin: 0,
fontSize: 12,
color: "var(--fg-tertiary)",
}}
>
Нарисуйте полигон участка или укажите кадастровый номер, чтобы начать
расчёт.
</p>
)}
</form>
);
}

View file

@ -0,0 +1,144 @@
"use client";
/**
* ConceptResultMap renders one variant's building placement: the parcel
* polygon (outline) + the generated buildings FeatureCollection (filled), over
* an OSM base. Re-uses the react-leaflet + GeoJSON pattern from SiteMap.
* Lazy-mounted (ssr:false) by the parent Leaflet touches `window`.
*/
import { useEffect } from "react";
import { MapContainer, TileLayer, GeoJSON, useMap } from "react-leaflet";
import type { FeatureCollection, Polygon, Position } from "geojson";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
// ── Bounds helper ─────────────────────────────────────────────────────────────
function ringBounds(rings: Position[][]): L.LatLngBoundsExpression | null {
let minLat = Infinity;
let maxLat = -Infinity;
let minLon = Infinity;
let maxLon = -Infinity;
for (const ring of rings) {
for (const [lon, lat] of ring) {
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon;
}
}
if (!Number.isFinite(minLat)) return null;
return [
[minLat, minLon],
[maxLat, maxLon],
];
}
interface FitBoundsProps {
bounds: L.LatLngBoundsExpression | null;
}
// react-leaflet does not auto-fit to data; do it imperatively once mounted.
function FitBounds({ bounds }: FitBoundsProps) {
const map = useMap();
useEffect(() => {
if (bounds) {
map.fitBounds(bounds, { padding: [24, 24] });
}
}, [map, bounds]);
return null;
}
// ── Component ─────────────────────────────────────────────────────────────────
interface Props {
parcel: Polygon;
buildings: FeatureCollection;
/** Keying makes react-leaflet remount the buildings layer on variant switch. */
variantKey: string;
height?: number;
}
export function ConceptResultMap({
parcel,
buildings,
variantKey,
height = 360,
}: Props) {
const bounds = ringBounds(parcel.coordinates as Position[][]);
const hasBuildings = buildings.features.length > 0;
return (
<div
style={{
border: "1px solid var(--border-card)",
borderRadius: 12,
overflow: "hidden",
height,
position: "relative",
}}
>
<MapContainer
center={[56.8389, 60.6057]}
zoom={15}
style={{ width: "100%", height: "100%" }}
scrollWheelZoom
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* Parcel boundary — outline only so buildings read on top. */}
<GeoJSON
key={`parcel-${variantKey}`}
data={parcel}
style={{
color: "#D1D5DB",
weight: 2,
dashArray: "6 4",
fillOpacity: 0,
}}
/>
{/* Generated buildings — filled blocks. */}
{hasBuildings && (
<GeoJSON
key={`buildings-${variantKey}`}
data={buildings}
style={{
color: "#1d4ed8",
weight: 1.5,
fillColor: "#1d4ed8",
fillOpacity: 0.55,
}}
/>
)}
<FitBounds bounds={bounds} />
</MapContainer>
{!hasBuildings && (
<div
style={{
position: "absolute",
bottom: 12,
left: 12,
right: 12,
zIndex: 1000,
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 8,
padding: "8px 12px",
fontSize: 12,
color: "var(--fg-secondary)",
}}
>
Движок ещё не вернул геометрию зданий для этого варианта.
</div>
)}
</div>
);
}

View file

@ -0,0 +1,290 @@
"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 {
STRATEGY_HINTS,
STRATEGY_LABELS,
type ConceptVariant,
} 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>
),
},
);
// ── 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)}%`;
}
// ── Variant panel ─────────────────────────────────────────────────────────────
interface PanelProps {
parcel: Polygon;
variant: ConceptVariant;
}
function VariantPanel({ parcel, variant }: PanelProps) {
const { teap, financial } = variant;
const marginPositive =
financial.gross_margin_rub > 0
? true
: financial.gross_margin_rub < 0
? false
: 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 }}>
{STRATEGY_LABELS[variant.strategy]}:{" "}
{formatInt(teap.total_floor_area_sqm)} м² надземной площади ·{" "}
{formatInt(teap.apartments_count)} квартир · валовая прибыль{" "}
{formatMoneyCompact(financial.gross_margin_rub)}
</div>
<div
style={{
marginTop: 8,
paddingTop: 8,
borderTop: "1px solid rgba(255,255,255,0.12)",
fontSize: 12,
color: "var(--fg-on-dark-muted)",
}}
>
{STRATEGY_HINTS[variant.strategy]} IRR {formatPct(financial.irr)} ·
плотность {teap.density.toLocaleString("ru-RU")} м²/га.
</div>
</div>
{/* Placement map */}
<Section
title="Размещение застройки"
subtitle="Полигон участка (пунктир) и сгенерированные корпуса (заливка)."
>
<ConceptResultMap
parcel={parcel}
buildings={variant.buildings_geojson}
variantKey={variant.strategy}
/>
</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="Плотность"
value={teap.density.toLocaleString("ru-RU")}
unit="м²/га"
/>
<KpiCard
label="Машино-мест"
value={formatInt(teap.parking_spaces)}
unit="шт"
/>
</div>
</Section>
{/* Финмодель */}
<Section
title="Финансовая модель"
subtitle="Оценка выручки и доходности по ценам класса жилья и стоимости участка."
right={<ConceptExportButtons variant={variant} />}
>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
}}
>
<KpiCard
label="Выручка"
value={formatMoneyCompact(financial.revenue_rub)}
/>
<KpiCard
label="Затраты"
value={formatMoneyCompact(financial.cost_rub)}
/>
<KpiCard
label="Валовая прибыль"
value={formatMoneyCompact(financial.gross_margin_rub)}
delta={{
value:
marginPositive === false
? "Отрицательная маржа"
: "Положительная маржа",
positive: marginPositive,
}}
/>
<KpiCard
label="IRR"
value={formatPct(financial.irr)}
delta={{ value: "Внутренняя норма доходности", positive: null }}
/>
</div>
</Section>
</div>
);
}
// ── Component ─────────────────────────────────────────────────────────────────
interface Props {
parcel: Polygon;
variants: ConceptVariant[];
}
export function ConceptVariantsResult({ parcel, variants }: 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)];
return (
<div>
{/* Variant tabs */}
<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",
}}
>
{STRATEGY_LABELS[v.strategy]}
<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} />
</div>
);
}