feat(sf-legacy): EGRN table + ЗОУИТ + инженерные сети в Землю tab (доп. к #363) #364
5 changed files with 406 additions and 1 deletions
123
frontend/src/components/site-finder/EgrnPropertyTable.tsx
Normal file
123
frontend/src/components/site-finder/EgrnPropertyTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,20 +7,55 @@ import { GeologyBlock } from "./GeologyBlock";
|
||||||
import { GeometrySuitabilityBlock } from "./GeometrySuitabilityBlock";
|
import { GeometrySuitabilityBlock } from "./GeometrySuitabilityBlock";
|
||||||
import { GeotechRiskBlock } from "./GeotechRiskBlock";
|
import { GeotechRiskBlock } from "./GeotechRiskBlock";
|
||||||
import { NeighborsBlock } from "./NeighborsBlock";
|
import { NeighborsBlock } from "./NeighborsBlock";
|
||||||
|
import { NspdZouitOverlapsBlock } from "./NspdZouitOverlapsBlock";
|
||||||
|
import { NspdEngineeringNearbyBlock } from "./NspdEngineeringNearbyBlock";
|
||||||
|
import { EgrnPropertyTable } from "./EgrnPropertyTable";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: ParcelAnalysis;
|
data: ParcelAnalysis;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LandTab({ data }: Props) {
|
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 =
|
const hasAny =
|
||||||
data.geotech_risk !== undefined ||
|
data.geotech_risk !== undefined ||
|
||||||
data.geology !== undefined ||
|
data.geology !== undefined ||
|
||||||
data.geometry_suitability !== undefined ||
|
data.geometry_suitability !== undefined ||
|
||||||
data.neighbors_summary !== undefined;
|
data.neighbors_summary !== undefined ||
|
||||||
|
hasZouit ||
|
||||||
|
hasEngineering ||
|
||||||
|
hasEgrn;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
<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) */}
|
{/* P2 (#46) — Соседи + overlap warning (hard warn если overlap) */}
|
||||||
{data.neighbors_summary && (
|
{data.neighbors_summary && (
|
||||||
<NeighborsBlock data={data.neighbors_summary} />
|
<NeighborsBlock data={data.neighbors_summary} />
|
||||||
|
|
|
||||||
|
|
@ -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>⚠</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>⚠</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -335,6 +335,46 @@ export interface ScoreGroupTotal {
|
||||||
contribution_pct: number;
|
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 {
|
export interface ParcelAnalysis {
|
||||||
cad_num: string;
|
cad_num: string;
|
||||||
source: "cad_quarter" | "cad_building";
|
source: "cad_quarter" | "cad_building";
|
||||||
|
|
@ -379,6 +419,11 @@ export interface ParcelAnalysis {
|
||||||
gate_verdict?: GateVerdict;
|
gate_verdict?: GateVerdict;
|
||||||
// D2 (#34) — velocity-score: темп продаж конкурентов
|
// D2 (#34) — velocity-score: темп продаж конкурентов
|
||||||
velocity?: Velocity | null;
|
velocity?: Velocity | null;
|
||||||
|
// B5 extended — новые поля анализа
|
||||||
|
utilities?: UtilitiesSummary | null;
|
||||||
|
nspd_zouit_overlaps?: NspdZouitOverlap[];
|
||||||
|
nspd_engineering_nearby?: NspdEngineeringPoint[];
|
||||||
|
egrn?: EgrnData | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PoiCategory =
|
export type PoiCategory =
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue