feat(sf-legacy): EGRN table + ЗОУИТ + инженерные сети в Землю tab (доп. к #363) #364

Closed
lekss361 wants to merge 1 commit from feat/legacy-expose-new-b5-fields into main
5 changed files with 406 additions and 1 deletions

View file

@ -0,0 +1,123 @@
"use client";
import type { EgrnData } from "@/types/site-finder";
interface Props {
egrn: EgrnData;
}
function formatRub(v: number | null): string {
if (v == null) return "—";
if (v >= 1_000_000) return (v / 1_000_000).toFixed(1) + " млн ₽";
if (v >= 1_000) return (v / 1_000).toFixed(0) + " тыс ₽";
return v.toFixed(0) + " ₽";
}
function formatArea(v: number | null): string {
if (v == null) return "—";
return v.toFixed(0) + " м²";
}
function formatDate(v: string | null): string {
if (!v) return "—";
// ISO date → DD.MM.YYYY
const d = new Date(v);
if (isNaN(d.getTime())) return v;
return d.toLocaleDateString("ru-RU");
}
const ROWS: Array<{
label: string;
render: (e: EgrnData) => string;
}> = [
{ label: "Адрес", render: (e) => e.address ?? "—" },
{ label: "Статус участка", render: (e) => e.parcel_status ?? "—" },
{ label: "Категория земель", render: (e) => e.land_category ?? "—" },
{ label: "ВРИ", render: (e) => e.permitted_use_text ?? "—" },
{ label: "Форма собственности", render: (e) => e.ownership_type ?? "—" },
{ label: "Вид права", render: (e) => e.right_type ?? "—" },
{ label: "Площадь по ЕГРН", render: (e) => formatArea(e.area_m2) },
{
label: "Кадастровая стоимость",
render: (e) => formatRub(e.cadastral_value_rub),
},
{
label: "Кад. стоимость / м²",
render: (e) => formatRub(e.cadastral_value_per_m2),
},
{
label: "Дата обновления ЕГРН",
render: (e) => formatDate(e.last_egrn_update_date),
},
];
export function EgrnPropertyTable({ egrn }: Props) {
return (
<div
style={{
borderRadius: 10,
border: "1px solid var(--border-card, #E6E8EC)",
background: "var(--bg-card, #fff)",
padding: "14px 16px",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<div
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--fg-primary, #111111)",
marginBottom: 4,
}}
>
ЕГРН данные участка
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
{ROWS.map(({ label, render }, idx) => {
const value = render(egrn);
if (value === "—") return null;
return (
<div
key={label}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: 12,
padding: "6px 0",
borderBottom:
idx < ROWS.length - 1 ? "1px solid #f3f4f6" : "none",
}}
>
<span
style={{
fontSize: 12,
color: "var(--fg-secondary, #5B6066)",
flexShrink: 0,
minWidth: 140,
}}
>
{label}
</span>
<span
style={{
fontSize: 12,
color: "var(--fg-primary, #111111)",
textAlign: "right",
wordBreak: "break-word",
fontVariantNumeric: "tabular-nums",
fontFeatureSettings: '"tnum"',
}}
>
{value}
</span>
</div>
);
})}
</div>
</div>
);
}

View file

