gendesign/frontend/src/components/site-finder/analysis/Section6Forecast.tsx
Light1YT 6894c297d6
All checks were successful
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m11s
CI / openapi-codegen-check (pull_request) Successful in 2m13s
feat(site-finder): прогрессивное раскрытие секций отчёта §1–§6
Каждая секция показывает по умолчанию только основную информацию (вердикт-плашка
+ KPI-ряд), остальное (карты, таблицы, графики, разбивки) — под единым тумблером
«Раскрыть всё ▾». Выполнение запроса: «на каждом этапе только основную информацию,
далее жмёшь раскрыть и показывает всё что у нас есть».

- StageDetails — примитив-тумблер: default-collapsed, conditional-mount детей
  (НЕ display:none — чтобы Leaflet-карты инициализировались при раскрытии),
  aria-expanded, токены, Unicode-каретка.
- §1 Объект: свёрнуты 2-кол грид (карта+ЕГРН/НСПД/POI) + экспорт.
- §2 Оценка: свёрнуты risk-cards + ЗОУИТ + рекомендация.
- §3 Сети: свёрнуты предупреждение ЛЭП + счётчики + таблица + карта + caveat.
- §4 Рынок: свёрнуты настройки+таблица+блоки; CompetitorDetailDrawer оставлен
  смонтированным вне тумблера.
- §5 Атмосфера: свёрнут сезонный климат.
- §6 Прогноз: свёрнуты график + 6.1–6.6 + футер.
- §7 Концепция — не тронут (уже сам по себе progressive: форма → авто-результат).
2026-06-30 20:27:53 +05:00

