feat(site-finder): прогрессивное раскрытие секций отчёта §1–§6 («Раскрыть всё») #2116

Merged
bot-backend merged 1 commit from feat/section-progressive-disclosure into main 2026-06-30 15:31:34 +00:00
7 changed files with 413 additions and 346 deletions
Showing only changes of commit 6894c297d6 - Show all commits

View file

@ -21,6 +21,7 @@ import { NspdRegulationCard } from "./NspdRegulationCard";
import { NspdVerifyLink } from "./NspdVerifyLink";
import { PoiList2Gis } from "./PoiList2Gis";
import { ExportButtons } from "./ExportButtons";
import { StageDetails } from "./StageDetails";
import {
adaptEgrn,
formatEncumbrance,
@ -295,62 +296,64 @@ export function Section1ParcelInfo({ cad }: Props) {
/>
</div>
{/* 2-column grid: map (left) + EGRN + POI (right) */}
<div
style={{
display: "grid",
gridTemplateColumns: "400px 1fr",
gap: 16,
alignItems: "start",
marginBottom: 20,
}}
>
{/* Left: mini-map */}
<MiniMap data={data} />
<StageDetails>
{/* 2-column grid: map (left) + EGRN + POI (right) */}
<div
style={{
display: "grid",
gridTemplateColumns: "400px 1fr",
gap: 16,
alignItems: "start",
marginBottom: 20,
}}
>
{/* Left: mini-map */}
<MiniMap data={data} />
{/* Right: EGRN table + НСПД-градрегламент + POI list */}
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<EgrnPropertyTable data={egrn} />
{/* Right: EGRN table + НСПД-градрегламент + POI list */}
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<EgrnPropertyTable data={egrn} />
{/* #1962 реальный ПЗЗ-градрегламент из nspd_zoning (synth #1891).
{/* #1962 реальный ПЗЗ-градрегламент из nspd_zoning (synth #1891).
Раньше отчёт показывал только мёртвый zoning.data_available=false
(закрытый PKK6-путь) и НЕ выводил резолвленный регламент. КСИТ-ёмкость
считается от площади ЕГРН (egrn.area_m2). */}
<NspdRegulationCard
zoning={data.nspd_zoning}
areaM2={
Number.isFinite(egrn.area_m2) && egrn.area_m2 > 0
? egrn.area_m2
: null
}
/>
{poiData ? (
<PoiList2Gis
items={poiData.items}
totalScore={poiData.poi_weighted_score}
<NspdRegulationCard
zoning={data.nspd_zoning}
areaM2={
Number.isFinite(egrn.area_m2) && egrn.area_m2 > 0
? egrn.area_m2
: null
}
/>
) : (
// Fallback: B6 poi-score (useParcelPoiScoreQuery) ещё грузится или
// упал. score_breakdown несёт только name/distance_m — без реального
// per-POI weight и без POI-weighted-score, поэтому НЕ подставляем
// инвест-балл участка (data.score) как POI-оценку «X / 100» и НЕ
// фабрикуем weight/score_contribution (#1466). Показываем честный
// пустой стейт «POI данные недоступны» до прихода B6.
<PoiList2Gis items={[]} totalScore={0} />
)}
</div>
</div>
{/* Export buttons */}
<div
style={{
paddingTop: 16,
borderTop: "1px solid var(--border-soft)",
}}
>
<ExportButtons cad={cad} analyzeData={data} egrn={egrn} />
</div>
{poiData ? (
<PoiList2Gis
items={poiData.items}
totalScore={poiData.poi_weighted_score}
/>
) : (
// Fallback: B6 poi-score (useParcelPoiScoreQuery) ещё грузится или
// упал. score_breakdown несёт только name/distance_m — без реального
// per-POI weight и без POI-weighted-score, поэтому НЕ подставляем
// инвест-балл участка (data.score) как POI-оценку «X / 100» и НЕ
// фабрикуем weight/score_contribution (#1466). Показываем честный
// пустой стейт «POI данные недоступны» до прихода B6.
<PoiList2Gis items={[]} totalScore={0} />
)}
</div>
</div>
{/* Export buttons */}
<div
style={{
paddingTop: 16,
borderTop: "1px solid var(--border-soft)",
}}
>
<ExportButtons cad={cad} analyzeData={data} egrn={egrn} />
</div>
</StageDetails>
</section>
);
}

