Surface the §16-traceability evidence behind the supply forecast that previously lived only in exports: future_market.future_supply (§9.3 supply-pressure breakdown — open/hidden stock, absorption, months of pressure, future units, pressure index) and future_market.future_competitors (§9.7 relevance-weighted pipeline, sorted by relevance desc). New ForecastFutureSupplyBlock as subsection 6.6 (+ section-6-6 nav), complementing 6.1's per-horizon aggregates. - Fix FutureSupply/FutureSupplyBreakdown/FutureCompetitor optionality in forecast.ts to match the backend (FutureSupplyPressure/Competitor): null metrics typed + rendered «нет данных», never a fabricated 0. - Supply panel gates on computed metrics (index/pressure), NOT open/hidden unit stocks — so the empty-supply_layers state (pre Monday-worker, every parcel today) hides the panel instead of falsely showing "0 ед."; the competitor table still renders. hasSupplySignal shared in forecast-helpers. - RU labels reuse report_pdf/excel export wording. Graceful on partial/202. Part of #958; surfaces #950 future-supply data.
449 lines
14 KiB
TypeScript
449 lines
14 KiB
TypeScript
"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 (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
||
{showSupply && futureSupply && (
|
||
<SupplyPressurePanel supply={futureSupply} />
|
||
)}
|
||
{showCompetitors && <CompetitorsTable competitors={competitors} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Supply-pressure panel (§9.3) ─────────────────────────────────────────────────
|
||
|
||
function SupplyPressurePanel({ supply }: { supply: FutureSupply }) {
|
||
const b = supply.breakdown;
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||
{/* Header: subhead + context (район · помещение · горизонт) + confidence. */}
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexWrap: "wrap",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<span style={SUBHEAD_STYLE}>Давление будущего предложения</span>
|
||
<SupplyContext supply={supply} />
|
||
<ConfidencePill level={supply.confidence} />
|
||
</div>
|
||
|
||
{/* Pressure index — token bar (∈ 0…1, higher = more queued supply). */}
|
||
<PressureIndexBar index={supply.index} />
|
||
|
||
{/* Breakdown — labeled stat cells (the §16 evidence). */}
|
||
<StatGrid breakdown={b} />
|
||
|
||
<p style={HINT_STYLE}>
|
||
Месяцев давления — сколько месяцев конкурирующего предложения (скрытый
|
||
запас плюс будущий слой) стоит в очереди при текущем темпе поглощения.
|
||
Индекс давления ∈ 0…1: выше — больше грядущего предложения относительно
|
||
скорости рынка.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<span style={{ fontSize: 12, color: "var(--fg-tertiary)" }}>
|
||
{parts.join(" · ")}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function ConfidencePill({ level }: { level: ConfidenceLevel }) {
|
||
return (
|
||
<Badge variant={confidenceVariant(level)} size="sm">
|
||
уверенность {CONFIDENCE_RU[level]}
|
||
</Badge>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "baseline",
|
||
justifyContent: "space-between",
|
||
gap: 12,
|
||
}}
|
||
>
|
||
<span style={{ fontSize: 13, color: "var(--fg-primary)" }}>
|
||
Индекс давления
|
||
</span>
|
||
<span
|
||
style={{
|
||
fontSize: 12,
|
||
color: hasValue ? "var(--fg-secondary)" : "var(--fg-tertiary)",
|
||
fontVariantNumeric: "tabular-nums",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
{valueStr}
|
||
</span>
|
||
</div>
|
||
<div
|
||
style={{
|
||
position: "relative",
|
||
height: 8,
|
||
borderRadius: 6,
|
||
background: "var(--bg-card-alt)",
|
||
border: "1px solid var(--border-soft)",
|
||
overflow: "hidden",
|
||
}}
|
||
role="img"
|
||
aria-label={`Индекс давления: ${valueStr}`}
|
||
>
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
insetBlock: 0,
|
||
insetInlineStart: 0,
|
||
width: `${widthPct}%`,
|
||
// Pressure is a risk signal — accent fill (neutral valence, not
|
||
// success/danger): the index value carries the meaning, not colour.
|
||
background: "var(--accent)",
|
||
borderRadius: 6,
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 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 (
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))",
|
||
gap: 8,
|
||
}}
|
||
>
|
||
{cells.map((c) => (
|
||
<StatCellView key={c.label} cell={c} />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatCellView({ cell }: { cell: StatCell }) {
|
||
const { value } = cell;
|
||
const hasValue = value != null;
|
||
const valueStr = hasValue ? fmtNum(value, cell.digits) : NO_DATA;
|
||
return (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 4,
|
||
padding: "10px 12px",
|
||
background: "var(--bg-card-alt)",
|
||
border: "1px solid var(--border-soft)",
|
||
borderRadius: 8,
|
||
}}
|
||
>
|
||
<span style={SUBHEAD_STYLE}>{cell.label}</span>
|
||
<span
|
||
style={{
|
||
fontSize: 18,
|
||
fontWeight: 600,
|
||
color: hasValue ? "var(--fg-primary)" : "var(--fg-tertiary)",
|
||
fontVariantNumeric: "tabular-nums",
|
||
}}
|
||
>
|
||
{valueStr}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 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 (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||
<span style={SUBHEAD_STYLE}>Будущие конкуренты ({rows.length})</span>
|
||
<div style={{ overflowX: "auto" }}>
|
||
<table
|
||
style={{
|
||
width: "100%",
|
||
borderCollapse: "collapse",
|
||
minWidth: 640,
|
||
}}
|
||
>
|
||
<thead>
|
||
<tr>
|
||
<th style={TH_STYLE}>Название</th>
|
||
<th style={TH_STYLE}>Класс</th>
|
||
<th style={NUM_TH}>Расстояние</th>
|
||
<th style={NUM_TH}>Квартир</th>
|
||
<th style={NUM_TH}>Скорость/мес</th>
|
||
<th style={{ ...TH_STYLE, minWidth: 140 }}>Релевантность</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((c) => (
|
||
<CompetitorRow key={c.obj_id} competitor={c} />
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p style={HINT_STYLE}>
|
||
Релевантность ∈ 0…1 — взвешенный сигнал близости, класса, цены и стадии
|
||
к горизонту (§9.7). Список — top-N будущих конкурентов по релевантности.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CompetitorRow({ competitor }: { competitor: FutureCompetitor }) {
|
||
const name = competitor.comm_name?.trim() || "Без названия";
|
||
const rw = competitor.relevance_weight;
|
||
return (
|
||
<tr>
|
||
<td style={{ ...TD_STYLE, maxWidth: 280 }}>
|
||
<span
|
||
title={name}
|
||
style={{
|
||
display: "block",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{name}
|
||
</span>
|
||
</td>
|
||
<td style={TD_STYLE}>
|
||
{competitor.obj_class ? (
|
||
<Badge variant="neutral" size="sm">
|
||
{competitor.obj_class}
|
||
</Badge>
|
||
) : (
|
||
<span style={{ color: "var(--fg-tertiary)" }}>{NO_DATA}</span>
|
||
)}
|
||
</td>
|
||
<td style={NUM_TD}>
|
||
{competitor.distance_m != null
|
||
? `${fmtNum(competitor.distance_m, 0)} м`
|
||
: NO_DATA}
|
||
</td>
|
||
<td style={NUM_TD}>
|
||
{competitor.flats_total != null
|
||
? competitor.flats_total.toLocaleString("ru")
|
||
: NO_DATA}
|
||
</td>
|
||
<td style={NUM_TD}>
|
||
{competitor.velocity_per_month != null
|
||
? fmtNum(competitor.velocity_per_month, 1)
|
||
: NO_DATA}
|
||
</td>
|
||
<td style={TD_STYLE}>
|
||
<RelevanceBar value={rw} />
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||
<div
|
||
style={{
|
||
position: "relative",
|
||
flex: 1,
|
||
minWidth: 64,
|
||
height: 8,
|
||
borderRadius: 6,
|
||
background: "var(--bg-card-alt)",
|
||
border: "1px solid var(--border-soft)",
|
||
overflow: "hidden",
|
||
}}
|
||
role="img"
|
||
aria-label={`Релевантность: ${valueStr}`}
|
||
>
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
insetBlock: 0,
|
||
insetInlineStart: 0,
|
||
width: `${widthPct}%`,
|
||
background: "var(--accent)",
|
||
borderRadius: 6,
|
||
}}
|
||
/>
|
||
</div>
|
||
<span
|
||
style={{
|
||
fontSize: 12,
|
||
color: hasValue ? "var(--fg-secondary)" : "var(--fg-tertiary)",
|
||
fontVariantNumeric: "tabular-nums",
|
||
flexShrink: 0,
|
||
minWidth: 36,
|
||
textAlign: "right",
|
||
}}
|
||
>
|
||
{valueStr}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|