diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx index b8213853..a43f12ec 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -261,6 +261,7 @@ const NAV_ITEMS = [ { id: "section-6-1", label: "6.1 Прогноз по горизонтам" }, { id: "section-6-2", label: "6.2 Сценарии" }, { id: "section-6-3", label: "6.3 Уверенность" }, + { id: "section-6-4", label: "6.4 Рекомендация по продукту" }, ], }, ]; diff --git a/frontend/src/components/site-finder/analysis/ForecastProductTzBlock.tsx b/frontend/src/components/site-finder/analysis/ForecastProductTzBlock.tsx new file mode 100644 index 00000000..e4b3f77b --- /dev/null +++ b/frontend/src/components/site-finder/analysis/ForecastProductTzBlock.tsx @@ -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 = { + 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 ( +
+ {/* Recommended class — prominent pill + summary line */} + + + {/* Квартирография — deficit/share bars */} + + + {/* Коммерция */} + + + {/* USP-ниши */} + + + {/* §16 обоснование (раскрываемое) */} + +
+ ); +} + +// ── Recommended class ───────────────────────────────────────────────────────── + +function ClassHeadline({ + obj_class, + summary, +}: { + obj_class: string | null; + summary: string | null; +}) { + if (obj_class == null && !summary) return null; + return ( +
+ {obj_class != null && ( +
+ Рекомендованный класс + + {obj_class} + +
+ )} + {summary && ( +

+ {summary} +

+ )} +
+ ); +} + +// ── Квартирография (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} +

+ )} + + {reason.rejected && reason.rejected.length > 0 && ( +
+ Отвергнутые альтернативы +
+ {reason.rejected.map((rej, i) => ( + + + {rej.alternative} + + {rej.deficit_index != null && ( + + {rej.deficit_index > 0 ? "+" : ""} + {fmtNum(rej.deficit_index, 2)} + + )} + + · {rej.reason} + + + ))} +
+
+ )} + + {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); +} diff --git a/frontend/src/components/site-finder/analysis/Section6Forecast.tsx b/frontend/src/components/site-finder/analysis/Section6Forecast.tsx index 063c61d6..bc598f4a 100644 --- a/frontend/src/components/site-finder/analysis/Section6Forecast.tsx +++ b/frontend/src/components/site-finder/analysis/Section6Forecast.tsx @@ -11,9 +11,10 @@ * * 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.1 Прогноз по горизонтам → ForecastHorizonsBlock + * 6.2 Сценарии → ScenariosBlock + * 6.3 Уверенность → ForecastConfidenceBlock + * 6.4 Рекомендация по продукту → ForecastProductTzBlock (что строить — §13.4) * advisory disclaimer (footer) */ @@ -28,6 +29,7 @@ import { ForecastChart } from "./ForecastChart"; import { ForecastHorizonsBlock } from "./ForecastHorizonsBlock"; import { ScenariosBlock } from "./ScenariosBlock"; import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock"; +import { ForecastProductTzBlock } from "./ForecastProductTzBlock"; import { ForecastExportButtons } from "./ForecastExportButtons"; import { CONFIDENCE_RU, deficitVariant, fmtNum } from "./forecast-helpers"; @@ -187,17 +189,27 @@ function ForecastReady({ report.confidence.level != null || 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 «откуда / что значит»). const subtitle = fm.summary ?? undefined; return (
- +
)} + {/* 6.4 Рекомендация по продукту — что строить на участке (§13.4) */} + {hasProductTz && pt && ( + + + + )} + {/* Advisory disclaimer */}

= { diff --git a/frontend/src/types/forecast.ts b/frontend/src/types/forecast.ts index b7dcfc9e..d1550a69 100644 --- a/frontend/src/types/forecast.ts +++ b/frontend/src/types/forecast.ts @@ -146,6 +146,83 @@ export interface ReportConfidence { factors: Record; } +// ── 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 ───────────────────────────────────────────────────────────────────── export interface ReportMeta { @@ -168,6 +245,11 @@ export interface ForecastReport { future_market: ReportFutureMarket; scenarios: ReportScenarios; 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; }