View file

@ -44,6 +44,7 @@ import {
type UtilityInfrastructureResponse,
} from "@/hooks/useUtilityInfrastructure";
import { buildUtilityTableRows, type UtilityTableRow } from "./utility-table";
import { StageDetails } from "./StageDetails";
// ── Connection-points map (#1746) ─────────────────────────────────────────────
// Высота тайла карты для drill-in уровня раздела. Легенда + CpLayerControlPanel
@ -770,117 +771,124 @@ export function Section2NetworksUtilities({ cad }: Props) {
<HeadlineBar title="Сети" subtitle={headlineSubtitle} />
</div>
{/* Power line охранная зона warning */}
{powerLineZone && (
<div
role="alert"
style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
padding: "12px 16px",
background: "var(--danger-soft)",
border: "1px solid var(--danger)",
borderRadius: 10,
marginBottom: 16,
fontSize: 13,
color: "var(--danger)",
}}
>
<AlertTriangle
size={16}
strokeWidth={1.5}
style={{ flexShrink: 0, marginTop: 1 }}
/>
<span>
Участок находится в охранной зоне ЛЭП 35 кВ. Капитальное
строительство в охранной зоне (ОЗ) запрещено (СП 36.13330). Участок
в зоне с особыми условиями использования территорий (ЗОУИТ), тип 5.
</span>
</div>
)}
<StageDetails>
{/* Power line охранная зона warning */}
{powerLineZone && (
<div
role="alert"
style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
padding: "12px 16px",
background: "var(--danger-soft)",
border: "1px solid var(--danger)",
borderRadius: 10,
marginBottom: 16,
fontSize: 13,
color: "var(--danger)",
}}
>
<AlertTriangle
size={16}
strokeWidth={1.5}
style={{ flexShrink: 0, marginTop: 1 }}
/>
<span>
Участок находится в охранной зоне ЛЭП 35 кВ. Капитальное
строительство в охранной зоне (ОЗ) запрещено (СП 36.13330).
Участок в зоне с особыми условиями использования территорий
(ЗОУИТ), тип 5.
</span>
</div>
)}
{/* Счётчики источников явно разделяем «инж.сети OSM» и «точки
{/* Счётчики источников явно разделяем «инж.сети OSM» и «точки
подключения НСПД», чтобы числа не путались (#1953). */}
{!networksLoading && (
<SourceCounters
utilityCount={utilityMappableCount}
connectionPointCount={connectionPointCount}
/>
)}
{/* Utilities table — строки из osm_utility_infrastructure (= карта) */}
{networksLoading ? (
<div
style={{
height: 160,
background: "var(--bg-card-alt)",
borderRadius: 10,
border: "1px solid var(--border-card)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--fg-tertiary)",
fontSize: 13,
}}
>
Загрузка инженерных сетей...
</div>
) : tableRows.length > 0 ? (
<UtilitiesTable rows={tableRows} />
) : (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "16px",
background: "var(--bg-card-alt)",
border: "1px dashed var(--border-strong)",
borderRadius: 10,
color: "var(--fg-tertiary)",
fontSize: 13,
}}
>
<Info size={16} strokeWidth={1.5} style={{ flexShrink: 0 }} />
Инженерные сети (OSM) не найдены в радиусе {COVERAGE_RADIUS_KM} км.
{connectionPointCount > 0
? " Точки подключения (НСПД) показаны на карте ниже."
: ""}
</div>
)}
{/* Карта точек подключения + охранных зон сетей + инж.сети OSM (#1746) */}
<ConnectionPointsMap
data={data}
connectionPoints={connectionPoints}
utilityInfrastructure={utilityInfrastructure}
isLoading={networksLoading}
/>
{/* Caveat note */}
{note && (
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 8,
marginTop: 12,
padding: "10px 12px",
background: "var(--bg-card-alt)",
borderRadius: 8,
fontSize: 12,
color: "var(--fg-secondary)",
}}
>
<Info
size={14}
strokeWidth={1.5}
style={{ flexShrink: 0, marginTop: 1, color: "var(--fg-tertiary)" }}
{!networksLoading && (
<SourceCounters
utilityCount={utilityMappableCount}
connectionPointCount={connectionPointCount}
/>
<span>{note}</span>
</div>
)}
)}
{/* Utilities table — строки из osm_utility_infrastructure (= карта) */}
{networksLoading ? (
<div
style={{
height: 160,
background: "var(--bg-card-alt)",
borderRadius: 10,
border: "1px solid var(--border-card)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--fg-tertiary)",
fontSize: 13,
}}
>
Загрузка инженерных сетей...
</div>
) : tableRows.length > 0 ? (
<UtilitiesTable rows={tableRows} />
) : (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "16px",
background: "var(--bg-card-alt)",
border: "1px dashed var(--border-strong)",
borderRadius: 10,
color: "var(--fg-tertiary)",
fontSize: 13,
}}
>
<Info size={16} strokeWidth={1.5} style={{ flexShrink: 0 }} />
Инженерные сети (OSM) не найдены в радиусе {COVERAGE_RADIUS_KM} км.
{connectionPointCount > 0
? " Точки подключения (НСПД) показаны на карте ниже."
: ""}
</div>
)}
{/* Карта точек подключения + охранных зон сетей + инж.сети OSM (#1746) */}
<ConnectionPointsMap
data={data}
connectionPoints={connectionPoints}
utilityInfrastructure={utilityInfrastructure}
isLoading={networksLoading}
/>
{/* Caveat note */}
{note && (
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 8,
marginTop: 12,
padding: "10px 12px",
background: "var(--bg-card-alt)",
borderRadius: 8,
fontSize: 12,
color: "var(--fg-secondary)",
}}
>
<Info
size={14}
strokeWidth={1.5}
style={{
flexShrink: 0,
marginTop: 1,
color: "var(--fg-tertiary)",
}}
/>
<span>{note}</span>
</div>
)}
</StageDetails>
</section>
);
}

