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
После ролевой дискуссии (аналитик×комдир×собственник): - #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
697 lines
22 KiB
TypeScript
697 lines
22 KiB
TypeScript
"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);
|
||
}
|