Segmented control «Горизонт прогноза» driving the analyze query (?horizon=) and Section 6's target highlight. Default 12. - HorizonSelector: radiogroup + a11y radios, tokens-only, tabular-nums, disabled while a re-analyze is in flight - useParcelAnalyzeQuery(cad, horizon=12): horizon in queryKey + ?horizon=; keepPreviousData so switching horizon doesn't blank the page to loading - AnalysisPageContent: horizon state + forecast-poll invalidation on change - Section6Forecast/ForecastHorizonsBlock: selectedHorizon → target row («Целевой») Part of EPIC #958.
380 lines
12 KiB
TypeScript
380 lines
12 KiB
TypeScript
"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
|
||
* advisory disclaimer (footer)
|
||
*/
|
||
|
||
import { 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 { ForecastHorizonsBlock } from "./ForecastHorizonsBlock";
|
||
import { ScenariosBlock } from "./ScenariosBlock";
|
||
import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock";
|
||
import { CONFIDENCE_RU, deficitVariant, fmtNum } from "./forecast-helpers";
|
||
|
||
interface Props {
|
||
cad: string;
|
||
/**
|
||
* Horizon chosen in the analysis-screen HorizonSelector (6 / 12 / 18). 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 report={data.report} selectedHorizon={selectedHorizon} />;
|
||
}
|
||
|
||
// ── ForecastReady (report present) ───────────────────────────────────────────
|
||
|
||
function ForecastReady({
|
||
report,
|
||
selectedHorizon,
|
||
}: {
|
||
report: ForecastReport;
|
||
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;
|
||
|
||
const hasAny = hasHorizons || hasScenarios || hasConfidence;
|
||
|
||
// 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,
|
||
}}
|
||
>
|
||
{/* 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"
|
||
}
|
||
/>
|
||
<KpiCard
|
||
label="Мес. предложения"
|
||
value={
|
||
kn.months_of_inventory != null
|
||
? fmtNum(kn.months_of_inventory, 1)
|
||
: "—"
|
||
}
|
||
color="neutral"
|
||
/>
|
||
<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>
|
||
|
||
{!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>
|
||
)}
|
||
|
||
{/* 6.1 Прогноз по горизонтам */}
|
||
{hasHorizons && (
|
||
<SubBlock id="section-6-1" title="6.1 Прогноз по горизонтам">
|
||
<ForecastHorizonsBlock
|
||
forecasts={fm.forecasts_by_horizon}
|
||
targetHorizon={targetHorizon}
|
||
/>
|
||
</SubBlock>
|
||
)}
|
||
|
||
{/* 6.2 Сценарии */}
|
||
{hasScenarios && (
|
||
<SubBlock id="section-6-2" title="6.2 Сценарии">
|
||
<ScenariosBlock
|
||
scenarios={report.scenarios}
|
||
scenariosSummary={fm.scenarios_summary}
|
||
targetHorizon={targetHorizon}
|
||
/>
|
||
</SubBlock>
|
||
)}
|
||
|
||
{/* 6.3 Уверенность */}
|
||
{hasConfidence && (
|
||
<SubBlock id="section-6-3" title="6.3 Уверенность">
|
||
<ForecastConfidenceBlock confidence={report.confidence} />
|
||
</SubBlock>
|
||
)}
|
||
|
||
{/* Advisory disclaimer */}
|
||
<p
|
||
style={{
|
||
margin: 0,
|
||
fontSize: 12,
|
||
lineHeight: 1.5,
|
||
color: "var(--fg-tertiary)",
|
||
paddingTop: 12,
|
||
borderTop: "1px solid var(--border-soft)",
|
||
}}
|
||
>
|
||
{ADVISORY_DISCLAIMER}
|
||
</p>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// ── 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";
|
||
}
|