Каждая секция показывает по умолчанию только основную информацию (вердикт-плашка + KPI-ряд), остальное (карты, таблицы, графики, разбивки) — под единым тумблером «Раскрыть всё ▾». Выполнение запроса: «на каждом этапе только основную информацию, далее жмёшь раскрыть и показывает всё что у нас есть». - StageDetails — примитив-тумблер: default-collapsed, conditional-mount детей (НЕ display:none — чтобы Leaflet-карты инициализировались при раскрытии), aria-expanded, токены, Unicode-каретка. - §1 Объект: свёрнуты 2-кол грид (карта+ЕГРН/НСПД/POI) + экспорт. - §2 Оценка: свёрнуты risk-cards + ЗОУИТ + рекомендация. - §3 Сети: свёрнуты предупреждение ЛЭП + счётчики + таблица + карта + caveat. - §4 Рынок: свёрнуты настройки+таблица+блоки; CompetitorDetailDrawer оставлен смонтированным вне тумблера. - §5 Атмосфера: свёрнут сезонный климат. - §6 Прогноз: свёрнуты график + 6.1–6.6 + футер. - §7 Концепция — не тронут (уже сам по себе progressive: форма → авто-результат).
672 lines
25 KiB
TypeScript
672 lines
25 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* Section6Forecast — "6. Прогноз" (958-B3 / §22).
|
||
*
|
||
* Renders the async §22 demand/supply forecast, scenarios and confidence on the
|
||
* Site Finder analysis screen. Self-fetches via useParcelForecastQuery, which
|
||
* POLLs GET /api/v1/parcels/{cad}/forecast every 4s until status === "ready"
|
||
* (the forecast is enqueued by useParcelAnalyzeQuery on page mount — render-only
|
||
* here, no trigger).
|
||
*
|
||
* Layout mirrors Section1-5: dark HeadlineBar + section root id="section-6".
|
||
* exec_summary banner (headline + verdict + KPI row)
|
||
* 6.1 Прогноз по горизонтам → ForecastHorizonsBlock
|
||
* 6.2 Сценарии → ScenariosBlock
|
||
* 6.3 Уверенность → ForecastConfidenceBlock
|
||
* 6.4 Рекомендация по продукту → ForecastProductTzBlock (что строить — §13.4)
|
||
* 6.5 Прозрачность скоринга → ForecastScoringBlock (почему скор — §13.6)
|
||
* 6.6 Будущее предложение и конкуренты → ForecastFutureSupplyBlock
|
||
* (§16-evidence за прогнозом предложения — §9.3 давление + §9.7 конкуренты)
|
||
* advisory disclaimer (footer)
|
||
*/
|
||
|
||
import { AlertTriangle, LineChart } from "lucide-react";
|
||
|
||
import { HeadlineBar } from "@/components/ui/HeadlineBar";
|
||
import { KpiCard } from "@/components/site-finder/KpiCard";
|
||
import { useParcelForecastQuery } from "@/lib/site-finder-api";
|
||
import type { ForecastReport } from "@/types/forecast";
|
||
|
||
import { ForecastChart } from "./ForecastChart";
|
||
import { ForecastHorizonsBlock } from "./ForecastHorizonsBlock";
|
||
import { ScenariosBlock } from "./ScenariosBlock";
|
||
import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock";
|
||
import { ForecastProductTzBlock } from "./ForecastProductTzBlock";
|
||
import { ForecastAssortmentBlock } from "./ForecastAssortmentBlock";
|
||
import { ForecastScoringBlock } from "./ForecastScoringBlock";
|
||
import { ForecastFutureSupplyBlock } from "./ForecastFutureSupplyBlock";
|
||
import { ForecastMetaLine } from "./ForecastMetaLine";
|
||
import { ForecastExportButtons } from "./ForecastExportButtons";
|
||
import { StageDetails } from "./StageDetails";
|
||
import {
|
||
CONFIDENCE_RU,
|
||
deficitPegState,
|
||
deficitVariant,
|
||
fmtNum,
|
||
hasSupplySignal,
|
||
} from "./forecast-helpers";
|
||
|
||
interface Props {
|
||
cad: string;
|
||
/**
|
||
* Horizon chosen in the analysis-screen HorizonSelector (6 / 12 / 18 / 24). When
|
||
* set it takes priority over the report-derived horizon so the user's choice
|
||
* instantly drives which horizon is treated as the target (client-side), even
|
||
* before the recomputed forecast finishes (see AnalysisPageContent comment).
|
||
*/
|
||
selectedHorizon?: number;
|
||
}
|
||
|
||
const ADVISORY_DISCLAIMER =
|
||
"Оценка advisory — не основание для инвест-решения.";
|
||
|
||
const SCROLL_MARGIN = 72;
|
||
|
||
// KpiCard color enum (site-finder variant) mapped from Badge variant words.
|
||
function kpiColorForDeficit(deficitIndex: number): "green" | "red" | "neutral" {
|
||
const v = deficitVariant(deficitIndex);
|
||
if (v === "success") return "green";
|
||
if (v === "danger") return "red";
|
||
return "neutral";
|
||
}
|
||
|
||
// ── Section6Forecast ─────────────────────────────────────────────────────────
|
||
|
||
export function Section6Forecast({ cad, selectedHorizon }: Props) {
|
||
const { data, isLoading, error } = useParcelForecastQuery(cad);
|
||
|
||
const isPending =
|
||
isLoading || (data != null && data.status !== "ready") || data == null;
|
||
|
||
// ── Pending: forecast still computing (poll in flight) ─────────────────────
|
||
if (isPending && !error) {
|
||
return (
|
||
<section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}>
|
||
<HeadlineBar
|
||
title="6. Прогноз"
|
||
subtitle="Прогноз спроса, предложения и сценарии (§22)."
|
||
/>
|
||
<div
|
||
style={{
|
||
marginTop: 12,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 12,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
borderRadius: 12,
|
||
background: "var(--bg-card-alt)",
|
||
border: "1px solid var(--border-soft)",
|
||
padding: "24px 20px",
|
||
textAlign: "center",
|
||
color: "var(--fg-tertiary)",
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
Прогноз рассчитывается…
|
||
</div>
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||
gap: 12,
|
||
}}
|
||
>
|
||
{[0, 1, 2].map((i) => (
|
||
<div
|
||
key={i}
|
||
style={{
|
||
borderRadius: 12,
|
||
background: "var(--bg-card-alt)",
|
||
border: "1px solid var(--border-soft)",
|
||
height: 88,
|
||
}}
|
||
aria-hidden
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// ── Error ──────────────────────────────────────────────────────────────────
|
||
if (error || !data || data.status !== "ready" || !data.report) {
|
||
return (
|
||
<section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}>
|
||
<HeadlineBar title="6. Прогноз" />
|
||
<div
|
||
style={{
|
||
marginTop: 12,
|
||
borderRadius: 12,
|
||
padding: "32px 24px",
|
||
background: "var(--bg-card-alt)",
|
||
border: "1px dashed var(--border-strong)",
|
||
textAlign: "center",
|
||
color: "var(--fg-tertiary)",
|
||
fontSize: 13,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<LineChart size={20} aria-hidden />
|
||
Данные прогноза недоступны
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<ForecastReady
|
||
cad={cad}
|
||
report={data.report}
|
||
runCreatedAt={data.created_at}
|
||
selectedHorizon={selectedHorizon}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// ── ForecastReady (report present) ───────────────────────────────────────────
|
||
|
||
function ForecastReady({
|
||
cad,
|
||
report,
|
||
runCreatedAt,
|
||
selectedHorizon,
|
||
}: {
|
||
cad: string;
|
||
report: ForecastReport;
|
||
/** Envelope `created_at` — the run's persist time, the real «когда рассчитан». */
|
||
runCreatedAt?: string | null;
|
||
selectedHorizon?: number;
|
||
}) {
|
||
const exec = report.exec_summary;
|
||
const fm = report.future_market;
|
||
|
||
// Target horizon: the user's HorizonSelector choice wins (instant client-side
|
||
// highlight), else future_supply.horizon_months, else median of meta horizons,
|
||
// else 12.
|
||
const targetHorizon =
|
||
selectedHorizon ??
|
||
fm.future_supply?.horizon_months ??
|
||
medianHorizon(report.meta.horizons) ??
|
||
12;
|
||
|
||
const kn = exec.key_numbers;
|
||
|
||
const hasHorizons = fm.forecasts_by_horizon.length > 0;
|
||
const hasScenarios =
|
||
Object.keys(report.scenarios.by_scenario).length > 0 ||
|
||
fm.scenarios_summary != null;
|
||
const hasConfidence =
|
||
report.confidence.level != null ||
|
||
Object.keys(report.confidence.factors).length > 0;
|
||
|
||
// 6.4 — рекомендация продукта (§13.4). product_tz is optional on the partial
|
||
// ForecastReport type; treat absent/thin section as "nothing to show".
|
||
const pt = report.product_tz;
|
||
const hasProductTz =
|
||
pt != null &&
|
||
(pt.obj_class != null ||
|
||
pt.mix.length > 0 ||
|
||
pt.usp.length > 0 ||
|
||
pt.reasons.length > 0 ||
|
||
!!pt.summary ||
|
||
(pt.commercial != null &&
|
||
(pt.commercial.available != null || !!pt.commercial.caveat)));
|
||
|
||
// 6.4a — прогноз по всем форматам (#1745). Показываем, когда квартирография
|
||
// несёт хотя бы одну форматную ячейку (с bucket) рекомендованного класса —
|
||
// иначе таблица пуста (объект класс не задан → деградируем к любым ячейкам).
|
||
const hasAssortment =
|
||
pt != null &&
|
||
pt.mix.some(
|
||
(m) =>
|
||
m.bucket != null &&
|
||
(pt.obj_class == null || m.obj_class === pt.obj_class),
|
||
);
|
||
|
||
// 6.5 — прозрачность скоринга (§13.6). scoring is optional on the partial
|
||
// ForecastReport type; populated when product_scores / special_indices exist.
|
||
const sc = report.scoring;
|
||
const hasScoring =
|
||
sc != null &&
|
||
((sc.product_scores != null &&
|
||
Object.keys(sc.product_scores.scores).length > 0) ||
|
||
(sc.special_indices != null &&
|
||
Object.keys(sc.special_indices.indices).length > 0));
|
||
|
||
// 6.6 — будущее предложение и конкуренты (§13.3 evidence). future_supply is
|
||
// shown only when it carries a genuinely-computed pressure signal (see
|
||
// hasSupplySignal — gates on computed metrics, NOT open/hidden stock which are
|
||
// 0-not-null when supply_layers is unloaded); future_competitors when the list
|
||
// is non-empty. The per-horizon 6.1 table stays the aggregate; this is the detail.
|
||
const hasFutureSupplyDetail =
|
||
hasSupplySignal(fm.future_supply) || fm.future_competitors.length > 0;
|
||
|
||
const hasAny =
|
||
hasHorizons ||
|
||
hasScenarios ||
|
||
hasConfidence ||
|
||
hasProductTz ||
|
||
hasAssortment ||
|
||
hasScoring ||
|
||
hasFutureSupplyDetail;
|
||
|
||
// B1 (#1958/#1959) — pegged-signal honesty-guard. deficit_index упирается в край
|
||
// шкалы (|value| ≥ 0.99) на ВСЕХ горизонтах того же знака → «−1.00 ×4» не
|
||
// различает горизонты. Считаем ОДИН раз тут (источник — общий ряд горизонтов) и
|
||
// прокидываем в 6.1 (таблица) и 6.2 (сценарии), чтобы плашки не разъезжались.
|
||
const peg = deficitPegState(fm.forecasts_by_horizon);
|
||
|
||
// B2 — gate-caveat участка (см. блокеры: «Нельзя строить МКД»). Бэкенд проставляет
|
||
// product_tz.gate_caveat, когда жильё под запретом → весь §6-прогноз справочный.
|
||
// Поднимаем его на уровень ВСЕЙ секции (был бы закопан в 6.4) — заметная плашка.
|
||
const gateCaveat = pt?.gate_caveat?.trim() || null;
|
||
|
||
// Headline subtitle = future_market summary (one sentence «откуда / что значит»).
|
||
const subtitle = fm.summary ?? undefined;
|
||
|
||
return (
|
||
<section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}>
|
||
<HeadlineBar title={exec.headline ?? "6. Прогноз"} subtitle={subtitle} />
|
||
|
||
<div
|
||
style={{
|
||
marginTop: 12,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 24,
|
||
}}
|
||
>
|
||
{/* B2 — участок не под жильё: прогноз спроса/предложения справочный.
|
||
Заметная плашка В ВЕРХУ всего §6 (выше KPI), а не закопанная в 6.4. */}
|
||
{gateCaveat && <NonResidentialCaveat caveat={gateCaveat} />}
|
||
|
||
{/* Export / share forecast report (EPIC #959) */}
|
||
<ForecastExportButtons cad={cad} />
|
||
|
||
{/* exec_summary verdict + KPI row */}
|
||
{exec.verdict && (
|
||
<p
|
||
style={{
|
||
margin: 0,
|
||
fontSize: 14,
|
||
lineHeight: 1.5,
|
||
color: "var(--fg-primary)",
|
||
}}
|
||
>
|
||
{exec.verdict}
|
||
</p>
|
||
)}
|
||
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||
gap: 12,
|
||
}}
|
||
>
|
||
<KpiCard
|
||
label="Индекс дефицита"
|
||
value={
|
||
kn.deficit_index != null
|
||
? `${kn.deficit_index > 0 ? "+" : ""}${fmtNum(kn.deficit_index, 2)}`
|
||
: "—"
|
||
}
|
||
color={
|
||
kn.deficit_index != null
|
||
? kpiColorForDeficit(kn.deficit_index)
|
||
: "neutral"
|
||
}
|
||
caption="шкала от −1 до +1"
|
||
/>
|
||
<KpiCard
|
||
label="Мес. предложения (MOI)"
|
||
value={
|
||
kn.months_of_inventory != null
|
||
? fmtNum(kn.months_of_inventory, 1)
|
||
: "—"
|
||
}
|
||
color="neutral"
|
||
caption="за сколько месяцев район распродаст текущее предложение"
|
||
/>
|
||
<KpiCard
|
||
label="Итоговый скор"
|
||
value={kn.overall_score != null ? fmtNum(kn.overall_score, 2) : "—"}
|
||
color="blue"
|
||
/>
|
||
<KpiCard
|
||
label="Уверенность"
|
||
value={
|
||
(exec.overall_confidence ?? kn.confidence) != null
|
||
? CONFIDENCE_RU[
|
||
(exec.overall_confidence ?? kn.confidence) as
|
||
"high" | "medium" | "low"
|
||
]
|
||
: "—"
|
||
}
|
||
color={confidenceKpiColor(exec.overall_confidence ?? kn.confidence)}
|
||
/>
|
||
</div>
|
||
|
||
{/* Легенда индекса дефицита (#1963) — что значит −1 / 0 / +1 + трактовка. */}
|
||
<DeficitLegend />
|
||
|
||
{!hasAny && (
|
||
<div
|
||
style={{
|
||
borderRadius: 12,
|
||
padding: "24px 20px",
|
||
background: "var(--bg-card-alt)",
|
||
border: "1px dashed var(--border-strong)",
|
||
textAlign: "center",
|
||
color: "var(--fg-tertiary)",
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
Данные прогноза недоступны
|
||
</div>
|
||
)}
|
||
|
||
<StageDetails>
|
||
{/* Траектория индекса дефицита (chart-then-table, выше 6.1). Причину
|
||
схлопывания сценариев показываем в 6.2 под заголовком «Почему один
|
||
сценарий, а не три» (#1963) — здесь дубль убран. */}
|
||
<ForecastChart report={report} selectedHorizon={selectedHorizon} />
|
||
|
||
{/* 6.1 Прогноз по горизонтам */}
|
||
{hasHorizons && (
|
||
<SubBlock id="section-6-1" title="6.1 Прогноз по горизонтам">
|
||
<ForecastHorizonsBlock
|
||
forecasts={fm.forecasts_by_horizon}
|
||
targetHorizon={targetHorizon}
|
||
peg={peg}
|
||
/>
|
||
</SubBlock>
|
||
)}
|
||
|
||
{/* 6.2 Сценарии */}
|
||
{hasScenarios && (
|
||
<SubBlock id="section-6-2" title="6.2 Сценарии">
|
||
<ScenariosBlock
|
||
scenarios={report.scenarios}
|
||
scenariosSummary={fm.scenarios_summary}
|
||
targetHorizon={targetHorizon}
|
||
peg={peg}
|
||
/>
|
||
</SubBlock>
|
||
)}
|
||
|
||
{/* 6.3 Уверенность */}
|
||
{hasConfidence && (
|
||
<SubBlock id="section-6-3" title="6.3 Уверенность">
|
||
<ForecastConfidenceBlock confidence={report.confidence} />
|
||
</SubBlock>
|
||
)}
|
||
|
||
{/* 6.4 Рекомендация по продукту — что строить на участке (§13.4) */}
|
||
{hasProductTz && pt && (
|
||
<SubBlock id="section-6-4" title="6.4 Рекомендация по продукту">
|
||
<ForecastProductTzBlock productTz={pt} />
|
||
</SubBlock>
|
||
)}
|
||
|
||
{/* 6.4a Прогноз по всем форматам — таблица ассортимента (#1745, фаза 1).
|
||
Под рекомендацией: весь срез дефицита по форматам рекомендованного
|
||
класса. Показываем только когда есть форматные ячейки квартирографии. */}
|
||
{hasAssortment && pt && (
|
||
<SubBlock id="section-6-4a" title="6.4 Прогноз по всем форматам">
|
||
<ForecastAssortmentBlock objClass={pt.obj_class} mix={pt.mix} />
|
||
</SubBlock>
|
||
)}
|
||
|
||
{/* 6.5 Прозрачность скоринга — почему итоговый скор такой (§13.6) */}
|
||
{hasScoring && sc && (
|
||
<SubBlock id="section-6-5" title="6.5 Прозрачность скоринга">
|
||
<ForecastScoringBlock scoring={sc} />
|
||
</SubBlock>
|
||
)}
|
||
|
||
{/* 6.6 Будущее предложение и конкуренты — §16-evidence за прогнозом (§13.3) */}
|
||
{hasFutureSupplyDetail && (
|
||
<SubBlock
|
||
id="section-6-6"
|
||
title="6.6 Будущее предложение и конкуренты"
|
||
>
|
||
<ForecastFutureSupplyBlock
|
||
futureSupply={fm.future_supply}
|
||
futureCompetitors={fm.future_competitors}
|
||
/>
|
||
</SubBlock>
|
||
)}
|
||
|
||
{/* Freshness / traceability: когда рассчитан + горизонты + версия схемы */}
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 6,
|
||
paddingTop: 12,
|
||
borderTop: "1px solid var(--border-soft)",
|
||
}}
|
||
>
|
||
<ForecastMetaLine meta={report.meta} runCreatedAt={runCreatedAt} />
|
||
|
||
{/* Advisory disclaimer (всегда последним) */}
|
||
<p
|
||
style={{
|
||
margin: 0,
|
||
fontSize: 12,
|
||
lineHeight: 1.5,
|
||
color: "var(--fg-tertiary)",
|
||
}}
|
||
>
|
||
{ADVISORY_DISCLAIMER}
|
||
</p>
|
||
</div>
|
||
</StageDetails>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// ── NonResidentialCaveat — участок не под жильё (B2) ──────────────────────────
|
||
//
|
||
// Заметная warn-плашка В ВЕРХУ §6: бэкенд проставил product_tz.gate_caveat, когда
|
||
// gate=«Нельзя строить МКД». Весь прогноз спроса/предложения тогда — справочный
|
||
// (считается по сегменту рынка, но строить жильё на участке нельзя). Паттерн
|
||
// --warn-soft / role="note" как у СЗЗ-баннера / VelocityBlock-caveat; иконка
|
||
// Lucide (НЕ emoji), токены с fallback. `caveat` — готовый RU-текст от бэкенда;
|
||
// под ним — наша микрокопия-следствие «прогноз носит справочный характер».
|
||
|
||
function NonResidentialCaveat({ caveat }: { caveat: string }) {
|
||
return (
|
||
<div
|
||
role="note"
|
||
style={{
|
||
display: "flex",
|
||
gap: 10,
|
||
alignItems: "flex-start",
|
||
padding: "12px 16px",
|
||
borderRadius: 12,
|
||
background: "var(--warn-soft, #FEF3C7)",
|
||
border: "1px solid var(--warn, #9A6700)",
|
||
}}
|
||
>
|
||
<AlertTriangle
|
||
size={18}
|
||
aria-hidden
|
||
style={{ flexShrink: 0, marginTop: 1, color: "var(--warn, #9A6700)" }}
|
||
/>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||
<span
|
||
style={{
|
||
fontSize: 13,
|
||
fontWeight: 600,
|
||
lineHeight: 1.5,
|
||
color: "var(--warn, #9A6700)",
|
||
}}
|
||
>
|
||
Участок не предназначен под жильё (см. блокеры) — прогноз спроса и
|
||
предложения носит справочный характер.
|
||
</span>
|
||
<span
|
||
style={{
|
||
fontSize: 12,
|
||
lineHeight: 1.5,
|
||
color: "var(--fg-secondary)",
|
||
}}
|
||
>
|
||
{caveat}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── DeficitLegend — что значит шкала −1…+1 + связь с MOI (#1963) ─────────────
|
||
//
|
||
// Плоская строка-легенда под KPI: финдиректор видит «−0.42» / «116.6 мес» без
|
||
// объяснения. Расшифровываем три точки шкалы простым языком + actionable-трактовку
|
||
// и привязываем MOI к индексу. Только токены, без хардкод-цвета.
|
||
|
||
function DeficitLegend() {
|
||
const items: { sign: string; color: string; text: string }[] = [
|
||
{
|
||
sign: "−1",
|
||
color: "var(--danger)",
|
||
text: "затоварка: предложения больше спроса — выходить осторожно, держать темп продаж",
|
||
},
|
||
{
|
||
sign: "0",
|
||
color: "var(--fg-secondary)",
|
||
text: "баланс: спрос и предложение сопоставимы",
|
||
},
|
||
{
|
||
sign: "+1",
|
||
color: "var(--success)",
|
||
text: "острый дефицит: спрос недозакрыт — окно для выхода, возможна премия к цене",
|
||
},
|
||
];
|
||
return (
|
||
<div
|
||
style={{
|
||
borderRadius: 12,
|
||
border: "1px solid var(--border-soft)",
|
||
background: "var(--bg-card-alt)",
|
||
padding: "12px 16px",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
fontSize: 11,
|
||
fontWeight: 500,
|
||
letterSpacing: "0.04em",
|
||
textTransform: "uppercase",
|
||
color: "var(--fg-secondary)",
|
||
}}
|
||
>
|
||
Как читать индекс дефицита (шкала −1…+1)
|
||
</span>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||
{items.map((it) => (
|
||
<div
|
||
key={it.sign}
|
||
style={{ display: "flex", alignItems: "baseline", gap: 8 }}
|
||
>
|
||
<span
|
||
style={{
|
||
fontSize: 13,
|
||
fontWeight: 600,
|
||
color: it.color,
|
||
minWidth: 24,
|
||
fontVariantNumeric: "tabular-nums",
|
||
}}
|
||
>
|
||
{it.sign}
|
||
</span>
|
||
<span
|
||
style={{
|
||
fontSize: 12,
|
||
lineHeight: 1.5,
|
||
color: "var(--fg-secondary)",
|
||
}}
|
||
>
|
||
{it.text}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<span
|
||
style={{ fontSize: 12, lineHeight: 1.5, color: "var(--fg-tertiary)" }}
|
||
>
|
||
«Мес. предложения» (MOI) — за сколько месяцев район распродаст текущее
|
||
предложение при нынешнем темпе продаж: чем больше месяцев, тем сильнее
|
||
затоварка и ниже индекс.
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── SubBlock wrapper (6.1 / 6.2 / 6.3) ───────────────────────────────────────
|
||
|
||
function SubBlock({
|
||
id,
|
||
title,
|
||
children,
|
||
}: {
|
||
id: string;
|
||
title: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div id={id} style={{ scrollMarginTop: SCROLL_MARGIN }}>
|
||
<h3
|
||
style={{
|
||
margin: "0 0 12px",
|
||
fontSize: 14,
|
||
fontWeight: 600,
|
||
color: "var(--fg-primary)",
|
||
}}
|
||
>
|
||
{title}
|
||
</h3>
|
||
<div
|
||
style={{
|
||
borderRadius: 12,
|
||
border: "1px solid var(--border-card)",
|
||
background: "var(--bg-card)",
|
||
padding: 20,
|
||
}}
|
||
>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function medianHorizon(horizons: number[]): number | null {
|
||
if (horizons.length === 0) return null;
|
||
const sorted = [...horizons].sort((a, b) => a - b);
|
||
return sorted[Math.floor(sorted.length / 2)];
|
||
}
|
||
|
||
function confidenceKpiColor(
|
||
level: "high" | "medium" | "low" | null,
|
||
): "green" | "amber" | "red" | "neutral" {
|
||
if (level === "high") return "green";
|
||
if (level === "medium") return "amber";
|
||
if (level === "low") return "red";
|
||
return "neutral";
|
||
}
|