View file

@ -19,6 +19,7 @@ import type {
ParcelAnalysis,
ParcelAnalysisCompetitor,
} from "@/types/site-finder";
import { StageDetails } from "./StageDetails";
// ── Types ─────────────────────────────────────────────────────────────────────
@ -799,40 +800,42 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) {
</div>
</div>
{/* Sub-sections */}
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
<Section31Settings filters={filters} onFiltersChange={setFilters} />
<StageDetails>
{/* Sub-sections */}
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
<Section31Settings filters={filters} onFiltersChange={setFilters} />
{/* Competitor table — moved before 3.2/3.3 for context */}
{filteredCompetitors.length > 0 && (
<CompetitorTable
competitors={filteredCompetitors}
districtName={districtName}
onRowClick={setSelectedCompetitor}
/>
)}
{/* Competitor table — moved before 3.2/3.3 for context */}
{filteredCompetitors.length > 0 && (
<CompetitorTable
competitors={filteredCompetitors}
districtName={districtName}
onRowClick={setSelectedCompetitor}
/>
)}
<Section32Layouts cad={cad} />
<Section33MarketSales data={data} />
</div>
<Section32Layouts cad={cad} />
<Section33MarketSales data={data} />
</div>
{/* Filtered competitors count hint */}
{competitorCount > 0 &&
filteredCompetitors.length !== competitorCount && (
<div
style={{
marginTop: 16,
padding: "10px 14px",
background: "var(--warn-soft, #FEF3C7)",
borderRadius: 8,
fontSize: 12,
color: "var(--warn, #9A6700)",
}}
>
Фильтр по радиусу: показано {filteredCompetitors.length} из{" "}
{competitorCount} конкурентов
</div>
)}
{/* Filtered competitors count hint */}
{competitorCount > 0 &&
filteredCompetitors.length !== competitorCount && (
<div
style={{
marginTop: 16,
padding: "10px 14px",
background: "var(--warn-soft, #FEF3C7)",
borderRadius: 8,
fontSize: 12,
color: "var(--warn, #9A6700)",
}}
>
Фильтр по радиусу: показано {filteredCompetitors.length} из{" "}
{competitorCount} конкурентов
</div>
)}
</StageDetails>
{/* Competitor detail drawer */}
<CompetitorDetailDrawer

View file

@ -26,6 +26,7 @@ import { GeotechRiskBlock } from "@/components/site-finder/GeotechRiskBlock";
import { NspdZouitOverlapsBlock } from "@/components/site-finder/NspdZouitOverlapsBlock";
import { SuccessRecommendationBlock } from "@/components/site-finder/SuccessRecommendationBlock";
import { useParcelAnalyzeQuery } from "@/lib/site-finder-api";
import { StageDetails } from "./StageDetails";
import type { ParcelAnalysis } from "@/types/site-finder";
import type { FactorContribution, ScoreGroupTotal } from "@/types/site-finder";
@ -187,72 +188,74 @@ export function Section4Estimate({ cad }: Props) {
</div>
)}
{/* ── Risk blocks grid ─────────────────────────────────────────────── */}
{(analysis.geology || analysis.hydrology || analysis.geotech_risk) && (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
marginBottom: 12,
}}
>
{analysis.geology && <GeologyBlock geology={analysis.geology} />}
{analysis.hydrology && (
<HydrologyBlock hydrology={analysis.hydrology} />
)}
{analysis.geotech_risk && (
<GeotechRiskBlock risk={analysis.geotech_risk} />
)}
</div>
)}
{/* ── NSPD ZOUIT overlaps ──────────────────────────────────────────── */}
{hasZouit && (
<div
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: "16px 20px",
marginBottom: 12,
}}
>
<StageDetails>
{/* ── Risk blocks grid ─────────────────────────────────────────────── */}
{(analysis.geology || analysis.hydrology || analysis.geotech_risk) && (
<div
title="Зоны с особыми условиями использования территорий — охранные зоны инженерной инфраструктуры (ЛЭП, газопровод и т.д.)"
style={{
fontSize: 12,
fontWeight: 600,
color: "var(--fg-secondary)",
textTransform: "uppercase",
letterSpacing: "0.04em",
marginBottom: 10,
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
marginBottom: 12,
}}
>
Охранные зоны ЗОУИТ
{analysis.geology && <GeologyBlock geology={analysis.geology} />}
{analysis.hydrology && (
<HydrologyBlock hydrology={analysis.hydrology} />
)}
{analysis.geotech_risk && (
<GeotechRiskBlock risk={analysis.geotech_risk} />
)}
</div>
<NspdZouitOverlapsBlock
overlaps={analysis.nspd_zouit_overlaps ?? []}
parcelCad={cad}
/>
</div>
)}
)}
{/* ── Success recommendation ───────────────────────────────────────── */}
{analysis.success_recommendation !== undefined && (
<div
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: "16px 20px",
}}
>
<SuccessRecommendationBlock
recommendation={analysis.success_recommendation}
/>
</div>
)}
{/* ── NSPD ZOUIT overlaps ──────────────────────────────────────────── */}
{hasZouit && (
<div
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: "16px 20px",
marginBottom: 12,
}}
>
<div
title="Зоны с особыми условиями использования территорий — охранные зоны инженерной инфраструктуры (ЛЭП, газопровод и т.д.)"
style={{
fontSize: 12,
fontWeight: 600,
color: "var(--fg-secondary)",
textTransform: "uppercase",
letterSpacing: "0.04em",
marginBottom: 10,
}}
>
Охранные зоны ЗОУИТ
</div>
<NspdZouitOverlapsBlock
overlaps={analysis.nspd_zouit_overlaps ?? []}
parcelCad={cad}
/>
</div>
)}
{/* ── Success recommendation ───────────────────────────────────────── */}
{analysis.success_recommendation !== undefined && (
<div
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: "16px 20px",
}}
>
<SuccessRecommendationBlock
recommendation={analysis.success_recommendation}
/>
</div>
)}
</StageDetails>
</section>
);
}

