feat(site-finder): NSPD frontend integration в LandTab (#202)
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.
This commit is contained in:
parent
adc45f5724
commit
e5de841f2a
8 changed files with 806 additions and 41 deletions
|
|
@ -7,6 +7,10 @@ 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 { NspdZoningBlock } from "./NspdZoningBlock";
|
||||||
|
import { NspdZouitOverlapsBlock } from "./NspdZouitOverlapsBlock";
|
||||||
|
import { NspdEngineeringNearbyBlock } from "./NspdEngineeringNearbyBlock";
|
||||||
|
import { NspdFreshnessBadge } from "./NspdFreshnessBadge";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: ParcelAnalysis;
|
data: ParcelAnalysis;
|
||||||
|
|
@ -17,7 +21,10 @@ export function LandTab({ data }: Props) {
|
||||||
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 ||
|
||||||
|
data.nspd_zoning !== undefined ||
|
||||||
|
data.nspd_zouit_overlaps !== undefined ||
|
||||||
|
data.nspd_engineering_nearby !== undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||||
|
|
@ -31,51 +38,49 @@ export function LandTab({ data }: Props) {
|
||||||
<GeometrySuitabilityBlock data={data.geometry_suitability} />
|
<GeometrySuitabilityBlock data={data.geometry_suitability} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Zoning note */}
|
{/* Issue #202 — Зонирование (ПЗЗ) из НСПД */}
|
||||||
<div
|
<div>
|
||||||
style={{
|
<div
|
||||||
background: "#f9fafb",
|
|
||||||
border: "1px solid #e5e7eb",
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: "14px 18px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SectionLabel style={{ marginBottom: 8 }}>
|
|
||||||
Зонирование (ПЗЗ)
|
|
||||||
</SectionLabel>
|
|
||||||
<div style={{ fontSize: 13, color: "#6b7280" }}>
|
|
||||||
Данные ПЗЗ доступны через{" "}
|
|
||||||
<a
|
|
||||||
href="https://pkk.rosreestr.ru/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{ color: "#1d4ed8", textDecoration: "none" }}
|
|
||||||
>
|
|
||||||
Публичную кадастровую карту
|
|
||||||
</a>
|
|
||||||
. API Росреестра закрыт в 2024 — используйте deep-link для кад. номера{" "}
|
|
||||||
<strong style={{ color: "#374151" }}>{data.cad_num}</strong>.
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href={`https://pkk.rosreestr.ru/#/search/${encodeURIComponent(data.cad_num)}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{
|
style={{
|
||||||
display: "inline-block",
|
display: "flex",
|
||||||
marginTop: 10,
|
alignItems: "center",
|
||||||
padding: "6px 14px",
|
gap: 8,
|
||||||
background: "#1d4ed8",
|
marginBottom: 10,
|
||||||
color: "#fff",
|
|
||||||
borderRadius: 6,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 500,
|
|
||||||
textDecoration: "none",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Открыть в ПКК
|
<SectionLabel>Зонирование (ПЗЗ)</SectionLabel>
|
||||||
</a>
|
<NspdFreshnessBadge dump={data.nspd_dump} />
|
||||||
|
</div>
|
||||||
|
<NspdZoningBlock
|
||||||
|
data={data.nspd_zoning}
|
||||||
|
dump={data.nspd_dump}
|
||||||
|
cadNum={data.cad_num}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Issue #202 — ЗОУИТ пересечения */}
|
||||||
|
{data.nspd_zouit_overlaps !== undefined && (
|
||||||
|
<div>
|
||||||
|
<SectionLabel style={{ marginBottom: 10 }}>
|
||||||
|
Зоны с особыми условиями использования (ЗОУИТ)
|
||||||
|
</SectionLabel>
|
||||||
|
<NspdZouitOverlapsBlock overlaps={data.nspd_zouit_overlaps ?? []} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Issue #202 — Инженерные объекты в 200 м */}
|
||||||
|
{data.nspd_engineering_nearby !== undefined && (
|
||||||
|
<div>
|
||||||
|
<SectionLabel style={{ marginBottom: 10 }}>
|
||||||
|
Инженерные объекты вблизи участка
|
||||||
|
</SectionLabel>
|
||||||
|
<NspdEngineeringNearbyBlock
|
||||||
|
nearby={data.nspd_engineering_nearby ?? []}
|
||||||
|
cadNum={data.cad_num}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Geotech risk */}
|
{/* Geotech risk */}
|
||||||
{data.geotech_risk !== undefined && (
|
{data.geotech_risk !== undefined && (
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -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<string>();
|
||||||
|
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 (
|
||||||
|
<div style={{ fontSize: 13, color: "#6b7280", fontStyle: "italic" }}>
|
||||||
|
Инженерных объектов в 200 м не найдено
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{deduped.length > 0 && (
|
||||||
|
<table
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "4px 8px",
|
||||||
|
color: "#6b7280",
|
||||||
|
fontWeight: 600,
|
||||||
|
borderBottom: "1px solid #e5e7eb",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Объект
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "4px 8px",
|
||||||
|
color: "#6b7280",
|
||||||
|
fontWeight: 600,
|
||||||
|
borderBottom: "1px solid #e5e7eb",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Тип
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
style={{
|
||||||
|
textAlign: "right",
|
||||||
|
padding: "4px 8px",
|
||||||
|
color: "#6b7280",
|
||||||
|
fontWeight: 600,
|
||||||
|
borderBottom: "1px solid #e5e7eb",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Расстояние
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{deduped.map((row, idx) => (
|
||||||
|
<tr
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
borderBottom:
|
||||||
|
idx < deduped.length - 1 ? "1px solid #f3f4f6" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "5px 8px",
|
||||||
|
color: "#1f2937",
|
||||||
|
maxWidth: 180,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.label}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "5px 8px",
|
||||||
|
color: "#6b7280",
|
||||||
|
maxWidth: 140,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.type ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "5px 8px",
|
||||||
|
color: "#374151",
|
||||||
|
textAlign: "right",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Math.round(row.distanceM)} м
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cpLoading && (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, color: "#9ca3af" }}>
|
||||||
|
Загружаем данные точек подключения…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{cpData?.summary && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 10,
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cpData.summary.in_protection_zone && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: "#fee2e2",
|
||||||
|
color: "#991b1b",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "2px 10px",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
В охранной зоне
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{cpData.summary.nearest_structure_distance_m !== null && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: "#f3f4f6",
|
||||||
|
color: "#374151",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "2px 10px",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ближайший:{" "}
|
||||||
|
{Math.round(cpData.summary.nearest_structure_distance_m)} м
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
frontend/src/components/site-finder/NspdFreshnessBadge.tsx
Normal file
110
frontend/src/components/site-finder/NspdFreshnessBadge.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
background: "#f3f4f6",
|
||||||
|
color: "#6b7280",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "1px 8px",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
title="Данные НСПД недоступны"
|
||||||
|
>
|
||||||
|
НСПД: нет данных
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dump.harvest_triggered && !dump.available) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
background: "#dbeafe",
|
||||||
|
color: "#1e40af",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "1px 8px",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
title="Загрузка данных НСПД запущена"
|
||||||
|
>
|
||||||
|
НСПД: загрузка…
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dump.available) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
background: "#f3f4f6",
|
||||||
|
color: "#6b7280",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "1px 8px",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
title="Дамп НСПД для квартала не загружен"
|
||||||
|
>
|
||||||
|
НСПД: нет дампа
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateStr = dump.fetched_at_utc
|
||||||
|
? new Date(dump.fetched_at_utc).toLocaleDateString("ru-RU")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (dump.stale) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
background: "#fef3c7",
|
||||||
|
color: "#92400e",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "1px 8px",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
dateStr
|
||||||
|
? `Данные от ${dateStr} (устаревшие)`
|
||||||
|
: "Устаревшие данные НСПД"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
НСПД: устарело{dateStr ? ` (${dateStr})` : ""}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
background: "#dcfce7",
|
||||||
|
color: "#15803d",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "1px 8px",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
title={dateStr ? `Данные НСПД от ${dateStr}` : "Данные НСПД актуальны"}
|
||||||
|
>
|
||||||
|
НСПД: актуально{dateStr ? ` (${dateStr})` : ""}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
216
frontend/src/components/site-finder/NspdZoningBlock.tsx
Normal file
216
frontend/src/components/site-finder/NspdZoningBlock.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#eff6ff",
|
||||||
|
border: "1px solid #bfdbfe",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 18px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "#3b82f6",
|
||||||
|
animation: "pulse 1.5s infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 13, color: "#1d4ed8", fontWeight: 500 }}>
|
||||||
|
Загружаем данные НСПД, 15–30 с…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 12,
|
||||||
|
background: "#bfdbfe",
|
||||||
|
borderRadius: 4,
|
||||||
|
width: "60%",
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 12,
|
||||||
|
background: "#bfdbfe",
|
||||||
|
borderRadius: 4,
|
||||||
|
width: "40%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data not available, no harvest triggered
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#f9fafb",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 18px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 13, color: "#6b7280" }}>
|
||||||
|
Данные ПЗЗ из НСПД недоступны. Используйте{" "}
|
||||||
|
<a
|
||||||
|
href={`https://pkk.rosreestr.ru/#/search/${encodeURIComponent(cadNum)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: "#1d4ed8", textDecoration: "none" }}
|
||||||
|
>
|
||||||
|
Публичную кадастровую карту
|
||||||
|
</a>{" "}
|
||||||
|
для кад. номера <strong style={{ color: "#374151" }}>{cadNum}</strong>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`https://pkk.rosreestr.ru/#/search/${encodeURIComponent(cadNum)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
marginTop: 10,
|
||||||
|
padding: "6px 14px",
|
||||||
|
background: "#1d4ed8",
|
||||||
|
color: "#fff",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
textDecoration: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Открыть в ПКК
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#f9fafb",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 18px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.zone_code && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: "#dbeafe",
|
||||||
|
color: "#1e40af",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "2px 10px",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.zone_code}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{data.zone_name && (
|
||||||
|
<span style={{ fontSize: 13, color: "#374151" }}>
|
||||||
|
{data.zone_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!data.zone_code && !data.zone_name && (
|
||||||
|
<span style={{ fontSize: 13, color: "#6b7280" }}>
|
||||||
|
Зона не определена
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.raw_props && Object.keys(data.raw_props).length > 0 && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#6b7280",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: 0,
|
||||||
|
textDecoration: "underline",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expanded ? "Скрыть свойства" : "Показать все свойства НСПД"}
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
background: "#f3f4f6",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "8px 12px",
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#374151",
|
||||||
|
maxHeight: 200,
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.entries(data.raw_props).map(([k, v]) => (
|
||||||
|
<div key={k} style={{ marginBottom: 2 }}>
|
||||||
|
<span style={{ color: "#6b7280" }}>{k}: </span>
|
||||||
|
<span>{String(v ?? "—")}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dump?.fetched_at_utc && (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 11, color: "#9ca3af" }}>
|
||||||
|
НСПД: данные от{" "}
|
||||||
|
{new Date(dump.fetched_at_utc).toLocaleDateString("ru-RU")}
|
||||||
|
{dump.stale && (
|
||||||
|
<span style={{ color: "#f59e0b", marginLeft: 4 }}>
|
||||||
|
(устаревшие)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
frontend/src/components/site-finder/NspdZouitOverlapsBlock.tsx
Normal file
124
frontend/src/components/site-finder/NspdZouitOverlapsBlock.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
background: "#f0fdf4",
|
||||||
|
border: "1px solid #bbf7d0",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 14px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: "#dcfce7",
|
||||||
|
color: "#15803d",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "2px 10px",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Нет пересечений
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 13, color: "#15803d" }}>
|
||||||
|
Охранных зон ЗОУИТ не обнаружено
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
background: "#f9fafb",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 14px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: style.bg,
|
||||||
|
color: style.color,
|
||||||
|
borderRadius: 5,
|
||||||
|
padding: "2px 8px",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{style.label.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500, color: "#1f2937" }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{detail && (
|
||||||
|
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 2 }}>
|
||||||
|
{detail}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
frontend/src/hooks/useConnectionPoints.ts
Normal file
23
frontend/src/hooks/useConnectionPoints.ts
Normal file
|
|
@ -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<ConnectionPointsResponse>({
|
||||||
|
queryKey: ["connection-points", cadNum],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<ConnectionPointsResponse>(
|
||||||
|
`/api/v1/parcels/${encodeURIComponent(cadNum!)}/connection-points`,
|
||||||
|
),
|
||||||
|
enabled: !!cadNum && enabled,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
71
frontend/src/types/nspd.ts
Normal file
71
frontend/src/types/nspd.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NspdEngineeringNearby {
|
||||||
|
name: string | null;
|
||||||
|
type: string | null;
|
||||||
|
distance_m: number | null;
|
||||||
|
raw_props?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
readable_address: string | null;
|
||||||
|
raw_props: Record<string, unknown>;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZouitEngineeringOverlap {
|
||||||
|
reg_numb_border: string | null;
|
||||||
|
type_zone: string | null;
|
||||||
|
subcategory: number | null;
|
||||||
|
intersects_parcel: boolean;
|
||||||
|
geometry_geojson: Record<string, unknown>;
|
||||||
|
raw_props: Record<string, unknown>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -379,6 +379,11 @@ export interface ParcelAnalysis {
|
||||||
gate_verdict?: GateVerdict;
|
gate_verdict?: GateVerdict;
|
||||||
// D2 (#34) — velocity-score: темп продаж конкурентов
|
// D2 (#34) — velocity-score: темп продаж конкурентов
|
||||||
velocity?: Velocity | null;
|
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 =
|
export type PoiCategory =
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue