This commit is contained in:
parent
14d76fb136
commit
e440209d91
4 changed files with 209 additions and 9 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 / отсутствует.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue