feat(sf-fe-a6): Section 2 «Сети и точки подключения» — real B5 utilities data (#354)
All checks were successful
Deploy / deploy (push) Successful in 41s
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m44s

This commit is contained in:
lekss361 2026-05-18 00:27:55 +00:00
parent da8847c0e2
commit 307a75f333
4 changed files with 616 additions and 6 deletions

View file

@ -7,6 +7,7 @@ import { AnalysisSidebar } from "@/components/site-finder/analysis/AnalysisSideb
import { AnalysisBreadcrumb } from "@/components/site-finder/analysis/AnalysisBreadcrumb";
import { UserAvatar } from "@/components/site-finder/analysis/UserAvatar";
import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo";
import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities";
// ── Section placeholder (Wave 34 refactor will replace these) ─────────────────
@ -133,12 +134,8 @@ export default function AnalysisPage({ params }: PageProps) {
{/* Section 1 — A5: Инфо об участке */}
<Section1ParcelInfo cad={cad} />
{/* Section 2 — TODO A6 */}
<SectionPlaceholder
id="section-2"
title="2. Земля и риски"
note="TODO A6: reuse NspdEngineeringNearbyBlock в новом layout headline+map"
/>
{/* Section 2 — A6: Сети и точки подключения */}
<Section2NetworksUtilities cad={cad} />
{/* Section 3 — TODO A7/A8/A9 */}
<SectionPlaceholder

View file

@ -0,0 +1,564 @@
"use client";
/**
* Section2NetworksUtilities "2. Сети и точки подключения"
*
* Layout:
* HeadlineBar (title «Сети» + subtitle с расстояниями)
* Utilities table: 4 rows × [Иконка / Тип / Расстояние / Количество / Статус]
* Warning banner if power_line_охранная_зона_flag === true
* Note (caveat text)
*
* Data: useParcelAnalyzeQuery (B5) field `utilities`.
* If utilities absent from response shows "нет данных" state gracefully.
*/
import type { ReactNode } from "react";
import {
Zap,
Flame,
Droplet,
Pipette,
AlertTriangle,
Info,
} from "lucide-react";
import { HeadlineBar } from "@/components/ui/HeadlineBar";
import { useParcelAnalyzeQuery } from "@/lib/site-finder-api";
import type { UtilitySummaryItem } from "@/lib/site-finder-api";
// ── Subtype display config ────────────────────────────────────────────────────
interface UtilityDisplayConfig {
label: string;
icon: ReactNode;
}
function getUtilityConfig(subtype: string): UtilityDisplayConfig {
switch (subtype) {
case "substation":
return {
label: "Электричество (подстанция)",
icon: (
<Zap size={16} strokeWidth={1.5} style={{ color: "var(--viz-4)" }} />
),
};
case "power_line":
return {
label: "Электричество (ЛЭП)",
icon: (
<Zap size={16} strokeWidth={1.5} style={{ color: "var(--viz-4)" }} />
),
};
case "pipeline":
return {
label: "Газ (газопровод)",
icon: (
<Flame
size={16}
strokeWidth={1.5}
style={{ color: "var(--viz-4)" }}
/>
),
};
case "water_intake":
return {
label: "Водопровод",
icon: (
<Droplet
size={16}
strokeWidth={1.5}
style={{ color: "var(--viz-2)" }}
/>
),
};
case "pumping_station":
return {
label: "Канализация (насосная)",
icon: (
<Pipette
size={16}
strokeWidth={1.5}
style={{ color: "var(--fg-secondary)" }}
/>
),
};
default:
return {
label: subtype,
icon: (
<Info
size={16}
strokeWidth={1.5}
style={{ color: "var(--fg-tertiary)" }}
/>
),
};
}
}
// ── Distance → presence status ────────────────────────────────────────────────
type PresenceStatus = "close" | "reachable" | "far" | "absent";
function getPresenceStatus(nearestM: number): PresenceStatus {
if (nearestM <= 200) return "close";
if (nearestM <= 500) return "reachable";
if (nearestM <= 1000) return "far";
return "absent";
}
const PRESENCE_LABEL: Record<PresenceStatus, string> = {
close: "В радиусе 200 м",
reachable: "В радиусе 500 м",
far: "В радиусе 1 км",
absent: "Далее 1 км",
};
const PRESENCE_COLOR: Record<PresenceStatus, string> = {
close: "var(--success)",
reachable: "var(--accent)",
far: "var(--warn)",
absent: "var(--danger)",
};
const PRESENCE_BG: Record<PresenceStatus, string> = {
close: "var(--success-soft)",
reachable: "var(--accent-soft)",
far: "var(--warn-soft)",
absent: "var(--danger-soft)",
};
// ── HeadlineBar subtitle helper ───────────────────────────────────────────────
function buildSubtitle(items: UtilitySummaryItem[]): string {
const parts: string[] = [];
const electric = items.find(
(i) => i.subtype === "substation" || i.subtype === "power_line",
);
const gas = items.find((i) => i.subtype === "pipeline");
const water = items.find((i) => i.subtype === "water_intake");
if (electric) parts.push(`электричество ${electric.nearest_m} м`);
if (gas) parts.push(`газ ${gas.nearest_m} м`);
if (water) parts.push(`водопровод ${water.nearest_m} м`);
return parts.length > 0
? parts.join(" · ")
: "данные из НСПД об инженерной инфраструктуре";
}
// ── Loading skeleton ──────────────────────────────────────────────────────────
function Section2Skeleton() {
return (
<section
id="section-2"
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: 20,
marginBottom: 24,
}}
>
<div
style={{
height: 56,
background: "var(--bg-card-alt)",
borderRadius: 12,
marginBottom: 16,
}}
/>
<div
style={{
height: 180,
background: "var(--bg-card-alt)",
borderRadius: 10,
}}
/>
</section>
);
}
// ── No-data state ─────────────────────────────────────────────────────────────
function Section2NoData() {
return (
<section
id="section-2"
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: 20,
marginBottom: 24,
}}
>
<h2
style={{
margin: "0 0 12px",
fontSize: 18,
fontWeight: 600,
color: "var(--fg-primary)",
}}
>
2. Сети и точки подключения
</h2>
<div
style={{
padding: "20px 16px",
background: "var(--bg-card-alt)",
border: "1px dashed var(--border-strong)",
borderRadius: 10,
display: "flex",
alignItems: "center",
gap: 8,
color: "var(--fg-tertiary)",
fontSize: 13,
}}
>
<Info size={16} strokeWidth={1.5} style={{ flexShrink: 0 }} />
Данные НСПД об инженерной инфраструктуре не получены для этого участка.
Проверьте наличие NSPD-снимка в базе.
</div>
</section>
);
}
// ── Error state ───────────────────────────────────────────────────────────────
function Section2Error({ message }: { message: string }) {
return (
<section
id="section-2"
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: 20,
marginBottom: 24,
}}
>
<h2
style={{
margin: "0 0 8px",
fontSize: 18,
fontWeight: 600,
color: "var(--fg-primary)",
}}
>
2. Сети и точки подключения
</h2>
<p
style={{ margin: 0, fontSize: 13, color: "var(--danger)" }}
role="alert"
>
{message}
</p>
</section>
);
}
// ── Utilities table ───────────────────────────────────────────────────────────
interface UtilitiesTableProps {
items: UtilitySummaryItem[];
}
function UtilitiesTable({ items }: UtilitiesTableProps) {
return (
<div
style={{
border: "1px solid var(--border-card)",
borderRadius: 10,
overflow: "hidden",
}}
>
{/* Table header */}
<div
style={{
display: "grid",
gridTemplateColumns: "32px 1fr 120px 110px 140px",
gap: 0,
background: "var(--bg-card-alt)",
borderBottom: "1px solid var(--border-soft)",
padding: "8px 16px",
alignItems: "center",
}}
>
<div />
<div
style={{
fontSize: 11,
fontWeight: 600,
color: "var(--fg-secondary)",
textTransform: "uppercase",
letterSpacing: "0.04em",
}}
>
Тип объекта
</div>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: "var(--fg-secondary)",
textTransform: "uppercase",
letterSpacing: "0.04em",
textAlign: "right",
}}
>
Ближайший
</div>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: "var(--fg-secondary)",
textTransform: "uppercase",
letterSpacing: "0.04em",
textAlign: "right",
}}
>
В 2 км
</div>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: "var(--fg-secondary)",
textTransform: "uppercase",
letterSpacing: "0.04em",
textAlign: "right",
}}
>
Статус
</div>
</div>
{/* Table rows */}
{items.map((item, idx) => {
const config = getUtilityConfig(item.subtype);
const status = getPresenceStatus(item.nearest_m);
const isLast = idx === items.length - 1;
return (
<div
key={`${item.subtype}-${idx}`}
style={{
display: "grid",
gridTemplateColumns: "32px 1fr 120px 110px 140px",
gap: 0,
padding: "10px 16px",
alignItems: "center",
borderBottom: isLast ? "none" : "1px solid var(--border-soft)",
background: "var(--bg-card)",
}}
>
{/* Icon */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
}}
>
{config.icon}
</div>
{/* Label */}
<div
style={{
fontSize: 14,
color: "var(--fg-primary)",
fontWeight: 400,
}}
>
{config.label}
{item.name && (
<span
style={{
display: "block",
fontSize: 12,
color: "var(--fg-tertiary)",
marginTop: 2,
}}
>
{item.name}
</span>
)}
</div>
{/* Nearest distance */}
<div
style={{
fontSize: 14,
fontVariantNumeric: "tabular-nums",
color: "var(--fg-primary)",
textAlign: "right",
}}
>
{item.nearest_m.toLocaleString("ru")} м
</div>
{/* Count within 2km */}
<div
style={{
fontSize: 14,
fontVariantNumeric: "tabular-nums",
color: "var(--fg-secondary)",
textAlign: "right",
}}
>
{item.count_within_2km} шт.
</div>
{/* Status badge */}
<div style={{ textAlign: "right" }}>
<span
style={{
display: "inline-block",
padding: "2px 8px",
borderRadius: 6,
fontSize: 12,
fontWeight: 500,
color: PRESENCE_COLOR[status],
background: PRESENCE_BG[status],
}}
>
{PRESENCE_LABEL[status]}
</span>
</div>
</div>
);
})}
</div>
);
}
// ── Main component ────────────────────────────────────────────────────────────
interface Props {
cad: string;
}
export function Section2NetworksUtilities({ cad }: Props) {
const { isLoading, isError, error, data } = useParcelAnalyzeQuery(cad);
if (isLoading) return <Section2Skeleton />;
if (isError || !data) {
return (
<Section2Error
message={
error instanceof Error
? error.message
: "Не удалось загрузить данные участка"
}
/>
);
}
const utilities = data.utilities;
// No utilities data in response (NSPD snapshot absent)
if (!utilities || utilities.summary.length === 0) {
return <Section2NoData />;
}
const {
summary,
power_line_охранная_зона_flag: powerLineZone,
note,
} = utilities;
// HeadlineBar subtitle: list distances for key utility types
const headlineSubtitle = buildSubtitle(summary);
return (
<section
id="section-2"
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: 20,
marginBottom: 24,
}}
>
{/* Section title */}
<h2
style={{
margin: "0 0 12px",
fontSize: 18,
fontWeight: 600,
color: "var(--fg-primary)",
}}
>
2. Сети и точки подключения
</h2>
{/* HeadlineBar */}
<div style={{ marginBottom: 16 }}>
<HeadlineBar title="Сети" subtitle={headlineSubtitle} />
</div>
{/* Power line охранная зона warning */}
{powerLineZone && (
<div
role="alert"
style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
padding: "12px 16px",
background: "var(--danger-soft)",
border: "1px solid var(--danger)",
borderRadius: 10,
marginBottom: 16,
fontSize: 13,
color: "var(--danger)",
}}
>
<AlertTriangle
size={16}
strokeWidth={1.5}
style={{ flexShrink: 0, marginTop: 1 }}
/>
<span>
Участок находится в охранной зоне ЛЭП 35 кВ. Капитальное
строительство в ОЗ запрещено (СП 36.13330, ЗОУИТ тип 5).
</span>
</div>
)}
{/* Utilities table */}
<UtilitiesTable items={summary} />
{/* Caveat note */}
{note && (
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 8,
marginTop: 12,
padding: "10px 12px",
background: "var(--bg-card-alt)",
borderRadius: 8,
fontSize: 12,
color: "var(--fg-secondary)",
}}
>
<Info
size={14}
strokeWidth={1.5}
style={{ flexShrink: 0, marginTop: 1, color: "var(--fg-tertiary)" }}
/>
<span>{note}</span>
</div>
)}
</section>
);
}

