gendesign/frontend/src/components/site-finder/ptica/scenarios/PticaScenarios.tsx
Light1YT e8d0863996
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 57s
CI / openapi-codegen-check (pull_request) Successful in 1m54s
feat(ptica): close tab prototype-parity gaps (Сценарии 6.6 + Отчёты Параметры)
- Сценарии: add section 6.6 «будущее предложение и конкуренты» (FutureSupply)
  rendering report.future_market.future_competitors after ScoringTransparency
  (order 6.1→6.6). Columns ЖК/Класс/Квартир/Дистанция/Поглощение, real data,
  honest empty-state. Reuses .dtable.
- Отчёты: add «Параметры» panel (карта/финмодель/прогноз §22); §22 row tied to
  the real forecast-ready flag. Reuses StatusRow.
Verified on the all-tabs harness; no console errors. tokens-only, no fabrication.
2026-06-21 11:34:11 +05:00

156 lines
5.6 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";
import { FutureSupply } from "./FutureSupply";
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} />}
<FutureSupply competitors={fm.future_competitors} />
</div>
);
}