"use client"; /** * Section7Concept — «7. Концепция» (epic #1953, unify-concept rewrite). * * ONE engine. The section folds the generative concept tooling into the LIGHT * analysis report after «6. Прогноз». A SINGLE source of numbers: POST /concepts * with a `building_program` set drives the placement map + ТЭП + финмодель * (ConceptVariantsResult). The old two-engine split (live 3D massing + a * separate params form / greedy run) is gone — it produced incoherent numbers * and a torn layout. * * «Число корпусов» maps directly to a single-type program item * `[{ section_type, floors, count }]`: the backend's `place_program` lays out * EXACTLY `count` footprints, so scatter is structurally impossible while the * «уплотнить» toggle is OFF. Toggle ON → building_program is omitted → greedy * max-FAR (the explicit, opt-in dense buildout). * * Inputs are seeded from the analysis the report already has (no re-entry): * • parcel polygon ← extractPolygon(analysis.geom_geojson) * • housing_class ← financial_estimate.housing_class_inferred * • development_type← financial_estimate.development_type_inferred * • land_cost_rub ← egrn.cadastral_value_rub * Falls back to comfort / mid_rise when those are null. * * The section is OPEN by default (Anton feedback 2026-07-02, issue #2179): the * concept is the report's key deliverable. It auto-runs once on mount with the * default program (count = 3) so the user lands on a result, not an empty form. * The
can still be collapsed via its summary. * * СЗЗ / financial_estimate === null is NOT a dead end: /concepts only needs a * polygon + form inputs (independent of the financial_estimate regulatory gate), * so we show an honest banner and STILL allow a run. */ import dynamic from "next/dynamic"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { HeadlineBar } from "@/components/ui/HeadlineBar"; import { Section } from "@/components/analytics/Section"; import { ConceptProgramForm, type ConceptProgramFormState, } from "@/components/concept/ConceptProgramForm"; import { extractPolygon, polygonAreaSqm, useCreateConcept, useHouseTypes, type BuildingProgramItem, type DevelopmentType, type HousingClass, type HouseTypeCatalogItem, } from "@/lib/concept-api"; import type { ParcelAnalysis } from "@/types/site-finder"; // ConceptVariantsResult hosts a Leaflet placement map — lazy-mount, no SSR, so // it never bloats the main report bundle (loaded only when results exist). const ConceptVariantsResult = dynamic( () => import("@/components/concept/ConceptVariantsResult").then( (m) => m.ConceptVariantsResult, ), { ssr: false, loading: () => (
Загрузка результата…
), }, ); const SCROLL_MARGIN = 72; // Fallback floors when neither the catalog nor a selection is available yet. const FLOORS_FALLBACK = 9; const CORPUSES_DEFAULT = 3; // ── Narrowing helpers (inferred values are typed `string` on the analysis) ──── const HOUSING_CLASSES: readonly HousingClass[] = [ "econom", "comfort", "business", ]; const DEVELOPMENT_TYPES: readonly DevelopmentType[] = [ "spot", "mid_rise", "high_rise", ]; function asHousingClass(value: string | undefined): HousingClass | null { return value != null && (HOUSING_CLASSES as readonly string[]).includes(value) ? (value as HousingClass) : null; } function asDevelopmentType(value: string | undefined): DevelopmentType | null { return value != null && (DEVELOPMENT_TYPES as readonly string[]).includes(value) ? (value as DevelopmentType) : null; } /** First comfort-class catalog type (else first entry, else null). */ function pickDefaultType( catalog: HouseTypeCatalogItem[], ): HouseTypeCatalogItem | null { if (catalog.length === 0) return null; return catalog.find((ht) => ht.housing_class === "comfort") ?? catalog[0]; } // ── Props ───────────────────────────────────────────────────────────────────── interface Props { /** The /analyze response the report already holds — no re-fetch / re-entry. */ analysis: ParcelAnalysis; } // ── Section7Concept ─────────────────────────────────────────────────────────── export function Section7Concept({ analysis }: Props) { // Parcel polygon from the analysis geometry (handles Polygon / MultiPolygon / // null → null; MultiPolygon collapses to its first ring per extractPolygon). const polygon = useMemo( () => extractPolygon(analysis.geom_geojson), [analysis.geom_geojson], ); // Seed defaults from the analysis: inferred class/type + cadastral value. // financial_estimate is null under the regulatory gate (СЗЗ / ЗОУИТ / нет // зоны) — fall back to comfort / mid_rise so a manual run is still possible. const fin = analysis.financial_estimate; const seeded = useMemo( () => ({ housing_class: asHousingClass(fin?.housing_class_inferred) ?? "comfort", development_type: asDevelopmentType(fin?.development_type_inferred) ?? "mid_rise", land_cost_rub: analysis.egrn?.cadastral_value_rub ?? null, }), [ fin?.housing_class_inferred, fin?.development_type_inferred, analysis.egrn?.cadastral_value_rub, ], ); const concept = useCreateConcept(); // Lift the catalog to the parent so auto-run-on-first-expand can build the // default program from the real catalog (single source of truth, no hardcode). const houseTypes = useHouseTypes(); const catalog = useMemo(() => houseTypes.data ?? [], [houseTypes.data]); const financeUnavailable = fin == null; const parcelAreaSqm = polygon ? polygonAreaSqm(polygon) : 0; // Build a concept.mutate payload from a fully-resolved form state. «Число // корпусов» → a single-type program item's `count` (toggle OFF). Toggle ON → // building_program omitted → greedy max-FAR (explicit dense opt-in). const handleGenerate = useCallback( (state: ConceptProgramFormState) => { if (!polygon) return; const building_program: BuildingProgramItem[] | undefined = state.densify ? undefined : [ { section_type: state.section_type, floors: state.floors, count: state.corpuses, footprint_w_m: state.footprint_w_m, footprint_d_m: state.footprint_d_m, }, ]; concept.mutate({ parcel_geojson: polygon, housing_class: state.housing_class, // Backward-compat scalar; the program item carries the real floors. target_floors: state.floors, development_type: state.development_type, land_cost_rub: state.land_cost_rub, building_program, }); }, [polygon, concept], ); // ── Auto-run-on-first-expand ────────────────────────────────────────────── // When the
is opened the FIRST time AND a polygon + catalog exist // AND there's no result yet AND nothing is in flight → fire one run with the // default program (count = 3, comfort-class section type). A ref guards a // single execution; if the catalog isn't ready at open the effect re-fires // when it becomes ready (still only once, still only after first open). The //
flips `expanded`, which (re)triggers the effect. const autoRanRef = useRef(false); // §7 «Концепция» is OPEN by default (Anton feedback 2026-07-02, issue #2179): // the concept is the report's key deliverable, so the auto-run fires on mount // instead of waiting for a first expand. The user can still collapse the //
via the summary; onToggle keeps `expanded` in sync when reopened. const [expanded, setExpanded] = useState(true); // Одноразовый авторетрай (fix 2026-07-03): на узких/мелких участках дефолтная // программа (3 корпуса comfort 21×18) честно не влезает — движок отвечает 422 // «программа застройки не вместила ни одной секции». Показывать это ошибкой // при ЗАГРУЗКЕ отчёта нельзя (прод-кейс 66:41:0702033:20) — пробуем один раз // densify=true: greedy-раскладка сама вмещает, что физически влезает. // Не влезло и так → честная ошибка остаётся. Ручные запуски не ретраятся. const autoRetriedRef = useRef(false); useEffect(() => { if (autoRanRef.current || !expanded) return; if (!polygon || catalog.length === 0) return; if (concept.isPending || concept.isSuccess || concept.isError) return; const def = pickDefaultType(catalog); if (!def) return; autoRanRef.current = true; handleGenerate({ corpuses: CORPUSES_DEFAULT, floors: def.default_floors > 0 ? def.default_floors : FLOORS_FALLBACK, floor_height_m: 3.0, section_type: def.section_type, footprint_w_m: def.footprint_w_m, footprint_d_m: def.footprint_d_m, housing_class: seeded.housing_class, development_type: seeded.development_type, land_cost_rub: seeded.land_cost_rub, densify: false, }); }, [expanded, polygon, catalog, concept, seeded, handleGenerate]); useEffect(() => { if (!autoRanRef.current || autoRetriedRef.current) return; if (!concept.isError || !polygon || catalog.length === 0) return; if (!/не вместила/i.test(concept.error.message)) return; const def = pickDefaultType(catalog); if (!def) return; autoRetriedRef.current = true; handleGenerate({ corpuses: CORPUSES_DEFAULT, floors: def.default_floors > 0 ? def.default_floors : FLOORS_FALLBACK, floor_height_m: 3.0, section_type: def.section_type, footprint_w_m: def.footprint_w_m, footprint_d_m: def.footprint_d_m, housing_class: seeded.housing_class, development_type: seeded.development_type, land_cost_rub: seeded.land_cost_rub, densify: true, // greedy max-FAR: вместить, что физически влезает }); }, [concept, polygon, catalog, seeded, handleGenerate]); const handleToggle = useCallback( (e: React.SyntheticEvent) => { if (e.currentTarget.open) setExpanded(true); }, [], ); return (
{/* Open by default (Anton feedback 2026-07-02, issue #2179): концепция — ключевой deliverable отчёта, поэтому §7 раскрыт и авторасчёт стартует сразу на маунте. Пользователь может свернуть блок через summary. */}
Параметры и расчёт концепции
{!polygon ? (

Геометрия участка недоступна — концепцию построить нельзя. Геометрия загружается из НСПД; повторите анализ позже либо воспользуйтесь страницей «Концепция застройки», где границы можно задать вручную.

) : ( <> {/* СЗЗ / нет финмодели по регламенту — честный баннер, но НЕ тупик: /concepts работает по полигону + параметрам формы независимо от регуляторного гейта financial_estimate. */} {financeUnavailable && (
Финмодель по регламенту недоступна — на участке ЗОУИТ / СЗЗ либо не определена территориальная зона. Концепцию можно рассчитать вручную: задайте число корпусов, класс, тип застройки и этажность ниже — движок построит застройку по геометрии участка и заданным параметрам.
)} {/* Result region */} {concept.isError && (

Не удалось рассчитать концепцию: {concept.error.message}

)} {concept.isPending && (
Движок раскладывает корпуса и считает ТЭП и финмодель…
)} {concept.isSuccess && ( )} )}
); }