@ -7,20 +7,55 @@ import { GeologyBlock } from "./GeologyBlock";
import { GeometrySuitabilityBlock } from "./GeometrySuitabilityBlock";
import { GeotechRiskBlock } from "./GeotechRiskBlock";
import { NeighborsBlock } from "./NeighborsBlock";
import { NspdZouitOverlapsBlock } from "./NspdZouitOverlapsBlock";
import { NspdEngineeringNearbyBlock } from "./NspdEngineeringNearbyBlock";
import { EgrnPropertyTable } from "./EgrnPropertyTable";
interface Props {
data: ParcelAnalysis;
}
export function LandTab({ data }: Props) {
const hasZouit =
Array.isArray(data.nspd_zouit_overlaps) &&
data.nspd_zouit_overlaps.length > 0;
const hasEngineering =
!!data.utilities?.summary ||
(Array.isArray(data.nspd_engineering_nearby) &&
data.nspd_engineering_nearby.length > 0);
const hasEgrn = !!data.egrn;
const hasAny =
data.geotech_risk !== undefined ||
data.geology !== undefined ||
data.geometry_suitability !== undefined ||
data.neighbors_summary !== undefined;
data.neighbors_summary !== undefined ||
hasZouit ||
hasEngineering ||
hasEgrn;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
{/* ЕГРН — данные участка из реестра */}
{hasEgrn && <EgrnPropertyTable egrn={data.egrn!} />}
{/* ЗОУИТ — зоны с особыми условиями использования */}
{Array.isArray(data.nspd_zouit_overlaps) && (
<div>
<SectionLabel style={{ marginBottom: 12 }}>
Зоны с ограничениями (ЗОУИТ)
</SectionLabel>
<NspdZouitOverlapsBlock overlaps={data.nspd_zouit_overlaps} />
</div>
)}
{/* Инженерные сети / коммуникации */}
{hasEngineering && (
<NspdEngineeringNearbyBlock
utilities={data.utilities}
points={data.nspd_engineering_nearby}
/>
)}
{/* P2 (#46) — Соседи + overlap warning (hard warn если overlap) */}
{data.neighbors_summary && (
<NeighborsBlock data={data.neighbors_summary} />

View file

@ -0,0 +1,109 @@
"use client";
import type {
NspdEngineeringPoint,
UtilitiesSummary,
} from "@/types/site-finder";
interface Props {
utilities?: UtilitiesSummary | null;
points?: NspdEngineeringPoint[];
}
export function NspdEngineeringNearbyBlock({ utilities, points }: Props) {
const hasPoints = Array.isArray(points) && points.length > 0;
const hasSummary = !!utilities?.summary;
if (!hasSummary && !hasPoints) return null;
return (
<div
style={{
borderRadius: 10,
border: "1px solid var(--border-card, #E6E8EC)",
background: "var(--bg-card, #fff)",
padding: "14px 16px",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--fg-primary, #111111)",
}}
>
Инженерные сети и коммуникации
</div>
{hasSummary && (
<div style={{ fontSize: 13, color: "#374151", lineHeight: 1.5 }}>
{utilities!.summary}
</div>
)}
{utilities?.power_line_охранная_зона_flag && (
<div
style={{
borderRadius: 8,
padding: "8px 12px",
background: "#fff7ed",
border: "1px solid #fdba74",
fontSize: 12,
fontWeight: 600,
color: "#c2410c",
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<span>&#9888;</span>
<span>Охранная зона ЛЭП строительство ограничено</span>
</div>
)}
{hasPoints && (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.04em",
}}
>
Ближайшие точки подключения
</div>
{points!.map((pt, idx) => (
<div
key={idx}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: 12,
color: "#374151",
borderBottom: "1px solid #f3f4f6",
paddingBottom: 4,
}}
>
<span>{pt.name ?? pt.type ?? "Коммуникация"}</span>
{pt.distance_m !== null && (
<span style={{ color: "#6b7280" }}>
{Math.round(pt.distance_m)} м
</span>
)}
</div>
))}
</div>
)}
{utilities?.note && (
<div style={{ fontSize: 11, color: "#9ca3af" }}>{utilities.note}</div>
)}
</div>
);
}

View file

@ -0,0 +1,93 @@
"use client";
import type { NspdZouitOverlap } from "@/types/site-finder";
interface Props {
overlaps: NspdZouitOverlap[];
}
export function NspdZouitOverlapsBlock({ overlaps }: Props) {
if (overlaps.length === 0) {
return (
<div
style={{
borderRadius: 10,
padding: "12px 16px",
background: "var(--success-soft, #dcfce7)",
border: "1px solid #bbf7d0",
fontSize: 13,
color: "var(--success, #0A7A3A)",
}}
>
Зоны с ограничениями (ЗОУИТ) не обнаружены
</div>
);
}
// Deduplicate by name to avoid showing the same zone twice (different sources)
const seen = new Set<string>();
const unique = overlaps.filter((o) => {
if (seen.has(o.name)) return false;
seen.add(o.name);
return true;
});
const isHardBlock = unique.some((o) =>
o.type_zone?.toLowerCase().includes("охранная"),
);
return (
<div
style={{
borderRadius: 10,
border: isHardBlock ? "1px solid #fca5a5" : "1px solid #fde68a",
background: isHardBlock ? "#fef2f2" : "#fffbeb",
padding: "14px 16px",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
fontWeight: 700,
color: isHardBlock ? "#b91c1c" : "#92400e",
}}
>
<span>&#9888;</span>
<span>
ЗОУИТ: {unique.length}{" "}
{unique.length === 1 ? "зона" : unique.length < 5 ? "зоны" : "зон"}
</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{unique.map((o, idx) => (
<div
key={idx}
style={{
fontSize: 12,
color: "#374151",
borderLeft: `3px solid ${isHardBlock ? "#f87171" : "#fbbf24"}`,
paddingLeft: 10,
lineHeight: 1.5,
}}
>
<div style={{ fontWeight: 600, color: "#111827" }}>
{o.type_zone ?? o.layer}
</div>
<div style={{ color: "#6b7280", marginTop: 2 }}>{o.name}</div>
</div>
))}
</div>
<div style={{ fontSize: 11, color: "#9ca3af" }}>
Источник: НСПД / Росреестр ЗОУИТ
</div>
</div>
);
}

View file

@ -335,6 +335,46 @@ export interface ScoreGroupTotal {
contribution_pct: number;
}
// B5 extended — utilities (коммуникации)
export interface UtilitiesSummary {
summary: string;
power_line_охранная_зона_flag: boolean;
note: string;
}
// B5 extended — NSPD ЗОУИТ overlap
export interface NspdZouitOverlap {
group_key: string;
layer: string;
subcategory: string | null;
name: string;
type_zone: string | null;
source: string;
}
// B5 extended — NSPD engineering nearby (точки подключения)
export interface NspdEngineeringPoint {
name: string | null;
type: string | null;
distance_m: number | null;
lat: number | null;
lon: number | null;
}
// B5 extended — ЕГРН данные участка
export interface EgrnData {
cadastral_value_rub: number | null;
cadastral_value_per_m2: number | null;
land_category: string | null;
permitted_use_text: string | null;
last_egrn_update_date: string | null;
area_m2: number | null;
ownership_type: string | null;
right_type: string | null;
parcel_status: string | null;
address: string | null;
}
export interface ParcelAnalysis {
cad_num: string;
source: "cad_quarter" | "cad_building";
@ -379,6 +419,11 @@ export interface ParcelAnalysis {
gate_verdict?: GateVerdict;
// D2 (#34) — velocity-score: темп продаж конкурентов
velocity?: Velocity | null;
// B5 extended — новые поля анализа
utilities?: UtilitiesSummary | null;
nspd_zouit_overlaps?: NspdZouitOverlap[];
nspd_engineering_nearby?: NspdEngineeringPoint[];
egrn?: EgrnData | null;
}
export type PoiCategory =