gendesign/frontend/src/components/site-finder/analysis/ForecastFutureSupplyBlock.tsx
Light1YT 8595c18d41
All checks were successful
CI / changes (push) Successful in 7s
CI / backend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 5s
CI / backend-tests (pull_request) Has been skipped
feat(sf): 6.6 in-app future-supply pressure + pipeline competitors (§13.3)
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.
2026-06-07 15:55:25 +05:00

449 lines
14 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";
/**
* 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}>
Месяцев давления сколько месяцев конкурирующего предложения (скрытый
запас плюс будущий слой) стоит в очереди при текущем темпе поглощения.
Индекс давления 01: выше больше грядущего предложения относительно
скорости рынка.
</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}>
Релевантность 01 взвешенный сигнал близости, класса, цены и стадии
к горизонту (§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>
);
}