601 lines
19 KiB
TypeScript
601 lines
19 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* ResourceReservesCard — «Ресурсные резервы» для §3 «Сети» (#connection-capacity).
|
||
*
|
||
* Показывает свободную мощность для технологического присоединения (ТП) из
|
||
* ОБЯЗАТЕЛЬНОГО раскрытия сетевых организаций:
|
||
* • Электричество — ближайший центр питания (ЦП, ПС 35/110 кВ) Россетей со
|
||
* свободной мощностью + бейдж статуса по индексу загрузки (open/limited/closed).
|
||
* • Вода/канализация — резервы ЦСВ/ЦСК Водоканала: городская система отдельной
|
||
* строкой (ОТРИЦАТЕЛЬНЫЙ резерв = дефицит, красный бейдж), посёлковые —
|
||
* свёрнуты в details.
|
||
*
|
||
* Числа приводятся через toFiniteNumber (Pydantic Decimal→str). Пока лоадеры
|
||
* бэка не отработали, показываем нейтральный «загружаются», а не «нет данных».
|
||
*/
|
||
|
||
import { Info, Zap, Droplets, Flame, Thermometer } from "lucide-react";
|
||
|
||
import type {
|
||
ConnectionCapacityResponse,
|
||
GasCapacityBlock,
|
||
GasGrsRow,
|
||
HeatCapacityBlock,
|
||
HeatSystemRow,
|
||
WaterCapacityRow,
|
||
} from "@/hooks/useConnectionCapacity";
|
||
import { CONNECTION_CAPACITY_RADIUS_M } from "@/hooks/useConnectionCapacity";
|
||
import {
|
||
LOAD_INDEX_BADGE,
|
||
classifyLoadIndex,
|
||
formatGcalHour,
|
||
formatMeters,
|
||
formatMva,
|
||
formatThousandM3Day,
|
||
formatThousandM3Hour,
|
||
humanizeLoadIndex,
|
||
pickCitySystem,
|
||
pluralizeSystems,
|
||
toFiniteNumber,
|
||
} from "@/components/site-finder/connection-capacity";
|
||
|
||
const RADIUS_KM = CONNECTION_CAPACITY_RADIUS_M / 1000;
|
||
|
||
// Semantic-бейдж (резерв/дефицит) — token-палитра карточек (не карта).
|
||
const RESERVE_BADGE = {
|
||
reserve: {
|
||
label: "резерв",
|
||
bg: "var(--success-soft)",
|
||
fg: "var(--success)",
|
||
},
|
||
deficit: {
|
||
label: "дефицит",
|
||
bg: "var(--danger-soft)",
|
||
fg: "var(--danger)",
|
||
},
|
||
depleted: {
|
||
label: "исчерпана",
|
||
bg: "var(--danger-soft)",
|
||
fg: "var(--danger)",
|
||
},
|
||
} as const;
|
||
|
||
function StatusBadge({
|
||
label,
|
||
bg,
|
||
fg,
|
||
border,
|
||
}: {
|
||
label: string;
|
||
bg: string;
|
||
fg: string;
|
||
border?: string;
|
||
}) {
|
||
return (
|
||
<span
|
||
style={{
|
||
display: "inline-block",
|
||
padding: "1px 8px",
|
||
borderRadius: 6,
|
||
fontSize: 12,
|
||
fontWeight: 500,
|
||
color: fg,
|
||
background: bg,
|
||
border: border ? `1px solid ${border}` : undefined,
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// ── Электричество ──────────────────────────────────────────────────────────────
|
||
|
||
function PowerRow({ data }: { data: ConnectionCapacityResponse }) {
|
||
const nearest = data.power_summary.nearest_with_reserve;
|
||
|
||
if (!nearest) {
|
||
return (
|
||
<div style={{ fontSize: 13, color: "var(--fg-secondary)" }}>
|
||
Данные о резервах ПС загружаются.
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const idx = classifyLoadIndex(nearest.load_index);
|
||
const badge = idx ? LOAD_INDEX_BADGE[idx] : null;
|
||
const humanIdx = humanizeLoadIndex(nearest.load_index);
|
||
|
||
const voltage = nearest.voltage_class?.trim() || null;
|
||
const reserve = formatMva(nearest.reserve_mva, nearest.reserve_unit);
|
||
const distance = formatMeters(nearest.distance_m);
|
||
const district = nearest.district?.trim() || null;
|
||
|
||
const parts: string[] = [];
|
||
if (voltage) parts.push(`${voltage} кВ`);
|
||
if (reserve) parts.push(`резерв ${reserve}`);
|
||
if (humanIdx) parts.push(humanIdx);
|
||
if (district) parts.push(`район ${district}`);
|
||
if (distance) parts.push(distance);
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "flex-start",
|
||
gap: 8,
|
||
flexWrap: "wrap",
|
||
}}
|
||
>
|
||
<span style={{ fontSize: 13, color: "var(--fg-primary)" }}>
|
||
Ближайший центр питания: <strong>{nearest.name}</strong>
|
||
{parts.length > 0 ? ` · ${parts.join(" · ")}` : ""}
|
||
</span>
|
||
{badge && (
|
||
<StatusBadge
|
||
label={badge.label}
|
||
bg={badge.bg}
|
||
fg={badge.fg}
|
||
border={badge.border}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Вода / канализация ─────────────────────────────────────────────────────────
|
||
|
||
function WaterSystemLine({
|
||
label,
|
||
row,
|
||
}: {
|
||
label: string;
|
||
row: WaterCapacityRow;
|
||
}) {
|
||
const reserveNum = toFiniteNumber(row.reserve_thousand_m3_day);
|
||
const reserveStr = formatThousandM3Day(row.reserve_thousand_m3_day);
|
||
const isDeficit = reserveNum !== null && reserveNum < 0;
|
||
const badge = isDeficit ? RESERVE_BADGE.deficit : RESERVE_BADGE.reserve;
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 4,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
flexWrap: "wrap",
|
||
}}
|
||
>
|
||
<span style={{ fontSize: 13, color: "var(--fg-primary)" }}>
|
||
{label}:{" "}
|
||
{reserveStr ? (
|
||
<strong
|
||
style={{
|
||
color: isDeficit ? "var(--danger)" : "var(--fg-primary)",
|
||
}}
|
||
>
|
||
{reserveStr}
|
||
</strong>
|
||
) : (
|
||
<span style={{ color: "var(--fg-tertiary)" }}>—</span>
|
||
)}
|
||
</span>
|
||
{reserveNum !== null && (
|
||
<StatusBadge label={badge.label} bg={badge.bg} fg={badge.fg} />
|
||
)}
|
||
</div>
|
||
{row.note && (
|
||
<span style={{ fontSize: 12, color: "var(--fg-secondary)" }}>
|
||
{row.note}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function WaterBlock({ rows }: { rows: WaterCapacityRow[] }) {
|
||
if (rows.length === 0) {
|
||
return (
|
||
<div style={{ fontSize: 13, color: "var(--fg-secondary)" }}>
|
||
Данные о резервах водоснабжения загружаются.
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Разделяем водоснабжение (ЦСВ, system_kind="water") и водоотведение
|
||
// (ЦСК, system_kind="sewerage") — значения из бэка (vodokanal_reserve_loader).
|
||
const supply = rows.filter((r) => r.system_kind === "water");
|
||
const sewage = rows.filter((r) => r.system_kind === "sewerage");
|
||
const other = rows.filter(
|
||
(r) => r.system_kind !== "water" && r.system_kind !== "sewerage",
|
||
);
|
||
|
||
const citySupply = pickCitySystem(supply);
|
||
const citySewage = pickCitySystem(sewage);
|
||
|
||
// Остальные (посёлковые) системы — свернуть в details. Городские строки уже
|
||
// показаны отдельно, поэтому исключаем их из «прочих».
|
||
const rest = [
|
||
...(citySupply ? supply.filter((r) => r !== citySupply) : supply),
|
||
...(citySewage ? sewage.filter((r) => r !== citySewage) : sewage),
|
||
...other,
|
||
];
|
||
|
||
// Источник-подпись: период из данных (первый ненулевой).
|
||
const period = rows.find((r) => r.period)?.period?.trim() || null;
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||
{citySupply && (
|
||
<WaterSystemLine label="Водоснабжение (город)" row={citySupply} />
|
||
)}
|
||
{citySewage && (
|
||
<WaterSystemLine label="Водоотведение (город)" row={citySewage} />
|
||
)}
|
||
|
||
{rest.length > 0 && (
|
||
<details>
|
||
<summary
|
||
style={{
|
||
fontSize: 12,
|
||
color: "var(--accent)",
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
+ {pluralizeSystems(rest.length)}
|
||
</summary>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 6,
|
||
marginTop: 6,
|
||
}}
|
||
>
|
||
{rest.map((r, i) => (
|
||
<WaterSystemLine
|
||
key={`${r.system_kind}-${r.system_name}-${i}`}
|
||
label={r.system_name || "Система"}
|
||
row={r}
|
||
/>
|
||
))}
|
||
</div>
|
||
</details>
|
||
)}
|
||
|
||
<span style={{ fontSize: 12, color: "var(--fg-tertiary)" }}>
|
||
Раскрытие МУП «Водоканал»{period ? ` · ${period}` : ""}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Газ ────────────────────────────────────────────────────────────────────────
|
||
|
||
function GasGrsLine({ row }: { row: GasGrsRow }) {
|
||
const freeNum = toFiniteNumber(row.free_capacity_th_m3_h);
|
||
const freeStr = formatThousandM3Hour(row.free_capacity_th_m3_h);
|
||
const designStr = formatThousandM3Hour(row.design_capacity_th_m3_h);
|
||
const isDepleted = freeNum !== null && freeNum <= 0;
|
||
const badge = isDepleted ? RESERVE_BADGE.depleted : RESERVE_BADGE.reserve;
|
||
const upgrade = row.upgrade_due?.trim() || null;
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
flexWrap: "wrap",
|
||
}}
|
||
>
|
||
<span style={{ fontSize: 13, color: "var(--fg-primary)" }}>
|
||
<strong>{row.grs_name}</strong>: свободно{" "}
|
||
{freeStr ? (
|
||
<strong
|
||
style={{
|
||
color: isDepleted ? "var(--danger)" : "var(--fg-primary)",
|
||
}}
|
||
>
|
||
{freeStr}
|
||
</strong>
|
||
) : (
|
||
<span style={{ color: "var(--fg-tertiary)" }}>—</span>
|
||
)}
|
||
{designStr ? ` из ${designStr}` : ""}
|
||
{row.outputs_count > 1 && (
|
||
<span style={{ color: "var(--fg-tertiary)" }}>
|
||
{" "}
|
||
({row.outputs_count} выхода)
|
||
</span>
|
||
)}
|
||
{upgrade && (
|
||
<span style={{ color: "var(--fg-tertiary)" }}>
|
||
{" "}
|
||
· расшивка: {upgrade}
|
||
</span>
|
||
)}
|
||
</span>
|
||
<StatusBadge label={badge.label} bg={badge.bg} fg={badge.fg} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Строка-сводка точек выхода газосети по городу (форма 6 Екатеринбурггаза,
|
||
// последний срез). Отдельная единица (млн м³/мес) от ГРС (тыс. м³/ч) — поэтому
|
||
// caveat про источник/единицу; счётчики city-level, не в радиусе участка.
|
||
function GasOutletsSummaryLine({ gas }: { gas: GasCapacityBlock }) {
|
||
const total = gas.outlets_total ?? 0;
|
||
if (total === 0) return null;
|
||
|
||
const deficit = gas.outlets_deficit ?? 0;
|
||
const needsCalc = gas.outlets_needs_calc ?? 0;
|
||
|
||
return (
|
||
<span
|
||
title="Форма 6 АО «Екатеринбурггаз» (ФАС №960), млн м³/мес за последний месяц"
|
||
style={{ fontSize: 12, color: "var(--fg-secondary)", cursor: "help" }}
|
||
>
|
||
Точки выхода газосети (город): {total.toLocaleString("ru-RU")} · дефицит{" "}
|
||
{deficit.toLocaleString("ru-RU")} · требуют гидрорасчёта{" "}
|
||
{needsCalc.toLocaleString("ru-RU")}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function GasBlock({ gas }: { gas: GasCapacityBlock | null | undefined }) {
|
||
// Нет ни ГРС-строк, ни счётчиков точек выхода → данные ещё грузятся.
|
||
if (!gas || (gas.city_grs.length === 0 && (gas.outlets_total ?? 0) === 0)) {
|
||
return (
|
||
<div style={{ fontSize: 13, color: "var(--fg-secondary)" }}>
|
||
Данные о загрузке ГРС загружаются.
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const totalNum = toFiniteNumber(gas.total_free_th_m3_h);
|
||
const totalStr = formatThousandM3Hour(gas.total_free_th_m3_h);
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||
{gas.city_grs.map((row, i) => (
|
||
<GasGrsLine key={`grs-${row.grs_name}-${i}`} row={row} />
|
||
))}
|
||
|
||
{totalNum !== null && totalStr && (
|
||
<span style={{ fontSize: 13, color: "var(--fg-primary)" }}>
|
||
Суммарно по городским ГРС: <strong>{totalStr}</strong>
|
||
</span>
|
||
)}
|
||
|
||
<GasOutletsSummaryLine gas={gas} />
|
||
|
||
<span style={{ fontSize: 12, color: "var(--fg-tertiary)" }}>
|
||
Газпром трансгаз Екатеринбург · техвозможность ГРС
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Тепло ────────────────────────────────────────────────────────────────────
|
||
|
||
function HeatSystemLine({ row }: { row: HeatSystemRow }) {
|
||
const reserveNum = toFiniteNumber(row.reserve_gcal_h);
|
||
const reserveStr = formatGcalHour(row.reserve_gcal_h);
|
||
const isDepleted = reserveNum !== null && reserveNum <= 0;
|
||
const badge = isDepleted ? RESERVE_BADGE.depleted : RESERVE_BADGE.reserve;
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
flexWrap: "wrap",
|
||
}}
|
||
>
|
||
<span style={{ fontSize: 13, color: "var(--fg-primary)" }}>
|
||
<strong>{row.org}</strong> · {row.system_name}: резерв{" "}
|
||
{reserveStr ? (
|
||
<strong
|
||
style={{
|
||
color: isDepleted ? "var(--danger)" : "var(--fg-primary)",
|
||
}}
|
||
>
|
||
{reserveStr}
|
||
</strong>
|
||
) : (
|
||
<span style={{ color: "var(--fg-tertiary)" }}>—</span>
|
||
)}
|
||
</span>
|
||
{reserveNum !== null && (
|
||
<StatusBadge label={badge.label} bg={badge.bg} fg={badge.fg} />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function HeatBlock({ heat }: { heat: HeatCapacityBlock | null | undefined }) {
|
||
if (!heat || heat.systems.length === 0) {
|
||
return (
|
||
<div style={{ fontSize: 13, color: "var(--fg-secondary)" }}>
|
||
Данные о резервах теплоснабжения загружаются.
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const head = heat.systems.slice(0, 5);
|
||
const rest = heat.systems.slice(5);
|
||
|
||
const totalNum = toFiniteNumber(heat.total_reserve_gcal_h);
|
||
const totalStr = formatGcalHour(heat.total_reserve_gcal_h);
|
||
|
||
// Источник-подпись: период первой системы (первый ненулевой).
|
||
const period = heat.systems.find((s) => s.period)?.period?.trim() || null;
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||
{head.map((row, i) => (
|
||
<HeatSystemLine
|
||
key={`heat-${row.org}-${row.system_name}-${i}`}
|
||
row={row}
|
||
/>
|
||
))}
|
||
|
||
{rest.length > 0 && (
|
||
<details>
|
||
<summary
|
||
style={{
|
||
fontSize: 12,
|
||
color: "var(--accent)",
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
+ {pluralizeSystems(rest.length)}
|
||
</summary>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 6,
|
||
marginTop: 6,
|
||
}}
|
||
>
|
||
{rest.map((row, i) => (
|
||
<HeatSystemLine
|
||
key={`heat-rest-${row.org}-${row.system_name}-${i}`}
|
||
row={row}
|
||
/>
|
||
))}
|
||
</div>
|
||
</details>
|
||
)}
|
||
|
||
{totalNum !== null && totalStr && (
|
||
<span style={{ fontSize: 13, color: "var(--fg-primary)" }}>
|
||
Суммарно: <strong>{totalStr}</strong>
|
||
</span>
|
||
)}
|
||
|
||
<span style={{ fontSize: 12, color: "var(--fg-tertiary)" }}>
|
||
Раскрытие ФАС (ЕТК, Екатеринбургэнерго)
|
||
{period ? ` · ${period}` : ""}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Card ───────────────────────────────────────────────────────────────────────
|
||
|
||
const SUB_HEADER: React.CSSProperties = {
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.04em",
|
||
color: "var(--fg-tertiary)",
|
||
marginBottom: 6,
|
||
};
|
||
|
||
interface Props {
|
||
data: ConnectionCapacityResponse;
|
||
}
|
||
|
||
export function ResourceReservesCard({ data }: Props) {
|
||
return (
|
||
<div
|
||
style={{
|
||
border: "1px solid var(--border-card)",
|
||
borderRadius: 12,
|
||
background: "var(--bg-card)",
|
||
padding: 20,
|
||
marginBottom: 16,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 16,
|
||
}}
|
||
>
|
||
<h3
|
||
style={{
|
||
margin: 0,
|
||
fontSize: 14,
|
||
fontWeight: 600,
|
||
color: "var(--fg-primary)",
|
||
}}
|
||
>
|
||
Ресурсные резервы для подключения (ТП)
|
||
</h3>
|
||
|
||
{/* Электричество */}
|
||
<div>
|
||
<div style={SUB_HEADER}>
|
||
<Zap size={14} strokeWidth={1.5} aria-hidden="true" />
|
||
Электричество
|
||
</div>
|
||
<PowerRow data={data} />
|
||
</div>
|
||
|
||
{/* Газ */}
|
||
<div>
|
||
<div style={SUB_HEADER}>
|
||
<Flame size={14} strokeWidth={1.5} aria-hidden="true" />
|
||
Газ
|
||
</div>
|
||
<GasBlock gas={data.gas} />
|
||
</div>
|
||
|
||
{/* Тепло */}
|
||
<div>
|
||
<div style={SUB_HEADER}>
|
||
<Thermometer size={14} strokeWidth={1.5} aria-hidden="true" />
|
||
Тепло
|
||
</div>
|
||
<HeatBlock heat={data.heat} />
|
||
</div>
|
||
|
||
{/* Вода / канализация */}
|
||
<div>
|
||
<div style={SUB_HEADER}>
|
||
<Droplets size={14} strokeWidth={1.5} aria-hidden="true" />
|
||
Вода и водоотведение
|
||
</div>
|
||
<WaterBlock rows={data.water} />
|
||
</div>
|
||
|
||
{/* Caveat */}
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "flex-start",
|
||
gap: 6,
|
||
fontSize: 12,
|
||
color: "var(--fg-tertiary)",
|
||
borderTop: "1px solid var(--border-soft)",
|
||
paddingTop: 12,
|
||
}}
|
||
>
|
||
<Info
|
||
size={13}
|
||
strokeWidth={1.5}
|
||
aria-hidden="true"
|
||
style={{ flexShrink: 0, marginTop: 1 }}
|
||
/>
|
||
<span>
|
||
Резервы — из обязательного раскрытия сетевых организаций в радиусе{" "}
|
||
{RADIUS_KM} км; для ТП требуется запрос технических условий (ТУ).
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|