gendesign/frontend/src/components/site-finder/OverviewTab.tsx
Light1YT 453a1f08da fix(report): newbuild-consistent district median + obj_class dedup (#1953)
FIX A (#1955) «Что хорошо продаётся»: убран фантомный класс 'Comfort'.
- Миграция 172: v_bucket_success_score COALESCE(obj_class,'Comfort')
  → COALESCE(obj_class,'не указан'). Английский литерал заполнял 397 NULL
  и сливался отдельным классом от русского 'Комфорт' → визуальные дубли
  бакетов в UI. Источник уже канонически-русский (проверено на проде),
  synonym-mapping не нужен.
- parcels.py: obj_class протаскивается в success-ranking query + dict.
- TS SuccessRankingBucket.obj_class добавлен.

FIX B (#1960) «Медиана рынка» = 64k (квартальная росреестровская n=1 ДКП):
- district.median_price_per_m2 больше не COALESCE(median_12m, ekb_ref) в SQL.
  Basis-приоритет (newbuild-first): Objective по имени района →
  geo_radius (Objective в 3км) → ekb_districts reference →
  квартальная росреестровская медиана ТОЛЬКО при deals_count≥5.
  Для 66:41:0205010:287: 64k → 132690 (geo_radius, newbuild-consistent).
- median_price_basis добавлен в payload + TS type (nullable median).
- Frontend null-guards для нового nullable median.

Tests: +4 (geo_radius basis, objective-приоритет, deals-guard, obj_class
passthrough); обновлены district-моки в 9 analyze-тестах под новую
SQL-сигнатуру.
2026-06-27 15:10:49 +05:00

362 lines
12 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 { AlertTriangle } from "lucide-react";
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 != null
? `${(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс ₽/м²`
: "нет данных"}
</strong>
<span className="text-xs text-slate-500 ml-1">(новостройки)</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={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 12,
color: "#b3261e",
fontWeight: 600,
marginTop: 4,
}}
>
<AlertTriangle
size={16}
strokeWidth={1.5}
aria-hidden
style={{ flexShrink: 0 }}
/>
<span>
Охранная зона ЛЭП капитальное строительство запрещено
</span>
</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,
}}
>
<AlertTriangle
size={16}
strokeWidth={1.5}
aria-hidden
style={{ flexShrink: 0 }}
/>
<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>
);
}