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-сигнатуру.
362 lines
12 KiB
TypeScript
362 lines
12 KiB
TypeScript
"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>
|
||
);
|
||
}
|