gendesign/frontend/src/components/site-finder/analysis/ResourceReservesCard.tsx
bot-backend 15757685c9
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 2m17s
Deploy / build-worker (push) Successful in 3m19s
Deploy / build-frontend (push) Successful in 3m54s
Deploy / deploy (push) Successful in 1m34s
feat(site-finder): Celery + §3 точки выхода газосети (#2119 B2, PR-4/4 — финал формы-6) (#2251)
2026-07-03 04:26:41 +00:00

601 lines
19 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";
/**
* 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>
);
}