gendesign/frontend/src/components/site-finder/ptica/scenarios/PticaScenarios.tsx
Light1YT b0736e3a45 feat(ptica): build Сценарии tab (§22 forecast) + real hero invest-score
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.
2026-06-20 15:48:54 +05:00

153 lines
5.5 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";
/**
* 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 формируется в фоне после запуска анализа.
Обычно занимает 12 минуты обновится автоматически.
</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>
);
}