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 */}
-
+ {/* 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 (
+
+ );
+ }
+
+ 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 =