Compare commits
1 commit
main
...
feat/forec
| Author | SHA1 | Date | |
|---|---|---|---|
| ed72e0ba1d |
5 changed files with 642 additions and 11 deletions
|
|
@ -261,6 +261,7 @@ const NAV_ITEMS = [
|
||||||
{ id: "section-6-1", label: "6.1 Прогноз по горизонтам" },
|
{ id: "section-6-1", label: "6.1 Прогноз по горизонтам" },
|
||||||
{ id: "section-6-2", label: "6.2 Сценарии" },
|
{ id: "section-6-2", label: "6.2 Сценарии" },
|
||||||
{ id: "section-6-3", label: "6.3 Уверенность" },
|
{ id: "section-6-3", label: "6.3 Уверенность" },
|
||||||
|
{ id: "section-6-4", label: "6.4 Рекомендация по продукту" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,521 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 6.4 Рекомендация по продукту — §13.4 product_tz (#953/#983).
|
||||||
|
*
|
||||||
|
* The literal answer to «что строить на участке»: рекомендованный класс (pill) +
|
||||||
|
* квартирография (горизонтальные bar'ы по дефициту форматов) + коммерческий
|
||||||
|
* сигнал + USP-ниши + §16-обоснование (раскрываемое).
|
||||||
|
*
|
||||||
|
* Quartirografiya is rendered as pure-Tailwind/token width-% bars (mobile-first,
|
||||||
|
* NOT a pie): bar length = |deficit_index| (or `pct` when an explicit mix carries
|
||||||
|
* shares), colour = deficit semantics (>0 недонасыщенность success, <0 затоварка
|
||||||
|
* danger, ≈0 баланс neutral) — same mapping as 6.1.
|
||||||
|
*
|
||||||
|
* GRACEFUL: returns null when product_tz is absent/empty; each sub-field is
|
||||||
|
* guarded individually so a thin/partial section never crashes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ProductCommercial,
|
||||||
|
ProductMixEntry,
|
||||||
|
ProductReason,
|
||||||
|
ProductUsp,
|
||||||
|
ReportProductTz,
|
||||||
|
} from "@/types/forecast";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import type { BadgeVariant } from "@/components/ui/Badge";
|
||||||
|
import {
|
||||||
|
DEFICIT_BALANCE_EPS,
|
||||||
|
deficitBarWidthPct,
|
||||||
|
deficitVariant,
|
||||||
|
deficitWord,
|
||||||
|
fmtNum,
|
||||||
|
} from "./forecast-helpers";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** §13.4 section; the whole block is skipped when absent (see Section6Forecast). */
|
||||||
|
productTz: ReportProductTz;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deficit BadgeVariant → bar-fill token. Semantic colours encode deficit sign
|
||||||
|
// (success/danger/neutral) — this is the documented viz exception (bar = signal).
|
||||||
|
const BAR_FILL: Record<BadgeVariant, string> = {
|
||||||
|
success: "var(--success)",
|
||||||
|
danger: "var(--danger)",
|
||||||
|
warning: "var(--warn)",
|
||||||
|
info: "var(--accent)",
|
||||||
|
neutral: "var(--border-strong)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUBHEAD_STYLE: React.CSSProperties = {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
};
|
||||||
|
|
||||||
|
function isNonEmpty(productTz: ReportProductTz): boolean {
|
||||||
|
return (
|
||||||
|
productTz.obj_class != null ||
|
||||||
|
productTz.mix.length > 0 ||
|
||||||
|
productTz.usp.length > 0 ||
|
||||||
|
productTz.reasons.length > 0 ||
|
||||||
|
(productTz.commercial != null &&
|
||||||
|
(productTz.commercial.available != null ||
|
||||||
|
!!productTz.commercial.caveat)) ||
|
||||||
|
!!productTz.summary
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ForecastProductTzBlock({ productTz }: Props) {
|
||||||
|
// Graceful: nothing meaningful to show → render nothing.
|
||||||
|
if (!isNonEmpty(productTz)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
{/* Recommended class — prominent pill + summary line */}
|
||||||
|
<ClassHeadline
|
||||||
|
obj_class={productTz.obj_class}
|
||||||
|
summary={productTz.summary}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Квартирография — deficit/share bars */}
|
||||||
|
<MixBars mix={productTz.mix} />
|
||||||
|
|
||||||
|
{/* Коммерция */}
|
||||||
|
<CommercialSignal commercial={productTz.commercial} />
|
||||||
|
|
||||||
|
{/* USP-ниши */}
|
||||||
|
<UspList usp={productTz.usp} />
|
||||||
|
|
||||||
|
{/* §16 обоснование (раскрываемое) */}
|
||||||
|
<ReasonsDisclosure reasons={productTz.reasons} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recommended class ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ClassHeadline({
|
||||||
|
obj_class,
|
||||||
|
summary,
|
||||||
|
}: {
|
||||||
|
obj_class: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
}) {
|
||||||
|
if (obj_class == null && !summary) return null;
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{obj_class != null && (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span style={SUBHEAD_STYLE}>Рекомендованный класс</span>
|
||||||
|
<Badge variant="info" size="md">
|
||||||
|
{obj_class}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{summary && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Квартирография (mix bars) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function mixLabel(m: ProductMixEntry): string {
|
||||||
|
const bucket = m.bucket ?? "формат";
|
||||||
|
return m.obj_class ? `${bucket} · ${m.obj_class}` : bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MixBars({ mix }: { mix: ProductMixEntry[] }) {
|
||||||
|
// Keep only entries with something to plot (a share or a deficit signal).
|
||||||
|
const rows = mix.filter((m) => m.pct != null || m.deficit_index != null);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
// Prefer explicit shares (доли) when the mix carries them; else fall back to
|
||||||
|
// the deficit signal as the bar driver (current overlay shape).
|
||||||
|
const hasShares = rows.some((m) => m.pct != null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
<span style={SUBHEAD_STYLE}>
|
||||||
|
Квартирография{hasShares ? "" : " · по дефициту форматов"}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
{rows.map((m, i) => (
|
||||||
|
<MixBar
|
||||||
|
key={`${m.bucket ?? "?"}-${m.obj_class ?? "?"}-${i}`}
|
||||||
|
entry={m}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!hasShares && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Длина полосы — сила сигнала по индексу дефицита: положительный
|
||||||
|
(зелёный) — недонасыщенность, отрицательный (красный) — затоварка.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MixBar({ entry }: { entry: ProductMixEntry }) {
|
||||||
|
const di = entry.deficit_index;
|
||||||
|
// Width: explicit share if present, else deficit magnitude.
|
||||||
|
const widthPct =
|
||||||
|
entry.pct != null
|
||||||
|
? Math.max(0, Math.min(100, entry.pct))
|
||||||
|
: di != null
|
||||||
|
? deficitBarWidthPct(di)
|
||||||
|
: 0;
|
||||||
|
const variant: BadgeVariant = di != null ? deficitVariant(di) : "neutral";
|
||||||
|
const balanced = di != null && Math.abs(di) < DEFICIT_BALANCE_EPS;
|
||||||
|
|
||||||
|
// Right-hand value: share % takes priority, else the signed deficit index.
|
||||||
|
const valueStr =
|
||||||
|
entry.pct != null
|
||||||
|
? `${fmtNum(entry.pct, entry.pct % 1 === 0 ? 0 : 1)}%`
|
||||||
|
: di != null
|
||||||
|
? `${di > 0 ? "+" : ""}${fmtNum(di, 2)}`
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
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)",
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mixLabel(entry)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{valueStr}
|
||||||
|
{di != null && entry.pct == null && (
|
||||||
|
<span style={{ marginLeft: 6, color: "var(--fg-tertiary)" }}>
|
||||||
|
{balanced ? "баланс" : deficitWord(di)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</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={`${mixLabel(entry)}: ${valueStr}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
insetBlock: 0,
|
||||||
|
insetInlineStart: 0,
|
||||||
|
width: `${widthPct}%`,
|
||||||
|
background: BAR_FILL[variant],
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Коммерция ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CommercialSignal({
|
||||||
|
commercial,
|
||||||
|
}: {
|
||||||
|
commercial: ProductCommercial | null;
|
||||||
|
}) {
|
||||||
|
if (
|
||||||
|
commercial == null ||
|
||||||
|
(commercial.available == null && !commercial.caveat)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const available = commercial.available === true;
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span style={SUBHEAD_STYLE}>Коммерция</span>
|
||||||
|
{commercial.available != null && (
|
||||||
|
<Badge variant={available ? "success" : "neutral"} size="sm">
|
||||||
|
{available ? "есть сигнал" : "нет данных"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{commercial.caveat && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{capitalize(commercial.caveat)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── USP-ниши ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function UspList({ usp }: { usp: ProductUsp[] }) {
|
||||||
|
const items = usp.filter((u) => !!u.usp_text || !!u.segment);
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
<span style={SUBHEAD_STYLE}>УТП · ниши по дефициту ({items.length})</span>
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
listStyle: "none",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((u, i) => (
|
||||||
|
<li
|
||||||
|
key={`${u.segment ?? "?"}-${u.obj_class ?? "?"}-${i}`}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: 8,
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: "var(--bg-card-alt)",
|
||||||
|
border: "1px solid var(--border-soft)",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
marginTop: 7,
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--accent)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{u.usp_text ?? uspFallbackText(u)}
|
||||||
|
</div>
|
||||||
|
{u.deficit_index != null && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
marginTop: 2,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Индекс дефицита {u.deficit_index > 0 ? "+" : ""}
|
||||||
|
{fmtNum(u.deficit_index, 2)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uspFallbackText(u: ProductUsp): string {
|
||||||
|
const seg = u.segment ?? "формат";
|
||||||
|
return u.obj_class ? `Ниша: ${seg} (${u.obj_class}).` : `Ниша: ${seg}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── §16 обоснование (раскрываемое) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ReasonsDisclosure({ reasons }: { reasons: ProductReason[] }) {
|
||||||
|
const items = reasons.filter(
|
||||||
|
(r) =>
|
||||||
|
!!r.why ||
|
||||||
|
(r.rejected?.length ?? 0) > 0 ||
|
||||||
|
(r.what_would_change?.length ?? 0) > 0,
|
||||||
|
);
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--border-soft)",
|
||||||
|
paddingTop: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<summary
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
listStyle: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Обоснование рекомендации
|
||||||
|
</summary>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((r, i) => (
|
||||||
|
<ReasonCard key={i} reason={r} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReasonCard({ reason }: { reason: ProductReason }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{reason.why && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: "var(--fg-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{reason.why}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reason.rejected && reason.rejected.length > 0 && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
<span style={SUBHEAD_STYLE}>Отвергнутые альтернативы</span>
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||||
|
{reason.rejected.map((rej, i) => (
|
||||||
|
<span
|
||||||
|
key={`${rej.alternative}-${i}`}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
background: "var(--bg-card-alt)",
|
||||||
|
border: "1px solid var(--border-soft)",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "3px 8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--fg-primary)" }}>
|
||||||
|
{rej.alternative}
|
||||||
|
</span>
|
||||||
|
{rej.deficit_index != null && (
|
||||||
|
<span style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{rej.deficit_index > 0 ? "+" : ""}
|
||||||
|
{fmtNum(rej.deficit_index, 2)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ color: "var(--fg-tertiary)" }}>
|
||||||
|
· {rej.reason}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reason.what_would_change && reason.what_would_change.length > 0 && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
<span style={SUBHEAD_STYLE}>Что изменит вывод</span>
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
paddingInlineStart: 18,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{reason.what_would_change.map((w, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{w}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function capitalize(text: string): string {
|
||||||
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||||
|
}
|
||||||
|
|
@ -11,9 +11,10 @@
|
||||||
*
|
*
|
||||||
* Layout mirrors Section1-5: dark HeadlineBar + section root id="section-6".
|
* Layout mirrors Section1-5: dark HeadlineBar + section root id="section-6".
|
||||||
* exec_summary banner (headline + verdict + KPI row)
|
* exec_summary banner (headline + verdict + KPI row)
|
||||||
* 6.1 Прогноз по горизонтам → ForecastHorizonsBlock
|
* 6.1 Прогноз по горизонтам → ForecastHorizonsBlock
|
||||||
* 6.2 Сценарии → ScenariosBlock
|
* 6.2 Сценарии → ScenariosBlock
|
||||||
* 6.3 Уверенность → ForecastConfidenceBlock
|
* 6.3 Уверенность → ForecastConfidenceBlock
|
||||||
|
* 6.4 Рекомендация по продукту → ForecastProductTzBlock (что строить — §13.4)
|
||||||
* advisory disclaimer (footer)
|
* advisory disclaimer (footer)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -28,6 +29,7 @@ import { ForecastChart } from "./ForecastChart";
|
||||||
import { ForecastHorizonsBlock } from "./ForecastHorizonsBlock";
|
import { ForecastHorizonsBlock } from "./ForecastHorizonsBlock";
|
||||||
import { ScenariosBlock } from "./ScenariosBlock";
|
import { ScenariosBlock } from "./ScenariosBlock";
|
||||||
import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock";
|
import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock";
|
||||||
|
import { ForecastProductTzBlock } from "./ForecastProductTzBlock";
|
||||||
import { ForecastExportButtons } from "./ForecastExportButtons";
|
import { ForecastExportButtons } from "./ForecastExportButtons";
|
||||||
import { CONFIDENCE_RU, deficitVariant, fmtNum } from "./forecast-helpers";
|
import { CONFIDENCE_RU, deficitVariant, fmtNum } from "./forecast-helpers";
|
||||||
|
|
||||||
|
|
@ -187,17 +189,27 @@ function ForecastReady({
|
||||||
report.confidence.level != null ||
|
report.confidence.level != null ||
|
||||||
Object.keys(report.confidence.factors).length > 0;
|
Object.keys(report.confidence.factors).length > 0;
|
||||||
|
|
||||||
const hasAny = hasHorizons || hasScenarios || hasConfidence;
|
// 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)));
|
||||||
|
|
||||||
|
const hasAny = hasHorizons || hasScenarios || hasConfidence || hasProductTz;
|
||||||
|
|
||||||
// Headline subtitle = future_market summary (one sentence «откуда / что значит»).
|
// Headline subtitle = future_market summary (one sentence «откуда / что значит»).
|
||||||
const subtitle = fm.summary ?? undefined;
|
const subtitle = fm.summary ?? undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}>
|
<section id="section-6" style={{ scrollMarginTop: SCROLL_MARGIN }}>
|
||||||
<HeadlineBar
|
<HeadlineBar title={exec.headline ?? "6. Прогноз"} subtitle={subtitle} />
|
||||||
title={exec.headline ?? "6. Прогноз"}
|
|
||||||
subtitle={subtitle}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -255,9 +267,7 @@ function ForecastReady({
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Итоговый скор"
|
label="Итоговый скор"
|
||||||
value={
|
value={kn.overall_score != null ? fmtNum(kn.overall_score, 2) : "—"}
|
||||||
kn.overall_score != null ? fmtNum(kn.overall_score, 2) : "—"
|
|
||||||
}
|
|
||||||
color="blue"
|
color="blue"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
|
|
@ -323,6 +333,13 @@ function ForecastReady({
|
||||||
</SubBlock>
|
</SubBlock>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 6.4 Рекомендация по продукту — что строить на участке (§13.4) */}
|
||||||
|
{hasProductTz && pt && (
|
||||||
|
<SubBlock id="section-6-4" title="6.4 Рекомендация по продукту">
|
||||||
|
<ForecastProductTzBlock productTz={pt} />
|
||||||
|
</SubBlock>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Advisory disclaimer */}
|
{/* Advisory disclaimer */}
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,16 @@ export function deficitWord(deficitIndex: number): string {
|
||||||
return "баланс";
|
return "баланс";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bar width (%) for a signed deficit_index (∈ −1..1) on a 0..100 scale. Uses the
|
||||||
|
* magnitude so both недонасыщенность (+) and затоварка (−) read as a filled bar
|
||||||
|
* (colour carries the sign via `deficitVariant`). Clamped to [0, 100].
|
||||||
|
*/
|
||||||
|
export function deficitBarWidthPct(deficitIndex: number): number {
|
||||||
|
const pct = Math.abs(deficitIndex) * 100;
|
||||||
|
return Math.max(0, Math.min(100, pct));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Confidence ────────────────────────────────────────────────────────────────
|
// ── Confidence ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const CONFIDENCE_RU: Record<ConfidenceLevel, string> = {
|
export const CONFIDENCE_RU: Record<ConfidenceLevel, string> = {
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,83 @@ export interface ReportConfidence {
|
||||||
factors: Record<string, ConfidenceFactor>;
|
factors: Record<string, ConfidenceFactor>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Product TZ (§13.4 — рекомендация продукта, #953/#983) ────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §16 structured reason — shared by `reasons[]` and the `reason` nested on each
|
||||||
|
* USP / mix entry. All fields optional: the assembler lifts class_reco.reason or
|
||||||
|
* per-segment reasons, and a thin overlay may carry only `why`.
|
||||||
|
*/
|
||||||
|
export interface ProductReasonDriver {
|
||||||
|
factor: string;
|
||||||
|
value: number | null;
|
||||||
|
/** "+" сигнал вверх / "−" давление вниз (типографский минус). */
|
||||||
|
direction: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductReasonRejected {
|
||||||
|
alternative: string;
|
||||||
|
deficit_index: number | null;
|
||||||
|
/** «затоварка» | «слабее сигнал». */
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductReason {
|
||||||
|
why?: string | null;
|
||||||
|
drivers?: ProductReasonDriver[];
|
||||||
|
rejected?: ProductReasonRejected[];
|
||||||
|
what_would_change?: string[];
|
||||||
|
confidence?: ConfidenceLevel | null;
|
||||||
|
advisory?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Квартирография entry. The assembler emits {bucket, obj_class, deficit_index}
|
||||||
|
* from ranked_segments; `pct` (доля формата) is consumed by the export renderers
|
||||||
|
* but absent from current runs — kept optional so a future explicit `mix` with
|
||||||
|
* shares renders too. `deficit_index` semantics mirror the rest of §22:
|
||||||
|
* >0 недонасыщенность, <0 затоварка.
|
||||||
|
*/
|
||||||
|
export interface ProductMixEntry {
|
||||||
|
bucket: string | null;
|
||||||
|
obj_class: string | null;
|
||||||
|
deficit_index: number | null;
|
||||||
|
/** Доля формата, % (0..100) — присутствует только в явном mix. */
|
||||||
|
pct?: number | null;
|
||||||
|
confidence?: ConfidenceLevel | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** §10.4 коммерческий сигнал — обычно «нет данных» (available=false + caveat). */
|
||||||
|
export interface ProductCommercial {
|
||||||
|
available?: boolean;
|
||||||
|
caveat?: string | null;
|
||||||
|
advisory?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** §10.5 USP-из-дефицитов — top-K недообеспеченных форматов. */
|
||||||
|
export interface ProductUsp {
|
||||||
|
segment: string | null;
|
||||||
|
obj_class: string | null;
|
||||||
|
deficit_index: number | null;
|
||||||
|
usp_text: string | null;
|
||||||
|
reason?: ProductReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportProductTz {
|
||||||
|
/** Рекомендованный класс (§10.2). */
|
||||||
|
obj_class: string | null;
|
||||||
|
/** Квартирография — доли/дефицит форматов. */
|
||||||
|
mix: ProductMixEntry[];
|
||||||
|
/** Коммерческий сигнал (§10.4). */
|
||||||
|
commercial: ProductCommercial | null;
|
||||||
|
/** USP-ниши из дефицитов (§10.5). */
|
||||||
|
usp: ProductUsp[];
|
||||||
|
/** §16-причины (отвергнутые альтернативы / what-would-change). */
|
||||||
|
reasons: ProductReason[];
|
||||||
|
/** Короткий RU-текст про рекомендованный продукт. */
|
||||||
|
summary: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Meta ─────────────────────────────────────────────────────────────────────
|
// ── Meta ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ReportMeta {
|
export interface ReportMeta {
|
||||||
|
|
@ -168,6 +245,11 @@ export interface ForecastReport {
|
||||||
future_market: ReportFutureMarket;
|
future_market: ReportFutureMarket;
|
||||||
scenarios: ReportScenarios;
|
scenarios: ReportScenarios;
|
||||||
confidence: ReportConfidence;
|
confidence: ReportConfidence;
|
||||||
|
/**
|
||||||
|
* §13.4 рекомендация продукта (#953). Optional: the report may be 202-pending
|
||||||
|
* or a thin assembly may omit the section entirely — guarded at render time.
|
||||||
|
*/
|
||||||
|
product_tz?: ReportProductTz;
|
||||||
schema_version?: string;
|
schema_version?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue