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 { 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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
|
|
@ -31,51 +38,49 @@ export function LandTab({ data }: Props) {
|
|||
<GeometrySuitabilityBlock data={data.geometry_suitability} />
|
||||
)}
|
||||
|
||||
{/* Zoning note */}
|
||||
<div
|
||||
style={{
|
||||
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"
|
||||
{/* Issue #202 — Зонирование (ПЗЗ) из НСПД */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "inline-block",
|
||||
marginTop: 10,
|
||||
padding: "6px 14px",
|
||||
background: "#1d4ed8",
|
||||
color: "#fff",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
textDecoration: "none",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
Открыть в ПКК
|
||||
</a>
|
||||
<SectionLabel>Зонирование (ПЗЗ)</SectionLabel>
|
||||
<NspdFreshnessBadge dump={data.nspd_dump} />
|
||||
</div>
|
||||
<NspdZoningBlock
|
||||
data={data.nspd_zoning}
|
||||
dump={data.nspd_dump}
|
||||
cadNum={data.cad_num}
|
||||
/>
|
||||
</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 */}
|
||||
{data.geotech_risk !== undefined && (
|
||||
<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;
|
||||
// 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 =
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue