Dark cockpit Scenarios tab wired to useParcelForecastQuery (ForecastEnvelope): scenario cards (base/aggr/conserv), demand-vs-supply chart + horizons table, scenario compare table, confidence panel, recommended product (квартирография + target price + success-score), scoring transparency. Calm polling skeleton while the async forecast is pending — never an error. Hero invest-score/buy-signal now surface real values once the forecast resolves (scoring.overall×10; buy-signal from exec_summary.key_numbers.deficit_index, derived independent of the overall score), keeping the "после прогноза" placeholders while pending. advisory · §22 caption shown in both states. All forecast field paths verified against types/forecast.ts. Dark theme stays scoped under .pticaRoot[data-theme=dark]. TS strict, no any.
153 lines
5.5 KiB
TypeScript
153 lines
5.5 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* PticaScenarios — root of the ПТИЦА dark «Сценарии» tab (§22 Прогноз).
|
||
*
|
||
* Reads the REAL async forecast via useParcelForecastQuery(cad). The forecast is
|
||
* enqueued fire-and-forget by /analyze on page mount — this hook only polls. While
|
||
* the envelope is `pending` (or the first request is loading) we render a CALM
|
||
* skeleton («Прогноз считается…»), NOT an error: the poll resolves once the report
|
||
* is assembled. Once `report` is present we compose the sub-blocks:
|
||
* 3 scenario cards · горизонты (chart + table) · сравнение · уверенность +
|
||
* рекомендованный продукт · прозрачность скоринга.
|
||
*
|
||
* The horizon (6/12/18 мес) selector lives in this tab's header; its state is
|
||
* lifted to PticaPageContent so the hero/other tabs can share it.
|
||
*/
|
||
|
||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||
import { useParcelForecastQuery } from "@/lib/site-finder-api";
|
||
import type { ForecastReport, ForecastSegment } from "@/types/forecast";
|
||
import { ScenarioCards } from "./ScenarioCards";
|
||
import { HorizonForecast } from "./HorizonForecast";
|
||
import { ScenarioCompareTable } from "./ScenarioCompareTable";
|
||
import { ConfidencePanel } from "./ConfidencePanel";
|
||
import { RecommendedProduct } from "./RecommendedProduct";
|
||
import { ScoringTransparency } from "./ScoringTransparency";
|
||
|
||
interface Props {
|
||
cad: string;
|
||
horizon: number;
|
||
onHorizonChange: (h: number) => void;
|
||
}
|
||
|
||
const HORIZONS = [6, 12, 18];
|
||
|
||
function segmentLabel(segment: ForecastSegment | undefined): string | null {
|
||
if (!segment) return null;
|
||
const parts = [segment.obj_class, segment.room_bucket].filter(
|
||
(p): p is string => !!p,
|
||
);
|
||
return parts.length > 0 ? parts.join(" · ") : null;
|
||
}
|
||
|
||
export function PticaScenarios({ cad, horizon, onHorizonChange }: Props) {
|
||
const { data, isLoading, error } = useParcelForecastQuery(cad);
|
||
|
||
const header = (
|
||
<div className={styles.viewHead}>
|
||
<h2>
|
||
Сценарии{" "}
|
||
<span className={styles.viewSub}>
|
||
прогноз спроса и предложения · §22
|
||
</span>
|
||
</h2>
|
||
<div className={styles.horizonSel}>
|
||
<span className={styles.horizonLab}>Горизонт</span>
|
||
<div className={styles.seg} role="group" aria-label="Горизонт прогноза">
|
||
{HORIZONS.map((h) => (
|
||
<button
|
||
key={h}
|
||
type="button"
|
||
className={h === horizon ? styles.segActive : undefined}
|
||
aria-pressed={h === horizon}
|
||
onClick={() => onHorizonChange(h)}
|
||
>
|
||
{h} мес
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
// ── Pending / loading → calm skeleton (NOT an error) ─────────────────────────
|
||
const report: ForecastReport | undefined =
|
||
data?.status === "ready" ? data.report : undefined;
|
||
const pending = isLoading || (!report && !error);
|
||
|
||
if (pending) {
|
||
return (
|
||
<div className={styles.scenariosRoot}>
|
||
{header}
|
||
<div className={`${styles.panel} ${styles.forecastPending}`}>
|
||
<div className={styles.pendingPulse} aria-hidden="true" />
|
||
<div className={styles.pendingTitle}>Прогноз считается…</div>
|
||
<p className={styles.pendingHint}>
|
||
Сценарный прогноз §22 формируется в фоне после запуска анализа.
|
||
Обычно занимает 1–2 минуты — обновится автоматически.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Error / no report → honest empty panel ───────────────────────────────────
|
||
if (error || !report) {
|
||
return (
|
||
<div className={styles.scenariosRoot}>
|
||
{header}
|
||
<div className={`${styles.panel} ${styles.emptyPanel}`}>
|
||
Прогноз §22 недоступен для этого участка.
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const fm = report.future_market;
|
||
const targetHorizon = horizon;
|
||
|
||
return (
|
||
<div className={styles.scenariosRoot}>
|
||
{header}
|
||
|
||
<ScenarioCards
|
||
scenarios={report.scenarios}
|
||
scenariosSummary={fm.scenarios_summary}
|
||
targetHorizon={targetHorizon}
|
||
/>
|
||
|
||
<HorizonForecast
|
||
forecasts={fm.forecasts_by_horizon}
|
||
targetHorizon={targetHorizon}
|
||
segmentLabel={segmentLabel(report.meta.segment)}
|
||
/>
|
||
|
||
<ScenarioCompareTable
|
||
scenarios={report.scenarios}
|
||
scenariosSummary={fm.scenarios_summary}
|
||
targetHorizon={targetHorizon}
|
||
/>
|
||
|
||
<div className={styles.sectionLabel}>
|
||
6.3 · <b>уверенность</b> · 6.4 · <b>рекомендация по продукту</b>
|
||
</div>
|
||
<section className={styles.forecastGrid}>
|
||
<ConfidencePanel confidence={report.confidence} />
|
||
{report.product_tz ? (
|
||
<RecommendedProduct
|
||
productTz={report.product_tz}
|
||
successScore={report.scoring?.overall ?? null}
|
||
targetPrice={null}
|
||
/>
|
||
) : (
|
||
<div className={`${styles.panel} ${styles.emptyPanel}`}>
|
||
Рекомендация по продукту недоступна.
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{report.scoring && <ScoringTransparency scoring={report.scoring} />}
|
||
</div>
|
||
);
|
||
}
|