"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 (
Прогноз рассчитывается…
{[0, 1, 2].map((i) => (
))}
);
}
// ── Error ──────────────────────────────────────────────────────────────────
if (error || !data || data.status !== "ready" || !data.report) {
return (
Данные прогноза недоступны
);
}
return (
);
}
// ── 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 (
{/* B2 — участок не под жильё: прогноз спроса/предложения справочный.
Заметная плашка В ВЕРХУ всего §6 (выше KPI), а не закопанная в 6.4. */}
{gateCaveat &&
}
{/* Export / share forecast report (EPIC #959) */}
{/* exec_summary verdict + KPI row */}
{exec.verdict && (
{exec.verdict}
)}
0 ? "+" : ""}${fmtNum(kn.deficit_index, 2)}`
: "—"
}
color={
kn.deficit_index != null
? kpiColorForDeficit(kn.deficit_index)
: "neutral"
}
caption="шкала от −1 до +1"
/>
{/* Легенда индекса дефицита (#1963) — что значит −1 / 0 / +1 + трактовка. */}
{!hasAny && (
Данные прогноза недоступны
)}
{/* Траектория индекса дефицита (chart-then-table, выше 6.1). Причину
схлопывания сценариев показываем в 6.2 под заголовком «Почему один
сценарий, а не три» (#1963) — здесь дубль убран. */}
{/* 6.1 Прогноз по горизонтам */}
{hasHorizons && (
)}
{/* 6.2 Сценарии */}
{hasScenarios && (
)}
{/* 6.3 Уверенность */}
{hasConfidence && (
)}
{/* 6.4 Рекомендация по продукту — что строить на участке (§13.4) */}
{hasProductTz && pt && (
)}
{/* 6.4a Прогноз по всем форматам — таблица ассортимента (#1745, фаза 1).
Под рекомендацией: весь срез дефицита по форматам рекомендованного
класса. Показываем только когда есть форматные ячейки квартирографии. */}
{hasAssortment && pt && (
)}
{/* 6.5 Прозрачность скоринга — почему итоговый скор такой (§13.6) */}
{hasScoring && sc && (
)}
{/* 6.6 Будущее предложение и конкуренты — §16-evidence за прогнозом (§13.3) */}
{hasFutureSupplyDetail && (
)}
{/* Freshness / traceability: когда рассчитан + горизонты + версия схемы */}
{/* Advisory disclaimer (всегда последним) */}
{ADVISORY_DISCLAIMER}
);
}
// ── 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 (
Участок не предназначен под жильё (см. блокеры) — прогноз спроса и
предложения носит справочный характер.
{caveat}
);
}
// ── 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 (
Как читать индекс дефицита (шкала −1…+1)
{items.map((it) => (
{it.sign}
{it.text}
))}
«Мес. предложения» (MOI) — за сколько месяцев район распродаст текущее
предложение при нынешнем темпе продаж: чем больше месяцев, тем сильнее
затоварка и ниже индекс.
);
}
// ── SubBlock wrapper (6.1 / 6.2 / 6.3) ───────────────────────────────────────
function SubBlock({
id,
title,
children,
}: {
id: string;
title: string;
children: React.ReactNode;
}) {
return (
);
}
// ── 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";
}