gendesign/frontend/src/components/site-finder/analysis/Section6Forecast.tsx
Light1YT 740210d9ea
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m49s
Deploy / deploy (push) Successful in 1m0s
feat(site-finder): horizon selector (6/12/18) on analysis screen (#996)
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.
2026-06-05 15:42:58 +05:00

380 lines
12 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
* 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";
}