View file

@ -20,6 +20,7 @@ import { Wind, Thermometer, Droplets } from "lucide-react";
import { HeadlineBar } from "@/components/ui/HeadlineBar";
import { SeasonalWeatherBlock } from "@/components/site-finder/SeasonalWeatherBlock";
import { StageDetails } from "./StageDetails";
import { useParcelAnalyzeQuery } from "@/lib/site-finder-api";
import type { ParcelAnalysis } from "@/types/site-finder";
import type {
@ -625,25 +626,27 @@ export function Section5Atmosphere({ cad }: Props) {
{/* Seasonal weather (climate normals) */}
{hasSeasonal && (
<div>
<div
style={{
fontSize: 11,
fontWeight: 500,
letterSpacing: "0.04em",
textTransform: "uppercase",
color: "var(--fg-secondary, #5B6066)",
marginBottom: 8,
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<Thermometer size={12} aria-hidden />
Климат (нормали 30 лет)
<StageDetails>
<div>
<div
style={{
fontSize: 11,
fontWeight: 500,
letterSpacing: "0.04em",
textTransform: "uppercase",
color: "var(--fg-secondary, #5B6066)",
marginBottom: 8,
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<Thermometer size={12} aria-hidden />
Климат (нормали 30 лет)
</div>
<SeasonalWeatherBlock seasonal={analysis.seasonal_weather} />
</div>
<SeasonalWeatherBlock seasonal={analysis.seasonal_weather} />
</div>
</StageDetails>
)}
</div>
) : (

View file

@ -38,6 +38,7 @@ import { ForecastScoringBlock } from "./ForecastScoringBlock";
import { ForecastFutureSupplyBlock } from "./ForecastFutureSupplyBlock";
import { ForecastMetaLine } from "./ForecastMetaLine";
import { ForecastExportButtons } from "./ForecastExportButtons";
import { StageDetails } from "./StageDetails";
import {
CONFIDENCE_RU,
deficitPegState,
@ -373,101 +374,103 @@ function ForecastReady({
</div>
)}
{/* Траектория индекса дефицита (chart-then-table, выше 6.1). Причину
<StageDetails>
{/* Траектория индекса дефицита (chart-then-table, выше 6.1). Причину
схлопывания сценариев показываем в 6.2 под заголовком «Почему один
сценарий, а не три» (#1963) здесь дубль убран. */}
<ForecastChart report={report} selectedHorizon={selectedHorizon} />
<ForecastChart report={report} selectedHorizon={selectedHorizon} />
{/* 6.1 Прогноз по горизонтам */}
{hasHorizons && (
<SubBlock id="section-6-1" title="6.1 Прогноз по горизонтам">
<ForecastHorizonsBlock
forecasts={fm.forecasts_by_horizon}
targetHorizon={targetHorizon}
peg={peg}
/>
</SubBlock>
)}
{/* 6.1 Прогноз по горизонтам */}
{hasHorizons && (
<SubBlock id="section-6-1" title="6.1 Прогноз по горизонтам">
<ForecastHorizonsBlock
forecasts={fm.forecasts_by_horizon}
targetHorizon={targetHorizon}
peg={peg}
/>
</SubBlock>
)}
{/* 6.2 Сценарии */}
{hasScenarios && (
<SubBlock id="section-6-2" title="6.2 Сценарии">
<ScenariosBlock
scenarios={report.scenarios}
scenariosSummary={fm.scenarios_summary}
targetHorizon={targetHorizon}
peg={peg}
/>
</SubBlock>
)}
{/* 6.2 Сценарии */}
{hasScenarios && (
<SubBlock id="section-6-2" title="6.2 Сценарии">
<ScenariosBlock
scenarios={report.scenarios}
scenariosSummary={fm.scenarios_summary}
targetHorizon={targetHorizon}
peg={peg}
/>
</SubBlock>
)}
{/* 6.3 Уверенность */}
{hasConfidence && (
<SubBlock id="section-6-3" title="6.3 Уверенность">
<ForecastConfidenceBlock confidence={report.confidence} />
</SubBlock>
)}
{/* 6.3 Уверенность */}
{hasConfidence && (
<SubBlock id="section-6-3" title="6.3 Уверенность">
<ForecastConfidenceBlock confidence={report.confidence} />
</SubBlock>
)}
{/* 6.4 Рекомендация по продукту — что строить на участке (§13.4) */}
{hasProductTz && pt && (
<SubBlock id="section-6-4" title="6.4 Рекомендация по продукту">
<ForecastProductTzBlock productTz={pt} />
</SubBlock>
)}
{/* 6.4 Рекомендация по продукту — что строить на участке (§13.4) */}
{hasProductTz && pt && (
<SubBlock id="section-6-4" title="6.4 Рекомендация по продукту">
<ForecastProductTzBlock productTz={pt} />
</SubBlock>
)}
{/* 6.4a Прогноз по всем форматам таблица ассортимента (#1745, фаза 1).
{/* 6.4a Прогноз по всем форматам таблица ассортимента (#1745, фаза 1).
Под рекомендацией: весь срез дефицита по форматам рекомендованного
класса. Показываем только когда есть форматные ячейки квартирографии. */}
{hasAssortment && pt && (
<SubBlock id="section-6-4a" title="6.4 Прогноз по всем форматам">
<ForecastAssortmentBlock objClass={pt.obj_class} mix={pt.mix} />
</SubBlock>
)}
{hasAssortment && pt && (
<SubBlock id="section-6-4a" title="6.4 Прогноз по всем форматам">
<ForecastAssortmentBlock objClass={pt.obj_class} mix={pt.mix} />
</SubBlock>
)}
{/* 6.5 Прозрачность скоринга — почему итоговый скор такой (§13.6) */}
{hasScoring && sc && (
<SubBlock id="section-6-5" title="6.5 Прозрачность скоринга">
<ForecastScoringBlock scoring={sc} />
</SubBlock>
)}
{/* 6.5 Прозрачность скоринга — почему итоговый скор такой (§13.6) */}
{hasScoring && sc && (
<SubBlock id="section-6-5" title="6.5 Прозрачность скоринга">
<ForecastScoringBlock scoring={sc} />
</SubBlock>
)}
{/* 6.6 Будущее предложение и конкуренты — §16-evidence за прогнозом (§13.3) */}
{hasFutureSupplyDetail && (
<SubBlock
id="section-6-6"
title="6.6 Будущее предложение и конкуренты"
>
<ForecastFutureSupplyBlock
futureSupply={fm.future_supply}
futureCompetitors={fm.future_competitors}
/>
</SubBlock>
)}
{/* 6.6 Будущее предложение и конкуренты — §16-evidence за прогнозом (§13.3) */}
{hasFutureSupplyDetail && (
<SubBlock
id="section-6-6"
title="6.6 Будущее предложение и конкуренты"
>
<ForecastFutureSupplyBlock
futureSupply={fm.future_supply}
futureCompetitors={fm.future_competitors}
/>
</SubBlock>
)}
{/* Freshness / traceability: когда рассчитан + горизонты + версия схемы */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 6,
paddingTop: 12,
borderTop: "1px solid var(--border-soft)",
}}
>
<ForecastMetaLine meta={report.meta} runCreatedAt={runCreatedAt} />
{/* Advisory disclaimer (всегда последним) */}
<p
{/* Freshness / traceability: когда рассчитан + горизонты + версия схемы */}
<div
style={{
margin: 0,
fontSize: 12,
lineHeight: 1.5,
color: "var(--fg-tertiary)",
display: "flex",
flexDirection: "column",
gap: 6,
paddingTop: 12,
borderTop: "1px solid var(--border-soft)",
}}
>
{ADVISORY_DISCLAIMER}
</p>
</div>
<ForecastMetaLine meta={report.meta} runCreatedAt={runCreatedAt} />
{/* Advisory disclaimer (всегда последним) */}
<p
style={{
margin: 0,
fontSize: 12,
lineHeight: 1.5,
color: "var(--fg-tertiary)",
}}
>
{ADVISORY_DISCLAIMER}
</p>
</div>
</StageDetails>
</div>
</section>
);

View file

@ -0,0 +1,44 @@
"use client";
import { useState } from "react";
interface StageDetailsProps {
children: React.ReactNode;
openLabel?: string;
closeLabel?: string;
}
export function StageDetails({
children,
openLabel = "Раскрыть всё",
closeLabel = "Свернуть",
}: StageDetailsProps) {
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((prev) => !prev)}
style={{
width: "100%",
padding: "10px 14px",
border: "1px solid var(--border-card)",
borderRadius: 8,
background: "var(--bg-card-alt)",
color: "var(--accent)",
fontSize: 13,
fontWeight: 500,
cursor: "pointer",
textAlign: "center",
marginTop: 16,
}}
>
{open ? closeLabel : openLabel}
{open ? " ▴" : " ▾"}
</button>
{open && <div style={{ marginTop: 16 }}>{children}</div>}
</>
);
}