(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 (
+
+
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/concept/ConceptParamsForm.tsx b/frontend/src/components/concept/ConceptParamsForm.tsx
new file mode 100644
index 00000000..95117f61
--- /dev/null
+++ b/frontend/src/components/concept/ConceptParamsForm.tsx
@@ -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 (
+
+ );
+}
diff --git a/frontend/src/components/concept/ConceptResultMap.tsx b/frontend/src/components/concept/ConceptResultMap.tsx
new file mode 100644
index 00000000..efd0daab
--- /dev/null
+++ b/frontend/src/components/concept/ConceptResultMap.tsx
@@ -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 (
+
+
+
+
+ {/* Parcel boundary — outline only so buildings read on top. */}
+
+
+ {/* Generated buildings — filled blocks. */}
+ {hasBuildings && (
+
+ )}
+
+
+
+
+ {!hasBuildings && (
+
+ Движок ещё не вернул геометрию зданий для этого варианта.
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/concept/ConceptVariantsResult.tsx b/frontend/src/components/concept/ConceptVariantsResult.tsx
new file mode 100644
index 00000000..29ef3e16
--- /dev/null
+++ b/frontend/src/components/concept/ConceptVariantsResult.tsx
@@ -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: () => (
+
+ Загрузка карты…
+
+ ),
+ },
+);
+
+// ── 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 (
+
+ {/* 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.gross_margin_rub)}
+
+
+ {STRATEGY_HINTS[variant.strategy]} IRR {formatPct(financial.irr)} ·
+ плотность {teap.density.toLocaleString("ru-RU")} м²/га.
+
+
+
+ {/* Placement map */}
+
+
+ {/* ТЭП — KPI grid */}
+
+
+ {/* Финмодель */}
+
}
+ >
+
+
+
+
+
+
+
+
+ );
+}
+
+// ── 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 (
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/frontend/src/lib/concept-api.ts b/frontend/src/lib/concept-api.ts
new file mode 100644
index 00000000..b65bb48b
--- /dev/null
+++ b/frontend/src/lib/concept-api.ts
@@ -0,0 +1,195 @@
+/**
+ * Concept (Generative Design) API — TanStack Query wrappers.
+ *
+ * Contract mirrors `backend/app/schemas/concept.py` exactly (Stage 1a frozen
+ * interface). Types are defined inline rather than via `npm run codegen`
+ * because codegen targets a live OpenAPI on localhost:8000 (`src/lib/
+ * api-types.ts`) which is unreachable in this env — same approach as the
+ * parcel-chat contract in `site-finder-api.ts`.
+ *
+ * Endpoint: POST /api/v1/concepts → ConceptOutput.
+ * Export endpoints (DXF/PDF) are not in the Stage-1a contract yet; the export
+ * buttons fall back to client-side GeoJSON/CSV generation and probe the
+ * documented `/concepts/{...}/export` path opportunistically (see
+ * ConceptExportButtons).
+ */
+
+import { useMutation } from "@tanstack/react-query";
+import type { Feature, FeatureCollection, Polygon } from "geojson";
+
+import { apiFetch, apiFetchWithStatus } from "@/lib/api";
+
+// ── Input contract (ConceptInput) ───────────────────────────────────────────
+
+export type HousingClass = "econom" | "comfort" | "business";
+export type DevelopmentType = "spot" | "mid_rise" | "high_rise";
+export type ConceptStrategy = "max_area" | "max_insolation" | "balanced";
+
+export interface ConceptInput {
+ /** GeoJSON Polygon of the parcel (WGS84 / EPSG:4326). */
+ parcel_geojson: Polygon;
+ housing_class: HousingClass;
+ /** Целевая этажность 1–30. */
+ target_floors: number;
+ development_type: DevelopmentType;
+ /** Стоимость участка (₽) — опционально, для финмодели. */
+ land_cost_rub?: number | null;
+}
+
+// ── Output contract (ConceptOutput) ─────────────────────────────────────────
+
+/** Технико-экономические показатели (ТЭП). */
+export interface Teap {
+ built_area_sqm: number;
+ total_floor_area_sqm: number;
+ residential_area_sqm: number;
+ apartments_count: number;
+ density: number;
+ parking_spaces: number;
+}
+
+export interface FinancialModel {
+ revenue_rub: number;
+ cost_rub: number;
+ gross_margin_rub: number;
+ irr: number;
+}
+
+export interface ConceptVariant {
+ strategy: ConceptStrategy;
+ /** FeatureCollection полигонов зданий (WGS84). */
+ buildings_geojson: FeatureCollection;
+ teap: Teap;
+ financial: FinancialModel;
+}
+
+export interface ConceptOutput {
+ variants: ConceptVariant[];
+}
+
+// ── Strategy display metadata ────────────────────────────────────────────────
+
+export const STRATEGY_LABELS: Record = {
+ max_area: "Макс. площадь",
+ max_insolation: "Макс. инсоляция",
+ balanced: "Баланс",
+};
+
+export const STRATEGY_HINTS: Record = {
+ max_area: "Максимальная плотность застройки в пределах нормативов.",
+ max_insolation: "Приоритет инсоляции и разрывов между корпусами.",
+ balanced: "Компромисс между ТЭП и качеством среды.",
+};
+
+export const HOUSING_CLASS_LABELS: Record = {
+ econom: "Эконом",
+ comfort: "Комфорт",
+ business: "Бизнес",
+};
+
+export const DEVELOPMENT_TYPE_LABELS: Record = {
+ spot: "Точечная",
+ mid_rise: "Среднеэтажная",
+ high_rise: "Высотная",
+};
+
+// ── Hook: useCreateConcept ────────────────────────────────────────────────────
+
+/**
+ * POST /api/v1/concepts — generate building variants for a parcel polygon.
+ * Action (not a query) → drive from `useMutation` per the data-layer rule.
+ * Uses the shared `apiFetch` (base URL + session header + Content-Type) rather
+ * than re-adding fetch config.
+ */
+export function useCreateConcept() {
+ return useMutation({
+ mutationFn: (payload) =>
+ apiFetch("/api/v1/concepts", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ }),
+ });
+}
+
+// ── Hook: useCadastreGeom ─────────────────────────────────────────────────────
+
+/**
+ * Resolves a cadastre number to its parcel polygon by POSTing the Site Finder
+ * analyze endpoint (`/api/v1/parcels/{cad}/analyze`) and reading `geom_geojson`.
+ * Re-uses the existing endpoint rather than adding a new backend route.
+ *
+ * The analyze endpoint returns 202 while НСПД geometry is still being fetched
+ * (see useParcelAnalyzeQuery). For the concept input we keep the UX simple: a
+ * 202 surfaces a "try again" error instead of a 2-minute poll loop — the user
+ * can re-submit once geometry is cached, or draw the polygon manually.
+ */
+export function useCadastreGeom() {
+ return useMutation({
+ mutationFn: async (cad) => {
+ const url = `/api/v1/parcels/${encodeURIComponent(cad)}/analyze?horizon=12`;
+ const { status, body } = await apiFetchWithStatus<{
+ geom_geojson?: unknown;
+ }>(url, { method: "POST" });
+
+ if (status === 202) {
+ throw new Error(
+ "Геометрия участка ещё загружается из НСПД. Повторите через несколько секунд.",
+ );
+ }
+ const polygon = extractPolygon(body?.geom_geojson);
+ if (!polygon) {
+ throw new Error(
+ "Не удалось получить геометрию участка по этому номеру.",
+ );
+ }
+ return polygon;
+ },
+ });
+}
+
+// ── Polygon helpers ───────────────────────────────────────────────────────────
+
+/**
+ * Narrows an unknown GeoJSON value (e.g. the analyze endpoint's `geom_geojson`)
+ * into a Polygon. Accepts a bare Polygon, a Feature wrapping a Polygon, or the
+ * first polygon ring of a MultiPolygon. Returns null on any other shape.
+ */
+export function extractPolygon(geom: unknown): Polygon | null {
+ if (geom == null || typeof geom !== "object") return null;
+ const g = geom as {
+ type?: unknown;
+ coordinates?: unknown;
+ geometry?: unknown;
+ };
+
+ if (g.type === "Feature") {
+ return extractPolygon((g as Feature).geometry);
+ }
+ if (g.type === "Polygon" && Array.isArray(g.coordinates)) {
+ return {
+ type: "Polygon",
+ coordinates: g.coordinates as Polygon["coordinates"],
+ };
+ }
+ if (g.type === "MultiPolygon" && Array.isArray(g.coordinates)) {
+ const first = (g.coordinates as Polygon["coordinates"][])[0];
+ if (first) return { type: "Polygon", coordinates: first };
+ }
+ return null;
+}
+
+/** Approximate parcel area in m² from a WGS84 polygon (spherical-excess shoelace). */
+export function polygonAreaSqm(polygon: Polygon): number {
+ const ring = polygon.coordinates[0];
+ if (!ring || ring.length < 4) return 0;
+ const R = 6_378_137; // WGS84 equatorial radius (m)
+ const toRad = (deg: number) => (deg * Math.PI) / 180;
+ let area = 0;
+ for (let i = 0; i < ring.length - 1; i++) {
+ const [lon1, lat1] = ring[i];
+ const [lon2, lat2] = ring[i + 1];
+ area +=
+ toRad(lon2 - lon1) * (2 + Math.sin(toRad(lat1)) + Math.sin(toRad(lat2)));
+ }
+ return Math.abs((area * R * R) / 2);
+}