"use client"; /** * 6.6 Будущее предложение и конкуренты — §13.3 future_market evidence (#950/#949). * * The §16-traceability evidence BEHIND the supply forecast. 6.1's per-horizon * table shows the aggregate deficit; this block surfaces the two detail layers * that, until now, lived only in the exports: * * • Supply-pressure panel (from `future_market.future_supply`, §9.3) — the * FutureSupplyPressure breakdown as labeled stat cells: открытый сток / * скрытый запас / поглощение / месяцев давления / будущий слой в горизонте, * with district + premise_kind + horizon context + a confidence badge. The * pressure index (∈ 0…1) gets a token bar — higher = more queued supply. * • Future-competitors table (from `future_market.future_competitors`, §9.7) — * the relevance-weighted pipeline competition, sorted by relevance_weight * desc, with a 0…1 token bar for relevance. * * RU labels reuse the established export wording (report_pdf / excel * `_future_supply_pairs`): «Открытый сток», «Скрытый запас», «Будущий слой в * горизонте», «Поглощение», «Месяцев давления», «Индекс давления». * * GRACEFUL: returns null when BOTH future_supply and future_competitors are * absent/empty; each part is guarded independently; a null metric renders «нет * данных» (never a 0-bar implying a real zero); an empty competitor list skips * the table entirely. No crash on a partial / 202-pending report. */ import type { ConfidenceLevel, FutureCompetitor, FutureSupply, FutureSupplyBreakdown, } from "@/types/forecast"; import { Badge } from "@/components/ui/Badge"; import { CONFIDENCE_RU, confidenceVariant, fmtNum, hasSupplySignal, } from "./forecast-helpers"; interface Props { /** §9.3 future-supply pressure (evidence behind the supply forecast). */ futureSupply: FutureSupply | null; /** §9.7 relevance-weighted pipeline competitors. */ futureCompetitors: FutureCompetitor[]; } const NO_DATA = "нет данных"; const SUBHEAD_STYLE: React.CSSProperties = { fontSize: 11, fontWeight: 500, letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--fg-secondary)", }; const HINT_STYLE: React.CSSProperties = { margin: 0, fontSize: 12, lineHeight: 1.5, color: "var(--fg-tertiary)", }; // ── ForecastFutureSupplyBlock ──────────────────────────────────────────────────── export function ForecastFutureSupplyBlock({ futureSupply, futureCompetitors, }: Props) { const showSupply = hasSupplySignal(futureSupply); const competitors = futureCompetitors.filter( (c): c is FutureCompetitor => c != null && typeof c.obj_id === "number", ); const showCompetitors = competitors.length > 0; // Graceful: nothing meaningful in either part → render nothing. if (!showSupply && !showCompetitors) return null; return (
{showSupply && futureSupply && ( )} {showCompetitors && }
); } // ── Supply-pressure panel (§9.3) ───────────────────────────────────────────────── function SupplyPressurePanel({ supply }: { supply: FutureSupply }) { const b = supply.breakdown; return (
{/* Header: subhead + context (район · помещение · горизонт) + confidence. */}
Давление будущего предложения
{/* Pressure index — token bar (∈ 0…1, higher = more queued supply). */} {/* Breakdown — labeled stat cells (the §16 evidence). */}

Месяцев давления — сколько месяцев конкурирующего предложения (скрытый запас плюс будущий слой) стоит в очереди при текущем темпе поглощения. Индекс давления ∈ 0…1: выше — больше грядущего предложения относительно скорости рынка.

); } function SupplyContext({ supply }: { supply: FutureSupply }) { const parts: string[] = []; if (supply.district) parts.push(supply.district); if (supply.premise_kind) parts.push(supply.premise_kind); parts.push(`горизонт ${supply.horizon_months} мес.`); if (parts.length === 0) return null; return ( {parts.join(" · ")} ); } function ConfidencePill({ level }: { level: ConfidenceLevel }) { return ( уверенность {CONFIDENCE_RU[level]} ); } function PressureIndexBar({ index }: { index: number | null }) { const hasValue = index != null; const widthPct = hasValue ? Math.max(0, Math.min(100, index * 100)) : 0; const valueStr = hasValue ? fmtNum(index, 2) : NO_DATA; return (
Индекс давления {valueStr}
); } // ── Breakdown stat cells ───────────────────────────────────────────────────────── interface StatCell { label: string; value: number | null; digits: number; } function StatGrid({ breakdown }: { breakdown: FutureSupplyBreakdown }) { // RU labels reuse the export wording (report_pdf / excel _future_supply_pairs). const cells: StatCell[] = [ { label: "Открытый сток, ед.", value: breakdown.open_units, digits: 0 }, { label: "Скрытый запас, ед.", value: breakdown.hidden_units, digits: 0 }, { label: "Будущий слой в горизонте, ед.", value: breakdown.future_units_by_horizon, digits: 1, }, { label: "Поглощение, ед./мес", value: breakdown.monthly_absorption_units, digits: 1, }, { label: "Месяцев давления", value: breakdown.months_of_pressure, digits: 1, }, ]; return (
{cells.map((c) => ( ))}
); } function StatCellView({ cell }: { cell: StatCell }) { const { value } = cell; const hasValue = value != null; const valueStr = hasValue ? fmtNum(value, cell.digits) : NO_DATA; return (
{cell.label} {valueStr}
); } // ── Future-competitors table (§9.7) ────────────────────────────────────────────── const TH_STYLE: React.CSSProperties = { textAlign: "left", padding: "8px 12px", fontSize: 11, fontWeight: 500, letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--fg-secondary)", whiteSpace: "nowrap", borderBottom: "1px solid var(--border-card)", }; const TD_STYLE: React.CSSProperties = { padding: "10px 12px", fontSize: 13, color: "var(--fg-primary)", verticalAlign: "middle", borderBottom: "1px solid var(--border-soft)", fontVariantNumeric: "tabular-nums", }; const NUM_TD: React.CSSProperties = { ...TD_STYLE, textAlign: "right" }; const NUM_TH: React.CSSProperties = { ...TH_STYLE, textAlign: "right" }; function CompetitorsTable({ competitors, }: { competitors: FutureCompetitor[]; }) { // Sort by relevance_weight desc; nulls sink to the bottom (no fabricated 0). const rows = [...competitors].sort( (a, b) => (b.relevance_weight ?? -1) - (a.relevance_weight ?? -1), ); return (
Будущие конкуренты ({rows.length})
{rows.map((c) => ( ))}
Название Класс Расстояние Квартир Скорость/мес Релевантность

Релевантность ∈ 0…1 — взвешенный сигнал близости, класса, цены и стадии к горизонту (§9.7). Список — top-N будущих конкурентов по релевантности.

); } function CompetitorRow({ competitor }: { competitor: FutureCompetitor }) { const name = competitor.comm_name?.trim() || "Без названия"; const rw = competitor.relevance_weight; return ( {name} {competitor.obj_class ? ( {competitor.obj_class} ) : ( {NO_DATA} )} {competitor.distance_m != null ? `${fmtNum(competitor.distance_m, 0)} м` : NO_DATA} {competitor.flats_total != null ? competitor.flats_total.toLocaleString("ru") : NO_DATA} {competitor.velocity_per_month != null ? fmtNum(competitor.velocity_per_month, 1) : NO_DATA} ); } function RelevanceBar({ value }: { value: number | null }) { const hasValue = value != null; const widthPct = hasValue ? Math.max(0, Math.min(100, value * 100)) : 0; const valueStr = hasValue ? fmtNum(value, 2) : NO_DATA; return (
{valueStr}
); }