gendesign/frontend/src/components/site-finder/analysis/Section2NetworksUtilities.tsx
lekss361 888885b6c2 feat(sf-fe-a6): Section 2 «Сети и точки подключения»
- Add Section2NetworksUtilities component (HeadlineBar + utilities table)
- Map backend utilities.summary subtypes to Lucide icons (Zap/Flame/Droplet/Pipette)
- Presence status badges: 200m / 500m / 1km / далее 1km with semantic colors
- AlertTriangle warning when power_line_охранная_зона_flag is true
- Graceful no-data state when utilities absent from NSPD snapshot
- Add UtilitiesData / UtilitySummaryItem types to ParcelAnalyzeResponse
- Update parcel-analyze.json mock fixture with utilities sample data
- Wire Section2 into analysis/[cad]/page.tsx replacing placeholder
2026-05-18 03:20:42 +03:00

564 lines
15 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 — "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>
);
}