672 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* Section6Forecast — "6. Прогноз" (958-B3 / §22).
*
* Renders the async §22 demand/supply forecast, scenarios and confidence on the
* Site Finder analysis screen. Self-fetches via useParcelForecastQuery, which
* POLLs GET /api/v1/parcels/{cad}/forecast every 4s until status === "ready"
* (the forecast is enqueued by useParcelAnalyzeQuery on page mount — render-only
* here, no trigger).
*
* Layout mirrors Section1-5: dark HeadlineBar + section root id="section-6".
* exec_summary banner (headline + verdict + KPI row)
* 6.1 Прогноз по горизонтам → ForecastHorizonsBlock
* 6.2 Сценарии → ScenariosBlock
* 6.3 Уверенность → ForecastConfidenceBlock
* 6.4 Рекомендация по продукту → ForecastProductTzBlock (что строить — §13.4)
* 6.5 Прозрачность скоринга → ForecastScoringBlock (почему скор — §13.6)
* 6.6 Будущее предложение и конкуренты → ForecastFutureSupplyBlock
* (§16-evidence за прогнозом предложения — §9.3 давление + §9.7 конкуренты)
* advisory disclaimer (footer)
*/
import { AlertTriangle, LineChart } from "lucide-react";
import { HeadlineBar } from "@/components/ui/HeadlineBar";
import { KpiCard } from "@/components/site-finder/KpiCard";
import { useParcelForecastQuery } from "@/lib/site-finder-api";
import type { ForecastReport } from "@/types/forecast";
import { ForecastChart } from "./ForecastChart";
import { ForecastHorizonsBlock } from "./ForecastHorizonsBlock";
import { ScenariosBlock } from "./ScenariosBlock";
import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock";
import { ForecastProductTzBlock } from "./ForecastProductTzBlock";
import { ForecastAssortmentBlock } from "./ForecastAssortmentBlock";
import { ForecastScoringBlock } from "./ForecastScoringBlock";
import { ForecastFutureSupplyBlock } from "./ForecastFutureSupplyBlock";
import { ForecastMetaLine } from "./ForecastMetaLine";
import { ForecastExportButtons } from "./ForecastExportButtons";
import { StageDetails } from "./StageDetails";
import {
CONFIDENCE_RU,
deficitPegState,
deficitVariant,
fmtNum,
hasSupplySignal,
} from "./forecast-helpers";
interface Props {
cad: string;
/**
* Horizon chosen in the analysis-screen HorizonSelector (6 / 12 / 18 / 24). When
* set it takes priority over the report-derived horizon so the user's choice
* instantly drives which horizon is treated as the target (client-side), even
* before the recomputed forecast finishes (see AnalysisPageContent comment).
*/
selectedHorizon?: number;
}
const ADVISORY_DISCLAIMER =
"Оценка advisory — не основание для инвест-решения.";
const SCROLL_MARGIN = 72;
// KpiCard color enum (site-finder variant) mapped from Badge variant words.
function kpiColorForDeficit(deficitIndex: number): "green" | "red" | "neutral" {
const v = deficitVariant(deficitIndex);
if (v === "success") return "green";
if (v === "danger") return "red";
return "neutral";
}
// ── Section6Forecast ─────────────────────────────────────────────────────────
export function Section6Forecast({ cad, selectedHorizon }: Props) {
const { data, isLoading, error } = useParcelForecastQuery(cad);
const isPending =
isLoading || (data != null && data.status !== "ready") || data == null;
// ── Pending: forecast still computing (poll in flight) ─────────────────────
if (isPending && !error) {
return (
<section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}>
<HeadlineBar
title="6. Прогноз"
subtitle="Прогноз спроса, предложения и сценарии (§22)."
/>
<div
style={{
marginTop: 12,
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
<div
style={{
borderRadius: 12,
background: "var(--bg-card-alt)",
border: "1px solid var(--border-soft)",
padding: "24px 20px",
textAlign: "center",
color: "var(--fg-tertiary)",
fontSize: 13,
}}
>
Прогноз рассчитывается
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
}}
>
{[0, 1, 2].map((i) => (
<div
key={i}
style={{
borderRadius: 12,
background: "var(--bg-card-alt)",
border: "1px solid var(--border-soft)",
height: 88,
}}
aria-hidden
/>
))}
</div>
</div>
</section>
);
}
// ── Error ──────────────────────────────────────────────────────────────────
if (error || !data || data.status !== "ready" || !data.report) {
return (
<section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}>
<HeadlineBar title="6. Прогноз" />
<div
style={{
marginTop: 12,
borderRadius: 12,
padding: "32px 24px",
background: "var(--bg-card-alt)",
border: "1px dashed var(--border-strong)",
textAlign: "center",
color: "var(--fg-tertiary)",
fontSize: 13,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 8,
}}
>
<LineChart size={20} aria-hidden />
Данные прогноза недоступны
</div>
</section>
);
}
return (
<ForecastReady
cad={cad}
report={data.report}
runCreatedAt={data.created_at}
selectedHorizon={selectedHorizon}
/>
);
}
// ── ForecastReady (report present) ───────────────────────────────────────────
function ForecastReady({
cad,
report,
runCreatedAt,
selectedHorizon,
}: {
cad: string;
report: ForecastReport;
/** Envelope `created_at` — the run's persist time, the real «когда рассчитан». */
runCreatedAt?: string | null;
selectedHorizon?: number;
}) {
const exec = report.exec_summary;
const fm = report.future_market;
// Target horizon: the user's HorizonSelector choice wins (instant client-side
// highlight), else future_supply.horizon_months, else median of meta horizons,
// else 12.
const targetHorizon =
selectedHorizon ??
fm.future_supply?.horizon_months ??
medianHorizon(report.meta.horizons) ??
12;
const kn = exec.key_numbers;
const hasHorizons = fm.forecasts_by_horizon.length > 0;
const hasScenarios =
Object.keys(report.scenarios.by_scenario).length > 0 ||
fm.scenarios_summary != null;
const hasConfidence =
report.confidence.level != null ||
Object.keys(report.confidence.factors).length > 0;
// 6.4 — рекомендация продукта (§13.4). product_tz is optional on the partial
// ForecastReport type; treat absent/thin section as "nothing to show".
const pt = report.product_tz;
const hasProductTz =
pt != null &&
(pt.obj_class != null ||
pt.mix.length > 0 ||
pt.usp.length > 0 ||
pt.reasons.length > 0 ||
!!pt.summary ||
(pt.commercial != null &&
(pt.commercial.available != null || !!pt.commercial.caveat)));
// 6.4a — прогноз по всем форматам (#1745). Показываем, когда квартирография
// несёт хотя бы одну форматную ячейку (с bucket) рекомендованного класса —
// иначе таблица пуста (объект класс не задан → деградируем к любым ячейкам).
const hasAssortment =
pt != null &&
pt.mix.some(
(m) =>
m.bucket != null &&
(pt.obj_class == null || m.obj_class === pt.obj_class),
);
// 6.5 — прозрачность скоринга (§13.6). scoring is optional on the partial
// ForecastReport type; populated when product_scores / special_indices exist.
const sc = report.scoring;
const hasScoring =
sc != null &&
((sc.product_scores != null &&
Object.keys(sc.product_scores.scores).length > 0) ||
(sc.special_indices != null &&
Object.keys(sc.special_indices.indices).length > 0));
// 6.6 — будущее предложение и конкуренты (§13.3 evidence). future_supply is
// shown only when it carries a genuinely-computed pressure signal (see
// hasSupplySignal — gates on computed metrics, NOT open/hidden stock which are
// 0-not-null when supply_layers is unloaded); future_competitors when the list
// is non-empty. The per-horizon 6.1 table stays the aggregate; this is the detail.
const hasFutureSupplyDetail =
hasSupplySignal(fm.future_supply) || fm.future_competitors.length > 0;
const hasAny =
hasHorizons ||
hasScenarios ||
hasConfidence ||
hasProductTz ||
hasAssortment ||
hasScoring ||
hasFutureSupplyDetail;
// B1 (#1958/#1959) — pegged-signal honesty-guard. deficit_index упирается в край
// шкалы (|value| ≥ 0.99) на ВСЕХ горизонтах того же знака → «1.00 ×4» не
// различает горизонты. Считаем ОДИН раз тут (источник — общий ряд горизонтов) и
// прокидываем в 6.1 (таблица) и 6.2 (сценарии), чтобы плашки не разъезжались.
const peg = deficitPegState(fm.forecasts_by_horizon);
// B2 — gate-caveat участка (см. блокеры: «Нельзя строить МКД»). Бэкенд проставляет
// product_tz.gate_caveat, когда жильё под запретом → весь §6-прогноз справочный.
// Поднимаем его на уровень ВСЕЙ секции (был бы закопан в 6.4) — заметная плашка.
const gateCaveat = pt?.gate_caveat?.trim() || null;
// Headline subtitle = future_market summary (one sentence «откуда / что значит»).
const subtitle = fm.summary ?? undefined;
return (
<section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}>
<HeadlineBar title={exec.headline ?? "6. Прогноз"} subtitle={subtitle} />
<div
style={{
marginTop: 12,
display: "flex",
flexDirection: "column",
gap: 24,
}}
>
{/* B2 — участок не под жильё: прогноз спроса/предложения справочный.
Заметная плашка В ВЕРХУ всего §6 (выше KPI), а не закопанная в 6.4. */}
{gateCaveat && <NonResidentialCaveat caveat={gateCaveat} />}
{/* Export / share forecast report (EPIC #959) */}
<ForecastExportButtons cad={cad} />
{/* exec_summary verdict + KPI row */}
{exec.verdict && (
<p
style={{
margin: 0,
fontSize: 14,
lineHeight: 1.5,
color: "var(--fg-primary)",
}}
>
{exec.verdict}
</p>
)}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
}}
>
<KpiCard
label="Индекс дефицита"
value={
kn.deficit_index != null
? `${kn.deficit_index > 0 ? "+" : ""}${fmtNum(kn.deficit_index, 2)}`
: "—"
}
color={
kn.deficit_index != null
? kpiColorForDeficit(kn.deficit_index)
: "neutral"
}
caption="шкала от 1 до +1"
/>
<KpiCard
label="Мес. предложения (MOI)"
value={
kn.months_of_inventory != null
? fmtNum(kn.months_of_inventory, 1)
: "—"
}
color="neutral"
caption="за сколько месяцев район распродаст текущее предложение"
/>
<KpiCard
label="Итоговый скор"
value={kn.overall_score != null ? fmtNum(kn.overall_score, 2) : "—"}
color="blue"
/>
<KpiCard
label="Уверенность"
value={
(exec.overall_confidence ?? kn.confidence) != null
? CONFIDENCE_RU[
(exec.overall_confidence ?? kn.confidence) as
"high" | "medium" | "low"
]
: "—"
}
color={confidenceKpiColor(exec.overall_confidence ?? kn.confidence)}
/>
</div>
{/* Легенда индекса дефицита (#1963) — что значит 1 / 0 / +1 + трактовка. */}
<DeficitLegend />
{!hasAny && (
<div
style={{
borderRadius: 12,
padding: "24px 20px",
background: "var(--bg-card-alt)",
border: "1px dashed var(--border-strong)",
textAlign: "center",
color: "var(--fg-tertiary)",
fontSize: 13,
}}
>
Данные прогноза недоступны
</div>
)}
<StageDetails>
{/* Траектория индекса дефицита (chart-then-table, выше 6.1). Причину
схлопывания сценариев показываем в 6.2 под заголовком «Почему один
сценарий, а не три» (#1963) — здесь дубль убран. */}
<ForecastChart report={report} selectedHorizon={selectedHorizon} />
{/* 6.1 Прогноз по горизонтам */}
{hasHorizons && (
<SubBlock id="section-6-1" title="6.1 Прогноз по горизонтам">
<ForecastHorizonsBlock
forecasts={fm.forecasts_by_horizon}
targetHorizon={targetHorizon}
peg={peg}
/>
</SubBlock>
)}
{/* 6.2 Сценарии */}
{hasScenarios && (
<SubBlock id="section-6-2" title="6.2 Сценарии">
<ScenariosBlock
scenarios={report.scenarios}
scenariosSummary={fm.scenarios_summary}
targetHorizon={targetHorizon}
peg={peg}
/>
</SubBlock>
)}
{/* 6.3 Уверенность */}
{hasConfidence && (
<SubBlock id="section-6-3" title="6.3 Уверенность">
<ForecastConfidenceBlock confidence={report.confidence} />
</SubBlock>
)}
{/* 6.4 Рекомендация по продукту — что строить на участке (§13.4) */}
{hasProductTz && pt && (
<SubBlock id="section-6-4" title="6.4 Рекомендация по продукту">
<ForecastProductTzBlock productTz={pt} />
</SubBlock>
)}
{/* 6.4a Прогноз по всем форматам — таблица ассортимента (#1745, фаза 1).
Под рекомендацией: весь срез дефицита по форматам рекомендованного
класса. Показываем только когда есть форматные ячейки квартирографии. */}
{hasAssortment && pt && (
<SubBlock id="section-6-4a" title="6.4 Прогноз по всем форматам">
<ForecastAssortmentBlock objClass={pt.obj_class} mix={pt.mix} />
</SubBlock>
)}
{/* 6.5 Прозрачность скоринга — почему итоговый скор такой (§13.6) */}
{hasScoring && sc && (
<SubBlock id="section-6-5" title="6.5 Прозрачность скоринга">
<ForecastScoringBlock scoring={sc} />
</SubBlock>
)}
{/* 6.6 Будущее предложение и конкуренты — §16-evidence за прогнозом (§13.3) */}
{hasFutureSupplyDetail && (
<SubBlock
id="section-6-6"
title="6.6 Будущее предложение и конкуренты"
>
<ForecastFutureSupplyBlock
futureSupply={fm.future_supply}
futureCompetitors={fm.future_competitors}
/>
</SubBlock>
)}
{/* Freshness / traceability: когда рассчитан + горизонты + версия схемы */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 6,
paddingTop: 12,
borderTop: "1px solid var(--border-soft)",
}}
>
<ForecastMetaLine meta={report.meta} runCreatedAt={runCreatedAt} />
{/* Advisory disclaimer (всегда последним) */}
<p
style={{
margin: 0,
fontSize: 12,
lineHeight: 1.5,
color: "var(--fg-tertiary)",
}}
>
{ADVISORY_DISCLAIMER}
</p>
</div>
</StageDetails>
</div>
</section>
);
}
// ── NonResidentialCaveat — участок не под жильё (B2) ──────────────────────────
//
// Заметная warn-плашка В ВЕРХУ §6: бэкенд проставил product_tz.gate_caveat, когда
// gate=«Нельзя строить МКД». Весь прогноз спроса/предложения тогда — справочный
// (считается по сегменту рынка, но строить жильё на участке нельзя). Паттерн
// --warn-soft / role="note" как у СЗЗ-баннера / VelocityBlock-caveat; иконка
// Lucide (НЕ emoji), токены с fallback. `caveat` — готовый RU-текст от бэкенда;
// под ним — наша микрокопия-следствие «прогноз носит справочный характер».
function NonResidentialCaveat({ caveat }: { caveat: string }) {
return (
<div
role="note"
style={{
display: "flex",
gap: 10,
alignItems: "flex-start",
padding: "12px 16px",
borderRadius: 12,
background: "var(--warn-soft, #FEF3C7)",
border: "1px solid var(--warn, #9A6700)",
}}
>
<AlertTriangle
size={18}
aria-hidden
style={{ flexShrink: 0, marginTop: 1, color: "var(--warn, #9A6700)" }}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span
style={{
fontSize: 13,
fontWeight: 600,
lineHeight: 1.5,
color: "var(--warn, #9A6700)",
}}
>
Участок не предназначен под жильё (см. блокеры) прогноз спроса и
предложения носит справочный характер.
</span>
<span
style={{
fontSize: 12,
lineHeight: 1.5,
color: "var(--fg-secondary)",
}}
>
{caveat}
</span>
</div>
</div>
);
}
// ── DeficitLegend — что значит шкала 1…+1 + связь с MOI (#1963) ─────────────
//
// Плоская строка-легенда под KPI: финдиректор видит «0.42» / «116.6 мес» без
// объяснения. Расшифровываем три точки шкалы простым языком + actionable-трактовку
// и привязываем MOI к индексу. Только токены, без хардкод-цвета.
function DeficitLegend() {
const items: { sign: string; color: string; text: string }[] = [
{
sign: "1",
color: "var(--danger)",
text: "затоварка: предложения больше спроса — выходить осторожно, держать темп продаж",
},
{
sign: "0",
color: "var(--fg-secondary)",
text: "баланс: спрос и предложение сопоставимы",
},
{
sign: "+1",
color: "var(--success)",
text: "острый дефицит: спрос недозакрыт — окно для выхода, возможна премия к цене",
},
];
return (
<div
style={{
borderRadius: 12,
border: "1px solid var(--border-soft)",
background: "var(--bg-card-alt)",
padding: "12px 16px",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<span
style={{
fontSize: 11,
fontWeight: 500,
letterSpacing: "0.04em",
textTransform: "uppercase",
color: "var(--fg-secondary)",
}}
>
Как читать индекс дефицита (шкала 1+1)
</span>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{items.map((it) => (
<div
key={it.sign}
style={{ display: "flex", alignItems: "baseline", gap: 8 }}
>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: it.color,
minWidth: 24,
fontVariantNumeric: "tabular-nums",
}}
>
{it.sign}
</span>
<span
style={{
fontSize: 12,
lineHeight: 1.5,
color: "var(--fg-secondary)",
}}
>
{it.text}
</span>
</div>
))}
</div>
<span
style={{ fontSize: 12, lineHeight: 1.5, color: "var(--fg-tertiary)" }}
>
«Мес. предложения» (MOI) за сколько месяцев район распродаст текущее
предложение при нынешнем темпе продаж: чем больше месяцев, тем сильнее
затоварка и ниже индекс.
</span>
</div>
);
}
// ── SubBlock wrapper (6.1 / 6.2 / 6.3) ───────────────────────────────────────
function SubBlock({
id,
title,
children,
}: {
id: string;
title: string;
children: React.ReactNode;
}) {
return (
<div id={id} style={{ scrollMarginTop: SCROLL_MARGIN }}>
<h3
style={{
margin: "0 0 12px",
fontSize: 14,
fontWeight: 600,
color: "var(--fg-primary)",
}}
>
{title}
</h3>
<div
style={{
borderRadius: 12,
border: "1px solid var(--border-card)",
background: "var(--bg-card)",
padding: 20,
}}
>
{children}
</div>
</div>
);
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function medianHorizon(horizons: number[]): number | null {
if (horizons.length === 0) return null;
const sorted = [...horizons].sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)];
}
function confidenceKpiColor(
level: "high" | "medium" | "low" | null,
): "green" | "amber" | "red" | "neutral" {
if (level === "high") return "green";
if (level === "medium") return "amber";
if (level === "low") return "red";
return "neutral";
}