gendesign/frontend/src/components/site-finder/analysis/Section4Estimate.tsx
Light1YT f7773c71d4
All checks were successful
CI / openapi-codegen-check (pull_request) Successful in 1m46s
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 48s
fix(site-finder): P3 a11y/UI batch from audit #1871
- add <h1> page title to analysis page (иерархия стартовала с h2 — a11y)
- emoji → Lucide во всех вердикт/баннер/статус UI (ui-conventions: emoji
  запрещены): GateVerdictBanner (VerdictColor.icon string→ComponentType,
  sourceHint→ReactNode, SectionLabels), + найдены ещё 3 файла:
  OverviewTab (⚠ ЛЭП/трамвай), GeotechRiskBlock (🟡 вечная мерзлота → Snowflake,
  ⚠ загрязнение), HydrologyBlock (⚠ паводок, 🏞💦🪷🚣💧 subtype-map → Waves/Droplet).
  Все иконки декоративные → aria-hidden, семантика дублируется текстом.
  Семантические цвета через var(--success/--warn/--danger) + hex-fallback.
- расшифровка аббревиатур при первом упоминании (ui-microcopy):
  Section2 «строительство в охранной зоне (ОЗ)... зоне с особыми условиями
  использования территорий (ЗОУИТ), тип 5»; Section4 title-tooltip для ЗОУИТ.
- ForecastChart: axis-chrome hex (:65/67/68) задокументирован как canvas-renderer
  исключение (echarts canvas не резолвит CSS var() — var() сломал бы chrome,
  как и VIZ-series). Цвета не тронуты, только комментарий.

НЕ в scope: ForecastChart CI-band (±p25/p75 — backend не отдаёт, отдельная
задача); ★ в WeightProfilePanel <option> (SVG в option невозможен);
токенизация legacy-hex в Geotech/Hydrology (отдельный pass).

138 frontend vitest passed, tsc/lint clean. lucide-react уже в deps.

Refs #1871
2026-06-23 12:52:32 +05:00

