From e5de841f2ae829e73e6b987dd7c7a2ca389054ec Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 14:49:31 +0300 Subject: [PATCH] =?UTF-8?q?feat(site-finder):=20NSPD=20frontend=20integrat?= =?UTF-8?q?ion=20=D0=B2=20LandTab=20(#202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded 'API Росреестра закрыт' с live NSPD данными. 8 files / +800 LOC: types/nspd.ts, useConnectionPoints.ts, NspdZoningBlock, NspdZouitOverlapsBlock, NspdEngineeringNearbyBlock, NspdFreshnessBadge. LandTab.tsx — удалён старый dead-end fallback, заменён 4 компонентами с PKK deep-link как fallback при NSPD null. Phase 2 (harvest auto-polling) — отдельный PR. tsc + lint + build clean. --- .../src/components/site-finder/LandTab.tsx | 87 +++---- .../NspdEngineeringNearbyBlock.tsx | 211 +++++++++++++++++ .../site-finder/NspdFreshnessBadge.tsx | 110 +++++++++ .../site-finder/NspdZoningBlock.tsx | 216 ++++++++++++++++++ .../site-finder/NspdZouitOverlapsBlock.tsx | 124 ++++++++++ frontend/src/hooks/useConnectionPoints.ts | 23 ++ frontend/src/types/nspd.ts | 71 ++++++ frontend/src/types/site-finder.ts | 5 + 8 files changed, 806 insertions(+), 41 deletions(-) create mode 100644 frontend/src/components/site-finder/NspdEngineeringNearbyBlock.tsx create mode 100644 frontend/src/components/site-finder/NspdFreshnessBadge.tsx create mode 100644 frontend/src/components/site-finder/NspdZoningBlock.tsx create mode 100644 frontend/src/components/site-finder/NspdZouitOverlapsBlock.tsx create mode 100644 frontend/src/hooks/useConnectionPoints.ts create mode 100644 frontend/src/types/nspd.ts diff --git a/frontend/src/components/site-finder/LandTab.tsx b/frontend/src/components/site-finder/LandTab.tsx index ddc0fed7..e72b10ad 100644 --- a/frontend/src/components/site-finder/LandTab.tsx +++ b/frontend/src/components/site-finder/LandTab.tsx @@ -7,6 +7,10 @@ import { GeologyBlock } from "./GeologyBlock"; import { GeometrySuitabilityBlock } from "./GeometrySuitabilityBlock"; import { GeotechRiskBlock } from "./GeotechRiskBlock"; import { NeighborsBlock } from "./NeighborsBlock"; +import { NspdZoningBlock } from "./NspdZoningBlock"; +import { NspdZouitOverlapsBlock } from "./NspdZouitOverlapsBlock"; +import { NspdEngineeringNearbyBlock } from "./NspdEngineeringNearbyBlock"; +import { NspdFreshnessBadge } from "./NspdFreshnessBadge"; interface Props { data: ParcelAnalysis; @@ -17,7 +21,10 @@ export function LandTab({ data }: Props) { data.geotech_risk !== undefined || data.geology !== undefined || data.geometry_suitability !== undefined || - data.neighbors_summary !== undefined; + data.neighbors_summary !== undefined || + data.nspd_zoning !== undefined || + data.nspd_zouit_overlaps !== undefined || + data.nspd_engineering_nearby !== undefined; return (
@@ -31,51 +38,49 @@ export function LandTab({ data }: Props) { )} - {/* Zoning note */} -
- - Зонирование (ПЗЗ) - -
- Данные ПЗЗ доступны через{" "} - - Публичную кадастровую карту - - . API Росреестра закрыт в 2024 — используйте deep-link для кад. номера{" "} - {data.cad_num}. -
- +
- Открыть в ПКК - + Зонирование (ПЗЗ) + +
+
+ {/* Issue #202 — ЗОУИТ пересечения */} + {data.nspd_zouit_overlaps !== undefined && ( +
+ + Зоны с особыми условиями использования (ЗОУИТ) + + +
+ )} + + {/* Issue #202 — Инженерные объекты в 200 м */} + {data.nspd_engineering_nearby !== undefined && ( +
+ + Инженерные объекты вблизи участка + + +
+ )} + {/* Geotech risk */} {data.geotech_risk !== undefined && (
diff --git a/frontend/src/components/site-finder/NspdEngineeringNearbyBlock.tsx b/frontend/src/components/site-finder/NspdEngineeringNearbyBlock.tsx new file mode 100644 index 00000000..f7dc76cb --- /dev/null +++ b/frontend/src/components/site-finder/NspdEngineeringNearbyBlock.tsx @@ -0,0 +1,211 @@ +"use client"; + +import type { NspdEngineeringNearby } from "@/types/nspd"; +import { useConnectionPoints } from "@/hooks/useConnectionPoints"; + +interface Props { + nearby: NspdEngineeringNearby[]; + cadNum: string | null | undefined; +} + +interface MergedRow { + label: string; + type: string | null; + distanceM: number; + source: "analyze" | "connection-points"; +} + +export function NspdEngineeringNearbyBlock({ nearby, cadNum }: Props) { + const { data: cpData, isPending: cpLoading } = useConnectionPoints(cadNum); + + // Merge and sort by distance + const rows: MergedRow[] = []; + + for (const item of nearby) { + if (item.distance_m !== null) { + rows.push({ + label: item.name ?? item.type ?? "Объект", + type: item.type, + distanceM: item.distance_m, + source: "analyze", + }); + } + } + + if (cpData) { + for (const s of cpData.engineering_structures) { + rows.push({ + label: s.name ?? s.type ?? "Объект", + type: s.type, + distanceM: s.distance_to_boundary_m, + source: "connection-points", + }); + } + } + + // Dedup by label+distance (simple check) + const seen = new Set(); + const deduped = rows.filter((r) => { + const key = `${r.label}|${Math.round(r.distanceM)}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + deduped.sort((a, b) => a.distanceM - b.distanceM); + + if (deduped.length === 0 && !cpLoading) { + return ( +
+ Инженерных объектов в 200 м не найдено +
+ ); + } + + return ( +
+ {deduped.length > 0 && ( + + + + + + + + + + {deduped.map((row, idx) => ( + + + + + + ))} + +
+ Объект + + Тип + + Расстояние +
+ {row.label} + + {row.type ?? "—"} + + {Math.round(row.distanceM)} м +
+ )} + + {cpLoading && ( +
+ Загружаем данные точек подключения… +
+ )} + + {cpData?.summary && ( +
+ {cpData.summary.in_protection_zone && ( + + В охранной зоне + + )} + {cpData.summary.nearest_structure_distance_m !== null && ( + + Ближайший:{" "} + {Math.round(cpData.summary.nearest_structure_distance_m)} м + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/site-finder/NspdFreshnessBadge.tsx b/frontend/src/components/site-finder/NspdFreshnessBadge.tsx new file mode 100644 index 00000000..c1033b74 --- /dev/null +++ b/frontend/src/components/site-finder/NspdFreshnessBadge.tsx @@ -0,0 +1,110 @@ +"use client"; + +import type { NspdDumpMeta } from "@/types/nspd"; + +interface Props { + dump: NspdDumpMeta | null | undefined; +} + +export function NspdFreshnessBadge({ dump }: Props) { + if (!dump) { + return ( + + НСПД: нет данных + + ); + } + + if (dump.harvest_triggered && !dump.available) { + return ( + + НСПД: загрузка… + + ); + } + + if (!dump.available) { + return ( + + НСПД: нет дампа + + ); + } + + const dateStr = dump.fetched_at_utc + ? new Date(dump.fetched_at_utc).toLocaleDateString("ru-RU") + : null; + + if (dump.stale) { + return ( + + НСПД: устарело{dateStr ? ` (${dateStr})` : ""} + + ); + } + + return ( + + НСПД: актуально{dateStr ? ` (${dateStr})` : ""} + + ); +} diff --git a/frontend/src/components/site-finder/NspdZoningBlock.tsx b/frontend/src/components/site-finder/NspdZoningBlock.tsx new file mode 100644 index 00000000..f9c90421 --- /dev/null +++ b/frontend/src/components/site-finder/NspdZoningBlock.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { useState } from "react"; + +import type { NspdDumpMeta, NspdZoning } from "@/types/nspd"; + +interface Props { + data: NspdZoning | null | undefined; + dump: NspdDumpMeta | null | undefined; + cadNum: string; +} + +export function NspdZoningBlock({ data, dump, cadNum }: Props) { + const [expanded, setExpanded] = useState(false); + + const isHarvesting = dump?.harvest_triggered && !dump?.available; + + // Loading skeleton when harvest is in progress + if (isHarvesting && !data) { + return ( +
+
+ + + Загружаем данные НСПД, 15–30 с… + +
+
+
+
+ ); + } + + // Data not available, no harvest triggered + if (!data) { + return ( +
+
+ Данные ПЗЗ из НСПД недоступны. Используйте{" "} + + Публичную кадастровую карту + {" "} + для кад. номера {cadNum} + . +
+ + Открыть в ПКК + +
+ ); + } + + return ( +
+
+ {data.zone_code && ( + + {data.zone_code} + + )} + {data.zone_name && ( + + {data.zone_name} + + )} + {!data.zone_code && !data.zone_name && ( + + Зона не определена + + )} +
+ + {data.raw_props && Object.keys(data.raw_props).length > 0 && ( +
+ + {expanded && ( +
+ {Object.entries(data.raw_props).map(([k, v]) => ( +
+ {k}: + {String(v ?? "—")} +
+ ))} +
+ )} +
+ )} + + {dump?.fetched_at_utc && ( +
+ НСПД: данные от{" "} + {new Date(dump.fetched_at_utc).toLocaleDateString("ru-RU")} + {dump.stale && ( + + (устаревшие) + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/site-finder/NspdZouitOverlapsBlock.tsx b/frontend/src/components/site-finder/NspdZouitOverlapsBlock.tsx new file mode 100644 index 00000000..4a08f6ea --- /dev/null +++ b/frontend/src/components/site-finder/NspdZouitOverlapsBlock.tsx @@ -0,0 +1,124 @@ +"use client"; + +import type { NspdZouitOverlap } from "@/types/nspd"; + +interface Props { + overlaps: NspdZouitOverlap[]; +} + +// Group key → human-readable label +const GROUP_LABELS: Record = { + engineering: "Инженерные коммуникации", + okn: "Объекты культурного наследия", + natural: "Природные территории", + protected: "Охраняемые зоны", + other: "Прочие ЗОУИТ", +}; + +// Determine a color-coded severity badge from group_key +function getSeverityStyle(groupKey: string): { + bg: string; + color: string; + label: string; +} { + switch (groupKey) { + case "engineering": + return { bg: "#fef3c7", color: "#92400e", label: "warn" }; + case "okn": + return { bg: "#fee2e2", color: "#991b1b", label: "block" }; + case "protected": + return { bg: "#fee2e2", color: "#991b1b", label: "block" }; + case "natural": + return { bg: "#dbeafe", color: "#1e40af", label: "low" }; + default: + return { bg: "#f3f4f6", color: "#374151", label: "info" }; + } +} + +export function NspdZouitOverlapsBlock({ overlaps }: Props) { + if (overlaps.length === 0) { + return ( +
+ + Нет пересечений + + + Охранных зон ЗОУИТ не обнаружено + +
+ ); + } + + return ( +
+ {overlaps.map((overlap, idx) => { + const style = getSeverityStyle(overlap.group_key); + const label = GROUP_LABELS[overlap.group_key] ?? overlap.group_key; + const detail = + overlap.name || + (typeof overlap.subcategory === "string" + ? overlap.subcategory + : null); + + return ( +
+ + {style.label.toUpperCase()} + +
+
+ {label} +
+ {detail && ( +
+ {detail} +
+ )} +
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/hooks/useConnectionPoints.ts b/frontend/src/hooks/useConnectionPoints.ts new file mode 100644 index 00000000..9e02174b --- /dev/null +++ b/frontend/src/hooks/useConnectionPoints.ts @@ -0,0 +1,23 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { apiFetch } from "@/lib/api"; +import type { ConnectionPointsResponse } from "@/types/nspd"; + +export type { ConnectionPointsResponse }; + +export function useConnectionPoints( + cadNum: string | null | undefined, + enabled = true, +) { + return useQuery({ + queryKey: ["connection-points", cadNum], + queryFn: () => + apiFetch( + `/api/v1/parcels/${encodeURIComponent(cadNum!)}/connection-points`, + ), + enabled: !!cadNum && enabled, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/frontend/src/types/nspd.ts b/frontend/src/types/nspd.ts new file mode 100644 index 00000000..daf84416 --- /dev/null +++ b/frontend/src/types/nspd.ts @@ -0,0 +1,71 @@ +// NSPD (НСПД) types — Issue #202 +// Shapes derived from backend/app/services/site_finder/quarter_dump_lookup.py +// and backend/app/schemas/parcel.py + +export interface NspdZoning { + zone_code: string | null; + zone_name: string | null; + source?: string; + raw_props?: Record; +} + +export interface NspdZouitOverlap { + group_key: string; // e.g. "engineering", "okn", "natural", "protected", "other" + layer: string; // e.g. "zouit_engineering" + subcategory: string | number | null; + name: string | null; + raw_props?: Record; +} + +export interface NspdEngineeringNearby { + name: string | null; + type: string | null; + distance_m: number | null; + raw_props?: Record; +} + +export interface NspdDumpMeta { + available: boolean; + fetched_at_utc: string | null; // ISO datetime + stale: boolean; + harvest_triggered: boolean; + total_features: number | null; +} + +// Connection-points endpoint shapes (/api/v1/parcels/{cad}/connection-points) + +export interface EngineeringStructure { + name: string | null; + type: string | null; + cad_num: string | null; + distance_to_boundary_m: number; + geometry_geojson: Record; + readable_address: string | null; + raw_props: Record; + source: string; +} + +export interface ZouitEngineeringOverlap { + reg_numb_border: string | null; + type_zone: string | null; + subcategory: number | null; + intersects_parcel: boolean; + geometry_geojson: Record; + raw_props: Record; + source: string; +} + +export interface ConnectionPointsSummary { + nearest_structure_distance_m: number | null; + in_protection_zone: boolean; + protection_zones_intersecting: number; + total_structures_in_radius: number; +} + +export interface ConnectionPointsResponse { + engineering_structures: EngineeringStructure[]; + zouit_engineering_overlaps: ZouitEngineeringOverlap[]; + summary: ConnectionPointsSummary; + dump_available: boolean; + dump_fetched_at: string | null; +} diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 7217fd6b..2af9ee13 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -379,6 +379,11 @@ export interface ParcelAnalysis { gate_verdict?: GateVerdict; // D2 (#34) — velocity-score: темп продаж конкурентов velocity?: Velocity | null; + // Issue #202 — NSPD frontend integration + nspd_zoning?: import("./nspd").NspdZoning | null; + nspd_zouit_overlaps?: import("./nspd").NspdZouitOverlap[]; + nspd_engineering_nearby?: import("./nspd").NspdEngineeringNearby[]; + nspd_dump?: import("./nspd").NspdDumpMeta | null; } export type PoiCategory =