gendesign/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx
Light1YT d93553c489
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
fix(site-finder): перенумеровать под-подписи Section3 4.1/4.2/4.3 под новый порядок + doc-хедеры (#1741 хвост)
2026-06-18 12:38:54 +05:00

668 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}