All checks were successful
CI / changes (push) Successful in 8s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (push) Successful in 1m17s
CI / frontend-tests (pull_request) Successful in 1m20s
CI / openapi-codegen-check (push) Successful in 2m13s
CI / openapi-codegen-check (pull_request) Successful in 1m40s
CI / backend-tests (pull_request) Successful in 9m0s
CI / backend-tests (push) Successful in 9m4s
668 lines
18 KiB
TypeScript
668 lines
18 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* Section2NetworksUtilities — "3. Сети и точки подключения"
|
||
*
|
||
* 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 { NspdVerifyLink } from "./NspdVerifyLink";
|
||
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 "transformer":
|
||
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 "gas_pipeline":
|
||
return {
|
||
label: "Газ (газопровод)",
|
||
icon: (
|
||
<Flame
|
||
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_main":
|
||
return {
|
||
label: "Водопровод (магистраль)",
|
||
icon: (
|
||
<Droplet
|
||
size={16}
|
||
strokeWidth={1.5}
|
||
style={{ color: "var(--viz-2)" }}
|
||
/>
|
||
),
|
||
};
|
||
case "water_works":
|
||
return {
|
||
label: "Водопровод (водозабор)",
|
||
icon: (
|
||
<Droplet
|
||
size={16}
|
||
strokeWidth={1.5}
|
||
style={{ color: "var(--viz-2)" }}
|
||
/>
|
||
),
|
||
};
|
||
case "water_tower":
|
||
return {
|
||
label: "Водопровод (водонапорная башня)",
|
||
icon: (
|
||
<Droplet
|
||
size={16}
|
||
strokeWidth={1.5}
|
||
style={{ color: "var(--viz-2)" }}
|
||
/>
|
||
),
|
||
};
|
||
case "storage_tank":
|
||
return {
|
||
label: "Резервуар",
|
||
icon: (
|
||
<Droplet
|
||
size={16}
|
||
strokeWidth={1.5}
|
||
style={{ color: "var(--viz-2)" }}
|
||
/>
|
||
),
|
||
};
|
||
// ── Канализация ──
|
||
case "sewerage":
|
||
return {
|
||
label: "Канализация (коллектор)",
|
||
icon: (
|
||
<Pipette
|
||
size={16}
|
||
strokeWidth={1.5}
|
||
style={{ color: "var(--fg-secondary)" }}
|
||
/>
|
||
),
|
||
};
|
||
case "wastewater_plant":
|
||
return {
|
||
label: "Канализация (очистные)",
|
||
icon: (
|
||
<Pipette
|
||
size={16}
|
||
strokeWidth={1.5}
|
||
style={{ color: "var(--fg-secondary)" }}
|
||
/>
|
||
),
|
||
};
|
||
// ── Теплоснабжение ──
|
||
case "heat_substation":
|
||
return {
|
||
label: "Теплоснабжение (ЦТП)",
|
||
icon: (
|
||
<Flame
|
||
size={16}
|
||
strokeWidth={1.5}
|
||
style={{ color: "var(--viz-4)" }}
|
||
/>
|
||
),
|
||
};
|
||
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 === "transformer" ||
|
||
i.subtype === "power_line",
|
||
);
|
||
const gas = items.find((i) => i.subtype === "gas_pipeline");
|
||
const water = items.find(
|
||
(i) =>
|
||
i.subtype === "water_main" ||
|
||
i.subtype === "water_works" ||
|
||
i.subtype === "water_tower",
|
||
);
|
||
|
||
if (electric)
|
||
parts.push(`электричество ${electric.nearest_m.toLocaleString("ru")} м`);
|
||
if (gas) parts.push(`газ ${gas.nearest_m.toLocaleString("ru")} м`);
|
||
if (water) parts.push(`водопровод ${water.nearest_m.toLocaleString("ru")} м`);
|
||
|
||
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 + verify-link на НСПД (#97) */}
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
gap: 12,
|
||
flexWrap: "wrap",
|
||
margin: "0 0 12px",
|
||
}}
|
||
>
|
||
<h2
|
||
style={{
|
||
margin: 0,
|
||
fontSize: 18,
|
||
fontWeight: 600,
|
||
color: "var(--fg-primary)",
|
||
}}
|
||
>
|
||
2. Сети и точки подключения
|
||
</h2>
|
||
<NspdVerifyLink
|
||
cadNum={cad}
|
||
source="nspd"
|
||
label="Проверить сети на НСПД"
|
||
/>
|
||
</div>
|
||
|
||
{/* 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>
|
||
);
|
||
}
|