"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 (
);
}