"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 = { 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 (
{/* Recommended class — prominent pill + автоген-фраза про дефицит + 3 класса */} {/* #1742 — отвергнутые альтернативы подняты из-под ката (выше обоснования) */} {/* Квартирография — deficit/share bars */} {/* Коммерция */} {/* USP-ниши */} {/* §16 обоснование (раскрываемое) — без «отвергнутых» (подняты выше) */}
); } // ── 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 (
{obj_class != null && (
Рекомендованный класс {capitalize(obj_class)}
)} {/* Автоген-фраза: почему именно этот класс (#1742) */} {obj_class != null && recoDeficit != null && (

{capitalize(obj_class)} — здесь сильнее всего недозакрыт спрос: индекс дефицита{" "} {recoDeficit > 0 ? "+" : ""} {fmtNum(recoDeficit, 2)} {" "} (где +1 — острый дефицит, −1 — затоварка).

)} {/* Мини-таблица 3 классов с их индексом дефицита (контраст) (#1742) */} {summary && (

{summary}

)}
); } // ── Мини-таблица per-класс дефицита (#1742 — контраст 3 классов) ─────────────── function ClassDeficitTable({ classDeficits, recommended, }: { classDeficits: ClassDeficit[]; recommended: string | null; }) { if (classDeficits.length === 0) return null; return (
{/* header */}
Класс Индекс дефицита
{classDeficits.map((c, i) => { const isReco = recommended != null && c.obj_class === recommended; const di = c.meanDeficitIndex; return (
{capitalize(c.obj_class)} {isReco && ( рекомендован )} {di != null ? ( {di > 0 ? "+" : ""} {fmtNum(di, 2)} ) : ( тонкие данные )}
); })}
); } // ── Отвергнутые альтернативы (#1742 — поднято из-под ката) ───────────────────── function RejectedAlternatives({ reasons }: { reasons: ProductReason[] }) { // Собираем уникальные отвергнутые альтернативы из всех §16-причин (де-дуп по // alternative — одна и та же альтернатива может встречаться в нескольких reason). const seen = new Set(); 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 (
Отвергнутые альтернативы
{rejected.map((rej, i) => ( {capitalize(rej.alternative)} {rej.deficit_index != null && ( {rej.deficit_index > 0 ? "+" : ""} {fmtNum(rej.deficit_index, 2)} )} · {rej.reason} ))}
); } // ── Квартирография (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 (
Квартирография{hasShares ? "" : " · по дефициту форматов"}
{rows.map((m, i) => ( ))}
{!hasShares && (

Длина полосы — сила сигнала по индексу дефицита: положительный (зелёный) — недонасыщенность, отрицательный (красный) — затоварка.

)}
); } 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 (
{mixLabel(entry)} {valueStr} {di != null && entry.pct == null && ( {balanced ? "баланс" : deficitWord(di)} )}
); } // ── Коммерция ───────────────────────────────────────────────────────────────── function CommercialSignal({ commercial, }: { commercial: ProductCommercial | null; }) { if ( commercial == null || (commercial.available == null && !commercial.caveat) ) { return null; } const available = commercial.available === true; return (
Коммерция {commercial.available != null && ( {available ? "есть сигнал" : "нет данных"} )}
{commercial.caveat && (

{capitalize(commercial.caveat)}

)}
); } // ── USP-ниши ────────────────────────────────────────────────────────────────── function UspList({ usp }: { usp: ProductUsp[] }) { const items = usp.filter((u) => !!u.usp_text || !!u.segment); if (items.length === 0) return null; return (
УТП · ниши по дефициту ({items.length})
    {items.map((u, i) => (
  • {u.usp_text ?? uspFallbackText(u)}
    {u.deficit_index != null && (
    Индекс дефицита {u.deficit_index > 0 ? "+" : ""} {fmtNum(u.deficit_index, 2)}
    )}
  • ))}
); } 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 (
Обоснование рекомендации
{items.map((r, i) => ( ))}
); } function ReasonCard({ reason }: { reason: ProductReason }) { return (
{reason.why && (

{reason.why}

)} {/* «Отвергнутые альтернативы» подняты на уровень блока (#1742) — здесь не дублируем. */} {reason.what_would_change && reason.what_would_change.length > 0 && (
Что изменит вывод
    {reason.what_would_change.map((w, i) => (
  • {w}
  • ))}
)}
); } // ── helpers ─────────────────────────────────────────────────────────────────── function capitalize(text: string): string { return text.charAt(0).toUpperCase() + text.slice(1); }