feat(site-finder): «Пересчитать §7 с этой программой» из блока альтернатив (#2181 хвост) (#2256)
All checks were successful
Deploy / changes (push) Successful in 15s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 4m35s
Deploy / deploy (push) Successful in 1m28s

This commit is contained in:
bot-backend 2026-07-03 06:11:58 +00:00
parent 14d76fb136
commit e440209d91
4 changed files with 209 additions and 9 deletions

View file

@ -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<number>(12);
const queryClient = useQueryClient();
// «Пересчитать §7 с этой программой» (план #2181, хвост): блок «Как участок
// сходится» кладёт сюда программу выбранного варианта, §7 «Концепция» её
// подхватывает (раскрывается + пересчитывает) и зовёт onProgramApplied →
// обнуляем pending, чтобы повторный клик по тому же варианту снова сработал.
const [pendingProgram, setPendingProgram] =
useState<PendingConceptProgram | null>(null);
// §7 сообщает свой concept.isPending — блок альтернатив дизейблит CTA, пока
// идёт пересчёт (одна раскладка за раз).
const [conceptPending, setConceptPending] = useState<boolean>(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). */}
<Section7Concept analysis={analysis} />
<Section7Concept
analysis={analysis}
pendingProgram={pendingProgram}
onProgramApplied={handleProgramApplied}
onPendingChange={setConceptPending}
/>
{/* «Как участок сходится» (#2181) авто-ранжир вариантов программы
(класс × этажность) по NPV при отрицательном фин-вердикте. Backend
кладёт program_alternatives только тогда; иначе блок не рендерится.
Не дубль §7: без карт/3D, статичный топ-3. */}
<SectionAlternatives alternatives={analysis.program_alternatives} />
Не дубль §7: без карт/3D, статичный топ-3. Кнопка «Пересчитать §7 с
этой программой» (хвост #2181) прокидывает вариант в §7. */}
<SectionAlternatives
alternatives={analysis.program_alternatives}
onApplyToConcept={handleApplyToConcept}
conceptPending={conceptPending}
/>
</div>
</div>

View file

@ -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<HTMLDetailsElement>) => {
if (e.currentTarget.open) setExpanded(true);

View file

@ -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)})
</div>
{/* CTA «Пересчитать §7» secondary orange (--accent-2), не primary: одна
primary на экран (в §7 «Рассчитать концепцию»). Передаём только
класс/этажность/тип; §7 добирает тип секции и пятно из каталога. */}
{onApply && (
<button
type="button"
disabled={applyDisabled}
aria-label="Пересчитать раздел 7 «Концепция» с программой этого варианта"
onClick={() =>
onApply({
housing_class: variant.housing_class,
floors: variant.floors,
development_type: variant.development_type,
})
}
style={{
marginTop: 4,
alignSelf: "flex-start",
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
height: 32,
background: applyDisabled
? "var(--bg-card-alt)"
: "var(--accent-2)",
color: applyDisabled ? "var(--fg-tertiary)" : "#fff",
border: applyDisabled ? "1px solid var(--border-card)" : "none",
borderRadius: 6,
fontSize: 13,
fontWeight: 500,
cursor: applyDisabled ? "not-allowed" : "pointer",
}}
>
{applyDisabled ? "§7 считает…" : "Пересчитать §7 с этой программой"}
</button>
)}
</div>
);
}
// ── 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}
/>
))}
</div>

View file

@ -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 / отсутствует.