257 lines
9.3 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";
/**
* Section4Estimate — "2. Оценка участка"
*
* Layout:
* HeadlineBar (score + verdict label as title)
* GateVerdictBanner (conditional — gate_verdict field)
* 2-col grid (desktop):
* left: ScoreBreakdownPanel (top-3 plus/minus + by-group bar)
* right: ScoreBreakdownStackedBar (per-category contribution)
* Geology / Hydrology / GeotechRisk blocks (conditional — each field)
* NspdZouitOverlapsBlock (conditional — nspd_zouit_overlaps present)
* SuccessRecommendationBlock (conditional — success_recommendation present)
*
* Data: useParcelAnalyzeQuery (canonical) from @/lib/site-finder-api.
* All sections are conditional — graceful if backend field is absent.
*/
import { HeadlineBar } from "@/components/ui/HeadlineBar";
import { ScoreBreakdownPanel } from "@/components/site-finder/ScoreBreakdownPanel";
import { ScoreBreakdownStackedBar } from "@/components/site-finder/ScoreBreakdownStackedBar";
import { GeologyBlock } from "@/components/site-finder/GeologyBlock";
import { HydrologyBlock } from "@/components/site-finder/HydrologyBlock";
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 type { ParcelAnalysis } from "@/types/site-finder";
import type { FactorContribution, ScoreGroupTotal } from "@/types/site-finder";
// ── Props ─────────────────────────────────────────────────────────────────────
interface Props {
cad: string;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function buildHeadlineTitle(analysis: ParcelAnalysis): string {
// Guard: a 202 on-demand-fetch stub (#1001) has no `score` — render "—"
// instead of throwing on `.toFixed` against undefined.
const score =
typeof analysis.score === "number" && Number.isFinite(analysis.score)
? analysis.score.toFixed(1)
: "—";
const label = analysis.score_label ?? "";
if (label) {
return `Оценка: ${score} · ${label.toUpperCase()}`;
}
return `Оценка: ${score}`;
}
function buildHeadlineSubtitle(analysis: ParcelAnalysis): string | undefined {
const parts: string[] = [];
if (analysis.gate_verdict) {
parts.push(`МКД: ${analysis.gate_verdict.verdict_label}`);
}
if (analysis.score_explanation) {
parts.push(analysis.score_explanation);
} else if (analysis.confidence_label) {
parts.push(`Уверенность данных: ${analysis.confidence_label}`);
}
return parts.length > 0 ? parts.join(" · ") : undefined;
}
// ── Section 4 skeleton (loading) ──────────────────────────────────────────────
function Section4Skeleton() {
return (
<section id="section-4" style={{ scrollMarginTop: 72 }}>
<div
style={{
background: "var(--bg-headline)",
borderRadius: 12,
padding: "14px 18px",
marginBottom: 12,
}}
>
<div
style={{
height: 20,
width: "40%",
background: "rgba(255,255,255,0.1)",
borderRadius: 4,
}}
/>
</div>
<div
style={{
height: 120,
background: "var(--bg-card-alt)",
border: "1px solid var(--border-soft)",
borderRadius: 12,
}}
/>
</section>
);
}
// ── Main component ────────────────────────────────────────────────────────────
export function Section4Estimate({ cad }: Props) {
const { data, isLoading, error } = useParcelAnalyzeQuery(cad);
if (isLoading) {
return <Section4Skeleton />;
}
if (error || !data) {
return (
<section
id="section-4"
style={{
scrollMarginTop: 72,
padding: "16px 20px",
background: "var(--danger-soft, #FEE2E2)",
border: "1px solid var(--danger, #B3261E)",
borderRadius: 12,
color: "var(--danger, #B3261E)",
fontSize: 13,
}}
>
{error instanceof Error
? error.message
: "Ошибка загрузки секции Оценка"}
</section>
);
}
// Cast to ParcelAnalysis which is the richer superset type with all optional fields.
const analysis = data as unknown as ParcelAnalysis;
const headlineTitle = buildHeadlineTitle(analysis);
const headlineSubtitle = buildHeadlineSubtitle(analysis);
// Breakdown data — may be absent on older backend deploys
const topPositives: FactorContribution[] =
analysis.score_top_3_positives ?? [];
const topNegatives: FactorContribution[] =
analysis.score_top_3_negatives ?? [];
const byGroup: ScoreGroupTotal[] = analysis.score_by_group ?? [];
const detailed: FactorContribution[] =
analysis.score_breakdown_detailed ?? [];
const hasBreakdown =
topPositives.length > 0 ||
topNegatives.length > 0 ||
byGroup.length > 0 ||
detailed.length > 0;
const hasStackedBar = detailed.length > 0;
const hasZouit =
analysis.nspd_zouit_overlaps !== undefined &&
analysis.nspd_zouit_overlaps !== null;
return (
<section id="section-4" style={{ scrollMarginTop: 72 }}>
{/* ── Headline bar ─────────────────────────────────────────────────── */}
<div style={{ marginBottom: 12 }}>
<HeadlineBar title={headlineTitle} subtitle={headlineSubtitle} />
</div>
{/* Gate verdict banner перенесён НАД группой «Участок» (AnalysisPageContent, #1741) */}
{/* ── Score breakdown: 2-col on desktop ───────────────────────────── */}
{hasBreakdown && (
<div
style={{
display: "grid",
gridTemplateColumns: hasStackedBar ? "1fr 1fr" : "1fr",
gap: 12,
marginBottom: 12,
}}
>
<ScoreBreakdownPanel
topPositives={topPositives}
topNegatives={topNegatives}
byGroup={byGroup}
detailed={detailed}
/>
{hasStackedBar && <ScoreBreakdownStackedBar breakdown={detailed} />}
</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,
}}
>
<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 ?? []}
/>
</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>
)}
</section>
);
}