View file

@ -112,5 +112,29 @@
"encumbrance": "Нет",
"status": "Учтённый",
"last_updated": "2025-10-01"
},
"utilities": {
"summary": [
{
"subtype": "substation",
"nearest_m": 59,
"name": null,
"count_within_2km": 7
},
{
"subtype": "pipeline",
"nearest_m": 320,
"name": null,
"count_within_2km": 1
},
{
"subtype": "power_line",
"nearest_m": 630,
"name": null,
"count_within_2km": 2
}
],
"power_line_охранная_зона_flag": false,
"note": "Охранная зона ЛЭП ≥35 кВ — 15-40м по обе стороны (СП 36.13330). В зоне ОЗ нельзя строить капитальные объекты."
}
}

View file

@ -254,6 +254,29 @@ export interface ParcelEgrn {
last_updated: string | null;
}
// ── Types: utilities (engineering nearby) ────────────────────────────────────
export type UtilitySubtype =
| "substation"
| "pipeline"
| "power_line"
| "water_intake"
| "pumping_station"
| string; // open for future subtypes
export interface UtilitySummaryItem {
subtype: UtilitySubtype;
nearest_m: number;
name: string | null;
count_within_2km: number;
}
export interface UtilitiesData {
summary: UtilitySummaryItem[];
power_line_охранная_зона_flag: boolean;
note: string | null;
}
export interface ParcelAnalyzeResponse {
cad_num: string;
source: string;
@ -279,6 +302,8 @@ export interface ParcelAnalyzeResponse {
poi_count: number;
/** EGRN data — may be null if B5 extended not yet deployed */
egrn?: ParcelEgrn | null;
/** Engineering / utilities nearby — present when NSPD data available */
utilities?: UtilitiesData | null;
}
// ── Types: B6 poi-score response ─────────────────────────────────────────────