From 1684363b5a3f518ff83ec09548d3d54d76f30c8b Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 11:03:45 +0500 Subject: [PATCH] =?UTF-8?q?feat(site-finder):=20=C2=AB=D0=9F=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D1=87=D0=B8=D1=82=D0=B0=D1=82=D1=8C=20=C2=A77=20?= =?UTF-8?q?=D1=81=20=D1=8D=D1=82=D0=BE=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D0=BC=D0=BC=D0=BE=D0=B9=C2=BB=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D0=B1=D0=BB=D0=BE=D0=BA=D0=B0=20=D0=B0=D0=BB=D1=8C=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=BD=D0=B0=D1=82=D0=B8=D0=B2=20(#2181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Хвост плана #2181: одним кликом применить вариант из блока «Как участок сходится» к §7 «Концепция». - SectionAlternatives: на каждой карточке варианта secondary-CTA (--accent-2, orange), передаёт класс/этажность/тип застройки варианта в §7 и плавно скроллит к #section-7. Дизейбл, пока §7 считает. - Section7Concept: useEffect на pendingProgram — раскрывает §7, подбирает тип секции + пятно из каталога по КЛАССУ варианта (pickTypeForClass, как pickDefaultType но для заданного класса), выставляет этажность/класс/тип, densify=false, запускает handleGenerate; после старта зовёт onProgramApplied (родитель обнуляет pending → повторный клик снова срабатывает). autoRan/ autoRetried refs не трогаются — ручной пересчёт всегда разрешён. Тонкий канал onPendingChange(concept.isPending) вверх для дизейбла CTA. - AnalysisPageContent: state pendingProgram + conceptPending, проброс между двумя секциями. - Новый узкий тип PendingConceptProgram в types/site-finder. tsc --noEmit чисто, next lint без предупреждений. Co-Authored-By: Claude Fable 5 --- .../analysis/[cad]/AnalysisPageContent.tsx | 39 +++++++-- .../site-finder/analysis/Section7Concept.tsx | 85 ++++++++++++++++++- .../analysis/SectionAlternatives.tsx | 80 ++++++++++++++++- frontend/src/types/site-finder.ts | 14 +++ 4 files changed, 209 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx index 5eae665b..29830683 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useQueryClient } from "@tanstack/react-query"; @@ -16,7 +16,10 @@ import { Section6Forecast } from "@/components/site-finder/analysis/Section6Fore import { Section7Concept } from "@/components/site-finder/analysis/Section7Concept"; import { SectionAlternatives } from "@/components/site-finder/analysis/SectionAlternatives"; import { adaptEgrn, useParcelAnalyzeQuery } from "@/lib/site-finder-api"; -import type { ParcelAnalysis } from "@/types/site-finder"; +import type { + ParcelAnalysis, + PendingConceptProgram, +} from "@/types/site-finder"; // ── Props ───────────────────────────────────────────────────────────────────── @@ -29,6 +32,22 @@ interface Props { export function AnalysisPageContent({ cad }: Props) { const [horizon, setHorizon] = useState(12); const queryClient = useQueryClient(); + + // «Пересчитать §7 с этой программой» (план #2181, хвост): блок «Как участок + // сходится» кладёт сюда программу выбранного варианта, §7 «Концепция» её + // подхватывает (раскрывается + пересчитывает) и зовёт onProgramApplied → + // обнуляем pending, чтобы повторный клик по тому же варианту снова сработал. + const [pendingProgram, setPendingProgram] = + useState(null); + // §7 сообщает свой concept.isPending — блок альтернатив дизейблит CTA, пока + // идёт пересчёт (одна раскладка за раз). + const [conceptPending, setConceptPending] = useState(false); + + const handleApplyToConcept = useCallback( + (program: PendingConceptProgram) => setPendingProgram(program), + [], + ); + const handleProgramApplied = useCallback(() => setPendingProgram(null), []); const { data, isLoading, isFetching, error } = useParcelAnalyzeQuery( cad, horizon, @@ -226,13 +245,23 @@ export function AnalysisPageContent({ cad }: Props) { {/* 7. Концепция — генеративный дизайн застройки (#1965 Stage 1). Seeded из анализа (geom + financial_estimate), считается по запросу; result lazy-mounted (Leaflet). */} - + {/* «Как участок сходится» (#2181) — авто-ранжир вариантов программы (класс × этажность) по NPV при отрицательном фин-вердикте. Backend кладёт program_alternatives только тогда; иначе блок не рендерится. - Не дубль §7: без карт/3D, статичный топ-3. */} - + Не дубль §7: без карт/3D, статичный топ-3. Кнопка «Пересчитать §7 с + этой программой» (хвост #2181) прокидывает вариант в §7. */} + diff --git a/frontend/src/components/site-finder/analysis/Section7Concept.tsx b/frontend/src/components/site-finder/analysis/Section7Concept.tsx index 883cb004..58871800 100644 --- a/frontend/src/components/site-finder/analysis/Section7Concept.tsx +++ b/frontend/src/components/site-finder/analysis/Section7Concept.tsx @@ -52,7 +52,10 @@ import { type HousingClass, type HouseTypeCatalogItem, } from "@/lib/concept-api"; -import type { ParcelAnalysis } from "@/types/site-finder"; +import type { + ParcelAnalysis, + PendingConceptProgram, +} 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). @@ -123,16 +126,49 @@ function pickDefaultType( return catalog.find((ht) => ht.housing_class === "comfort") ?? catalog[0]; } +/** + * Каталожный тип секции ДЛЯ ЗАДАННОГО класса (apply-alternative, план #2181). + * Как pickDefaultType, но фильтр по housing_class варианта, а не по comfort: + * первый тип нужного класса → иначе pickDefaultType (comfort/первый). Так §7 + * подставляет пятно/этажность из типа, подходящего к пришедшему классу. + */ +function pickTypeForClass( + catalog: HouseTypeCatalogItem[], + housingClass: HousingClass, +): HouseTypeCatalogItem | null { + return ( + catalog.find((ht) => ht.housing_class === housingClass) ?? + pickDefaultType(catalog) + ); +} + // ── Props ───────────────────────────────────────────────────────────────────── interface Props { /** The /analyze response the report already holds — no re-fetch / re-entry. */ analysis: ParcelAnalysis; + /** + * «Пересчитать §7 с этой программой» (план #2181, хвост). Не null → §7 + * раскрывается, форма выставляется под класс/этажность/тип варианта (тип + * секции и пятно берём из каталога по классу), запускается пересчёт. После + * запуска §7 зовёт onProgramApplied() — родитель обнуляет pending, чтобы + * повторный клик по тому же варианту снова сработал. + */ + pendingProgram?: PendingConceptProgram | null; + /** Вызывается после старта пересчёта по pendingProgram — родитель чистит pending. */ + onProgramApplied?: () => void; + /** §7 сообщает родителю своё concept.isPending (дизейбл CTA в блоке альтернатив). */ + onPendingChange?: (pending: boolean) => void; } // ── Section7Concept ─────────────────────────────────────────────────────────── -export function Section7Concept({ analysis }: Props) { +export function Section7Concept({ + analysis, + pendingProgram, + onProgramApplied, + onPendingChange, +}: Props) { // Parcel polygon from the analysis geometry (handles Polygon / MultiPolygon / // null → null; MultiPolygon collapses to its first ring per extractPolygon). const polygon = useMemo( @@ -262,6 +298,51 @@ export function Section7Concept({ analysis }: Props) { }); }, [concept, polygon, catalog, seeded, handleGenerate]); + // ── Apply-alternative («Пересчитать §7 с этой программой», план #2181) ──── + // Клик по варианту в блоке «Как участок сходится» ставит pendingProgram в + // родителе. Здесь: раскрыть §7, подобрать тип секции + пятно из каталога по + // КЛАССУ варианта (pickTypeForClass), выставить этажность/класс/тип застройки + // варианта, densify=false → запустить пересчёт этой программой. Ручной запуск + // всегда разрешён (autoRanRef/autoRetriedRef НЕ трогаем; concept.mutate сам + // ресетит предыдущий результат). После старта зовём onProgramApplied() — + // родитель обнуляет pending, чтобы повторный клик по тому же варианту сработал. + useEffect(() => { + if (!pendingProgram || !polygon || catalog.length === 0) return; + const hc = asHousingClass(pendingProgram.housing_class) ?? "comfort"; + const dt = asDevelopmentType(pendingProgram.development_type) ?? "mid_rise"; + const type = pickTypeForClass(catalog, hc); + if (!type) return; + const floors = + pendingProgram.floors > 0 ? pendingProgram.floors : FLOORS_FALLBACK; + setExpanded(true); + handleGenerate({ + corpuses: CORPUSES_DEFAULT, + floors, + floor_height_m: 3.0, + section_type: type.section_type, + footprint_w_m: type.footprint_w_m, + footprint_d_m: type.footprint_d_m, + housing_class: hc, + development_type: dt, + land_cost_rub: seeded.land_cost_rub, + densify: false, + }); + onProgramApplied?.(); + }, [ + pendingProgram, + polygon, + catalog, + seeded.land_cost_rub, + handleGenerate, + onProgramApplied, + ]); + + // Сообщаем родителю concept.isPending — блок альтернатив дизейблит CTA, пока §7 + // считает (одна раскладка за раз). Тонкий канал вверх, без подъёма мутации. + useEffect(() => { + onPendingChange?.(concept.isPending); + }, [concept.isPending, onPendingChange]); + const handleToggle = useCallback( (e: React.SyntheticEvent) => { if (e.currentTarget.open) setExpanded(true); diff --git a/frontend/src/components/site-finder/analysis/SectionAlternatives.tsx b/frontend/src/components/site-finder/analysis/SectionAlternatives.tsx index 2fd7550f..11131c1d 100644 --- a/frontend/src/components/site-finder/analysis/SectionAlternatives.tsx +++ b/frontend/src/components/site-finder/analysis/SectionAlternatives.tsx @@ -23,7 +23,11 @@ import { type DevelopmentType, type HousingClass, } from "@/lib/concept-api"; -import type { ProgramAlternatives, ProgramVariant } from "@/types/site-finder"; +import type { + PendingConceptProgram, + ProgramAlternatives, + ProgramVariant, +} from "@/types/site-finder"; const SCROLL_MARGIN = 72; const TOP_N = 3; @@ -113,6 +117,15 @@ function priceSourceLabel(source: string): string { interface Props { /** program_alternatives из ответа /analyze (null/пусто → блок не рендерится). */ alternatives?: ProgramAlternatives | null; + /** + * «Пересчитать §7 с этой программой» (план #2181, хвост). Если задан — на + * каждой карточке появляется secondary-CTA, по клику передающая класс/этажность/ + * тип застройки варианта в §7 «Концепция» (родитель ставит pendingProgram + + * скроллит к #section-7). Не задан → кнопки нет (блок остаётся статичным топ-3). + */ + onApplyToConcept?: (program: PendingConceptProgram) => void; + /** §7 сейчас считает — CTA дизейблится (одна раскладка за раз). */ + conceptPending?: boolean; } // ── Variant card ────────────────────────────────────────────────────────────── @@ -120,9 +133,14 @@ interface Props { function VariantCard({ variant, isBest, + onApply, + applyDisabled, }: { variant: ProgramVariant; isBest: boolean; + /** Задан → рендерим CTA «Пересчитать §7 с этой программой». */ + onApply?: (program: PendingConceptProgram) => void; + applyDisabled: boolean; }) { const npvPositive = variant.npv_rub > 0; const npvColor = npvPositive @@ -212,17 +230,73 @@ function VariantCard({ Цена {formatPricePerSqm(variant.price_per_sqm_used)} ( {priceSourceLabel(variant.price_source)}) + + {/* CTA «Пересчитать §7» — secondary orange (--accent-2), не primary: одна + primary на экран (в §7 — «Рассчитать концепцию»). Передаём только + класс/этажность/тип; §7 добирает тип секции и пятно из каталога. */} + {onApply && ( + + )} ); } // ── SectionAlternatives ─────────────────────────────────────────────────────── -export function SectionAlternatives({ alternatives }: Props) { +export function SectionAlternatives({ + alternatives, + onApplyToConcept, + conceptPending = false, +}: Props) { // Рендер-guard: нет поля / пустой список → блок не рендерится вовсе. const variants = alternatives?.variants ?? []; if (!alternatives || variants.length === 0) return null; + // Клик по CTA: сначала ставим pendingProgram в родителе (это триггерит §7), + // затем плавно скроллим к §7, чтобы пользователь увидел пересчёт. Скролл здесь + // (не в родителе) — секция §7 всегда в DOM, якорь #section-7 гарантированно есть. + const handleApply = onApplyToConcept + ? (program: PendingConceptProgram) => { + onApplyToConcept(program); + if (typeof document !== "undefined") { + document + .getElementById("section-7") + ?.scrollIntoView({ behavior: "smooth", block: "start" }); + } + } + : undefined; + const topVariants = variants.slice(0, TOP_N); const { any_viable, grid_size, caveat } = alternatives; @@ -265,6 +339,8 @@ export function SectionAlternatives({ alternatives }: Props) { key={`${variant.housing_class}-${variant.floors}-${variant.development_type}`} variant={variant} isBest={i === bestIndex} + onApply={handleApply} + applyDisabled={conceptPending} /> ))} diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 8a12aaac..3ed312de 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -580,6 +580,20 @@ export interface ProgramVariant { schedule_is_default: boolean; } +/** + * Программа варианта, ПЕРЕДАВАЕМАЯ из блока «Как участок сходится» (#2181) в §7 + * «Концепция» по клику «Пересчитать §7 с этой программой» (план #2181, хвост). + * Узкий срез ProgramVariant: только класс/этажность/тип застройки — §7 сам + * подбирает тип секции и пятно из каталога по housing_class (как pickDefaultType). + * housing_class / development_type — строки backend'а; §7 нарроуит их к литералам + * (asHousingClass / asDevelopmentType) с fallback comfort / mid_rise. + */ +export interface PendingConceptProgram { + housing_class: string; + floors: number; + development_type: string; +} + /** * Блок «Как участок сходится» (#2181) — ранжир вариантов программы. Присутствует * только при отрицательном базовом фин-вердикте; иначе null / отсутствует.