gendesign/frontend/src/components/site-finder/analysis/ForecastProductTzBlock.tsx
Light1YT 8cd0620543
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / changes (push) Successful in 9s
CI / frontend-tests (push) Successful in 1m43s
CI / frontend-tests (pull_request) Successful in 1m59s
CI / openapi-codegen-check (push) Successful in 2m43s
CI / openapi-codegen-check (pull_request) Successful in 1m55s
CI / backend-tests (push) Successful in 9m28s
CI / backend-tests (pull_request) Successful in 9m28s
feat(site-finder): продуктовые решения по фидбеку analyze (#1741 #1742 #1743 #1744 #1745)
После ролевой дискуссии (аналитик×комдир×собственник):
- #1741 секции в 2 группы «Участок»→«Стройка и рынок» (Section1→4→2→3→5→6), gate-баннер вынесен НАД группой (убран дубль из Section4Estimate), nav-группы
- #1742 объяснение выбора класса: фраза-причина + мини-таблица 3 классов по deficit_index («+1 острый дефицит, −1 затоварка»). POI «обеспеченность»/market-overstock relabel — N/A (в UI не выводятся)
- #1743 «Остатки и скорость»→«Как продаётся рынок рядом» + headline-вердикт + «Темп продаж»/«Будущее предложение конкурентов (24 мес)» (убран англицизм Velocity-score/Pipeline) + интерпретированные единицы
- #1744 горизонт: caption+tooltip «пересчёт ~10-30с» + статус «Пересчитываем…»; колонки прогноза «…, квартир» + caveat advisory
- #1745 фаза1: таблица «Прогноз по всем форматам» (студия/1-5к × индекс дефицита/прогноз спроса/сигнал); backend пробросил per-format projected_demand_units + signal. Слайдеры/рубли — фаза2 после бэктеста #951

Verify: tsc 0 ошибок; py_compile; what_to_build/recommendation/report/exporter тесты pass.

Closes #1741
Closes #1742
Closes #1743
Closes #1744
Closes #1745
2026-06-18 12:18:04 +05:00

697 lines
22 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.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,
ProductReasonRejected,
ProductUsp,
ReportProductTz,
} from "@/types/forecast";
import { Badge } from "@/components/ui/Badge";
import type { BadgeVariant } from "@/components/ui/Badge";
import {
aggregateClassDeficits,
DEFICIT_BALANCE_EPS,
deficitBarWidthPct,
deficitVariant,
deficitWord,
fmtNum,
} from "./forecast-helpers";
import type { ClassDeficit } 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;
// #1742 — объяснение выбора класса: per-класс дефицит (агрегат mix по классам).
const classDeficits = aggregateClassDeficits(productTz.mix);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* Recommended class — prominent pill + автоген-фраза про дефицит + 3 класса */}
<ClassHeadline
obj_class={productTz.obj_class}
summary={productTz.summary}
classDeficits={classDeficits}
/>
{/* #1742 — отвергнутые альтернативы подняты из-под ката (выше обоснования) */}
<RejectedAlternatives reasons={productTz.reasons} />
{/* Квартирография — 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,
classDeficits,
}: {
obj_class: string | null;
summary: string | null;
classDeficits: ClassDeficit[];
}) {
if (obj_class == null && !summary) return null;
// #1742 — дефицит рекомендованного класса (для автоген-фразы). Ищем его агрегат
// в per-класс таблице; null → у класса нет измеримого индекса (тонкие данные).
const recoDeficit =
obj_class != null
? (classDeficits.find((c) => c.obj_class === obj_class)
?.meanDeficitIndex ?? null)
: 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">
{capitalize(obj_class)}
</Badge>
</div>
)}
{/* Автоген-фраза: почему именно этот класс (#1742) */}
{obj_class != null && recoDeficit != null && (
<p
style={{
margin: 0,
fontSize: 13,
lineHeight: 1.5,
color: "var(--fg-primary)",
}}
>
<strong style={{ fontWeight: 600 }}>{capitalize(obj_class)}</strong>
здесь сильнее всего недозакрыт спрос: индекс дефицита{" "}
<span
style={{
fontVariantNumeric: "tabular-nums",
fontWeight: 600,
color: `var(--${deficitVariant(recoDeficit) === "danger" ? "danger" : deficitVariant(recoDeficit) === "success" ? "success" : "fg-secondary"})`,
}}
title="Индекс дефицита ∈ [1, +1]: +1 — острый дефицит (недонасыщенность), 1 — затоварка."
>
{recoDeficit > 0 ? "+" : ""}
{fmtNum(recoDeficit, 2)}
</span>{" "}
<span style={{ color: "var(--fg-tertiary)" }}>
(где +1 острый дефицит, 1 затоварка).
</span>
</p>
)}
{/* Мини-таблица 3 классов с их индексом дефицита (контраст) (#1742) */}
<ClassDeficitTable
classDeficits={classDeficits}
recommended={obj_class}
/>
{summary && (
<p
style={{
margin: 0,
fontSize: 13,
lineHeight: 1.5,
color: "var(--fg-secondary)",
}}
>
{summary}
</p>
)}
</div>
);
}
// ── Мини-таблица per-класс дефицита (#1742 — контраст 3 классов) ───────────────
function ClassDeficitTable({
classDeficits,
recommended,
}: {
classDeficits: ClassDeficit[];
recommended: string | null;
}) {
if (classDeficits.length === 0) return null;
return (
<div
style={{
display: "flex",
flexDirection: "column",
border: "1px solid var(--border-card)",
borderRadius: 8,
overflow: "hidden",
}}
>
{/* header */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr auto",
gap: 12,
padding: "6px 12px",
background: "var(--bg-card-alt)",
borderBottom: "1px solid var(--border-soft)",
}}
>
<span style={SUBHEAD_STYLE}>Класс</span>
<span style={{ ...SUBHEAD_STYLE, textAlign: "right" }}>
Индекс дефицита
</span>
</div>
{classDeficits.map((c, i) => {
const isReco = recommended != null && c.obj_class === recommended;
const di = c.meanDeficitIndex;
return (
<div
key={c.obj_class}
style={{
display: "grid",
gridTemplateColumns: "1fr auto",
gap: 12,
alignItems: "center",
padding: "7px 12px",
background: isReco ? "var(--accent-soft)" : "var(--bg-card)",
borderTop: i === 0 ? "none" : "1px solid var(--border-soft)",
}}
>
<span
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
color: "var(--fg-primary)",
fontWeight: isReco ? 600 : 400,
}}
>
{capitalize(c.obj_class)}
{isReco && (
<Badge variant="info" size="sm">
рекомендован
</Badge>
)}
</span>
{di != null ? (
<span
style={{
fontSize: 13,
fontVariantNumeric: "tabular-nums",
fontWeight: 600,
textAlign: "right",
color: `var(--${deficitVariant(di) === "danger" ? "danger" : deficitVariant(di) === "success" ? "success" : "fg-secondary"})`,
}}
>
{di > 0 ? "+" : ""}
{fmtNum(di, 2)}
</span>
) : (
<span
style={{
fontSize: 12,
textAlign: "right",
color: "var(--fg-tertiary)",
}}
>
тонкие данные
</span>
)}
</div>
);
})}
</div>
);
}
// ── Отвергнутые альтернативы (#1742 — поднято из-под ката) ─────────────────────
function RejectedAlternatives({ reasons }: { reasons: ProductReason[] }) {
// Собираем уникальные отвергнутые альтернативы из всех §16-причин (де-дуп по
// alternative — одна и та же альтернатива может встречаться в нескольких reason).
const seen = new Set<string>();
const rejected: ProductReasonRejected[] = [];
for (const r of reasons) {
for (const rej of r.rejected ?? []) {
if (!rej.alternative || seen.has(rej.alternative)) continue;
seen.add(rej.alternative);
rejected.push(rej);
}
}
if (rejected.length === 0) return null;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={SUBHEAD_STYLE}>Отвергнутые альтернативы</span>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{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)" }}>
{capitalize(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>
);
}
// ── Квартирография (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>
)}
{/* «Отвергнутые альтернативы» подняты на уровень блока (#1742) —
здесь не дублируем. */}
{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);
}