"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 (

{title}

{children}
); } // ── 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"; }