gendesign/frontend/src/components/site-finder/OverviewTab.tsx
lekss361 6350e3f59a feat(sf-legacy): expose EGRN + utilities в Обзор tab
Бэкенд /api/v1/parcels/{cad}/analyze возвращает много новых B5 fields
(egrn, utilities, gate_verdict, nspd_zouit_overlaps, ...), но старая Обзор
вкладка показывает только subset (district, score, POI). User просит чтобы
legacy показывал не только старые данные, ближе к Maxim-style макету.

Добавил 2 conditional блока в OverviewTab после District:
- EGRN compact: address / ВРИ / категория / статус / право (если есть)
- Сети рядом: substation/pipeline/water/...  с distances + охранная зона
  ЛЭП warning

Type-only: ParcelAnalysis в types/site-finder.ts расширен полями
egrn (8 optional fields) + utilities (summary array + охранная зона flag).
Поля nullable optional — graceful empty state per cad.

Не трогает новый SF (/site-finder/*) и не меняет существующие блоки
Обзора (district / score / POI / isochrones).
2026-05-18 07:21:53 +03:00

343 lines
11 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";
import type { FeatureCollection } from "geojson";
import type { ParcelAnalysis } from "@/types/site-finder";
import { SectionLabel } from "@/components/ui/SectionLabel";
import { ConfidenceBadge } from "./ConfidenceBadge";
import { GateVerdictBanner } from "./GateVerdictBanner";
import { IsochronesPanel } from "./IsochronesPanel";
import { ScoreBreakdownPanel } from "./ScoreBreakdownPanel";
import { ScoreBreakdownStackedBar } from "./ScoreBreakdownStackedBar";
interface Props {
data: ParcelAnalysis;
onIsochronesResult?: (fc: FeatureCollection) => void;
}
const CATEGORY_LABELS: Record<string, string> = {
school: "Школы",
kindergarten: "Детские сады",
pharmacy: "Аптеки",
hospital: "Больницы / клиники",
shop_mall: "ТЦ / ТРЦ",
shop_supermarket: "Супермаркеты",
shop_small: "Магазины",
park: "Парки",
tram_stop: "Трамвайные остановки",
bus_stop: "Автобусные остановки",
metro_stop: "Метро",
};
function avgDist(items: Array<{ distance_m: number }>): number {
if (!items.length) return 0;
return Math.round(items.reduce((s, i) => s + i.distance_m, 0) / items.length);
}
export function OverviewTab({ data, onIsochronesResult }: Props) {
const tramPois = data.score_breakdown["tram_stop"] ?? [];
const nearestTram =
tramPois.length > 0
? Math.round(Math.min(...tramPois.map((p) => p.distance_m)))
: null;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
{/* G5 (#32): gate verdict banner — МКД buildability */}
{data.gate_verdict && <GateVerdictBanner verdict={data.gate_verdict} />}
{/* X2 (#48): confidence indicator на самом верху Overview */}
{data.confidence !== undefined && data.confidence_label && (
<ConfidenceBadge
value={data.confidence}
label={data.confidence_label}
breakdown={data.confidence_breakdown}
caveats={data.confidence_caveats}
/>
)}
{/* District info */}
{data.district && (
<div
style={{
background: "#f9fafb",
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<SectionLabel>Район</SectionLabel>
<div style={{ fontSize: 16, fontWeight: 700, color: "#111827" }}>
{data.district.district_name}
</div>
<div style={{ fontSize: 13, color: "#6b7280" }}>
Медиана:{" "}
<strong style={{ color: "#374151" }}>
{(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс /м²
</strong>
<span className="text-xs text-slate-500 ml-1">(12 мес)</span>
<span style={{ marginLeft: 12 }}>
{(data.district.dist_to_center / 1000).toFixed(1)} км до центра
</span>
</div>
{data.location && (
<div
style={{ fontSize: 12, color: "#9ca3af" }}
title={data.location.note}
>
До центра ЕКБ:{" "}
<strong style={{ color: "#374151" }}>
{data.location.distance_to_center_km.toFixed(1)} км
</strong>
{data.location.center_bonus > 0 && (
<span
style={{ color: "#16a34a", fontWeight: 600, marginLeft: 6 }}
>
(+{data.location.center_bonus.toFixed(1)} к score)
</span>
)}
</div>
)}
</div>
)}
{/* B5 EGRN compact (added 2026-05-18 — expose new field в legacy Обзор) */}
{data.egrn && (
<div
style={{
background: "#fff",
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<SectionLabel>ЕГРН</SectionLabel>
<div style={{ fontSize: 13, color: "#374151" }}>
{data.egrn.address && (
<div>
Адрес: <strong>{data.egrn.address}</strong>
</div>
)}
{data.egrn.permitted_use_text && (
<div>
ВРИ: <strong>{data.egrn.permitted_use_text}</strong>
</div>
)}
{data.egrn.land_category && (
<div>
Категория: <strong>{data.egrn.land_category}</strong>
</div>
)}
{data.egrn.parcel_status && (
<div>
Статус: <strong>{data.egrn.parcel_status}</strong>
</div>
)}
{data.egrn.right_type && (
<div>
Право: <strong>{data.egrn.right_type}</strong>
</div>
)}
</div>
</div>
)}
{/* B5 Utilities summary (added 2026-05-18 — expose new field в legacy Обзор) */}
{data.utilities &&
Array.isArray(data.utilities.summary) &&
data.utilities.summary.length > 0 && (
<div
style={{
background: "#fff",
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<SectionLabel>Сети рядом</SectionLabel>
<div style={{ fontSize: 13, color: "#374151" }}>
{data.utilities.summary
.map((u) => {
const label =
u.subtype === "substation"
? "электричество"
: u.subtype === "power_line"
? "ЛЭП"
: u.subtype === "pipeline"
? "газ"
: u.subtype === "water_intake"
? "вода"
: u.subtype === "pumping_station"
? "канализация"
: u.subtype;
return `${label} ${u.nearest_m} м`;
})
.join(" · ")}
</div>
{data.utilities.power_line_охранная_зона_flag && (
<div
style={{
fontSize: 12,
color: "#b3261e",
fontWeight: 600,
marginTop: 4,
}}
>
Охранная зона ЛЭП капитальное строительство запрещено
</div>
)}
</div>
)}
{/* Tram warning */}
{nearestTram !== null && (
<div
style={{
padding: "10px 16px",
background: "#fff7ed",
border: "1px solid #fed7aa",
borderRadius: 10,
fontSize: 13,
color: "#c2410c",
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<span style={{ fontSize: 16 }}></span>
<span>Трамвай в {nearestTram} м возможный источник шума</span>
</div>
)}
{/* Score details */}
<div
style={{
background: "#f9fafb",
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
<SectionLabel style={{ marginBottom: 6 }}>Балл</SectionLabel>
<div style={{ fontSize: 13, color: "#6b7280" }}>
{data.poi_count} POI ·{" "}
{data.source === "cad_quarter" ? "квартал" : "участок"}
</div>
{data.score_explanation && (
<div style={{ fontSize: 12, color: "#6b7280", fontStyle: "italic" }}>
{data.score_explanation}
</div>
)}
{data.score_without_center !== undefined && (
<div style={{ fontSize: 12, color: "#9ca3af" }}>
Без бонуса центра: {data.score_without_center.toFixed(2)}
</div>
)}
</div>
{/* X1 (#47): per-factor score breakdown с verbal explain */}
{data.score_breakdown_detailed &&
data.score_breakdown_detailed.length > 0 && (
<ScoreBreakdownPanel
topPositives={data.score_top_3_positives ?? []}
topNegatives={data.score_top_3_negatives ?? []}
byGroup={data.score_by_group ?? []}
detailed={data.score_breakdown_detailed}
/>
)}
{/* #114: score breakdown stacked bar by category */}
{data.score_breakdown_detailed &&
data.score_breakdown_detailed.length > 0 && (
<ScoreBreakdownStackedBar breakdown={data.score_breakdown_detailed} />
)}
{/* POI breakdown */}
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
background: "#fff",
}}
>
<SectionLabel style={{ marginBottom: 12 }}>
POI по категориям
</SectionLabel>
{Object.entries(data.score_breakdown).length === 0 ? (
<p style={{ fontSize: 13, color: "#9ca3af" }}>Нет данных</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
{Object.entries(data.score_breakdown).map(([cat, items]) => (
<div
key={cat}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: 13,
}}
>
<span
style={{ color: cat === "tram_stop" ? "#dc2626" : "#374151" }}
>
{CATEGORY_LABELS[cat] ?? cat}
</span>
<span style={{ color: "#6b7280", fontSize: 12 }}>
{items.length} шт · ср. {avgDist(items)} м
</span>
</div>
))}
</div>
)}
</div>
{/* Isochrones */}
{data.isochrones_available === false ? (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
background: "#f9fafb",
}}
>
<SectionLabel style={{ marginBottom: 6 }}>
Изохроны доступности
</SectionLabel>
<div style={{ fontSize: 12, color: "#9ca3af" }}>
Недоступны нужен OpenRouteService API key (см.{" "}
<code style={{ fontFamily: "monospace" }}>
backend/.env.runtime
</code>
)
</div>
</div>
) : data.isochrones_available === true && onIsochronesResult ? (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
overflow: "hidden",
}}
>
<IsochronesPanel
cadNum={data.cad_num}
onResult={onIsochronesResult}
/>
</div>
) : null}
</div>
);
}