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 { 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} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
// 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 =
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue