gendesign/frontend/src/components/site-finder/analysis/Section6Forecast.tsx
Light1YT 54a501b90a
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m56s
Deploy / deploy (push) Successful in 1m14s
feat(forecast): render product_tz (ТЗ на ЖК) as in-app subsection 6.4
Adds «6.4 Рекомендация по продукту» to Section6Forecast — the §13.4
product recommendation (class, unit-mix, commercial, USP, §16 reasons)
that previously existed only in the export renderers. Unit-mix is shown
as token-colored horizontal bars; since the assembler emits no per-format
share (pct), bars are honestly labeled as a deficit-by-format signal, not
a build-share. Graceful on thin/202-pending/partial reports.

- types/forecast.ts: ReportProductTz + nested interfaces, optional on ForecastReport
- ForecastProductTzBlock.tsx: per-field guards, role=img bars, reasons disclosure
- forecast-helpers.ts: deficitBarWidthPct
- Section6Forecast + sidebar nav: wire 6.4

Part of #958.
2026-06-07 09:55:54 +00:00

413 lines
13 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)
* 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 { ForecastChart } from "./ForecastChart";
import { ForecastHorizonsBlock } from "./ForecastHorizonsBlock";
import { ScenariosBlock } from "./ScenariosBlock";
import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock";
import { ForecastProductTzBlock } from "./ForecastProductTzBlock";
import { ForecastExportButtons } from "./ForecastExportButtons";
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
cad={cad}
report={data.report}
selectedHorizon={selectedHorizon}
/>
);
}
// ── ForecastReady (report present) ───────────────────────────────────────────
function ForecastReady({
cad,
report,
selectedHorizon,
}: {
cad: string;
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;
// 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)));
const hasAny = hasHorizons || hasScenarios || hasConfidence || hasProductTz;
// 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,
}}
>
{/* 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"
}
/>
<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>
)}
{/* Траектория индекса дефицита (chart-then-table, выше 6.1) */}
<ForecastChart report={report} selectedHorizon={selectedHorizon} />
{/* 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>
)}
{/* 6.4 Рекомендация по продукту — что строить на участке (§13.4) */}
{hasProductTz && pt && (
<SubBlock id="section-6-4" title="6.4 Рекомендация по продукту">
<ForecastProductTzBlock productTz={pt} />
</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";
}