leistungsbilanz-ts/src/app/projects/[projectId]/page.tsx

1912 lines
68 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";
import Link from "next/link";
import { useParams } from "next/navigation";
import { FormEvent, useEffect, useMemo, useState } from "react";
import {
ArrowLeft,
FileUp,
Library,
Link2,
ListTree,
Pencil,
Plus,
RefreshCw,
Settings,
Trash2,
Unlink,
X,
} from "lucide-react";
import { IconButton } from "../../../frontend/components/icon-button";
import { IconLink } from "../../../frontend/components/icon-link";
import {
CollapsibleFilterPanel,
FilterSearchField,
} from "../../../frontend/components/filter-panel";
import { MultiSelectDropdown } from "../../../frontend/components/multi-select-dropdown";
import {
circuitGroupCategories,
circuitGroupCategoryLabels,
} from "../../../shared/constants/circuit-group";
import { distributionBoardSupplyTypes } from "../../../shared/constants/distribution-board";
import {
buildCostGroupFilterOptions,
buildFloorFilterOptions,
buildSupplyTypeFilterOptions,
emptyDistributionBoardFilters,
emptyProjectDeviceFilters,
filterDistributionBoards,
filterProjectDevices,
isDistributionBoardFilterActive,
isProjectDeviceFilterActive,
retainAvailableFilterValues,
type DistributionBoardFilters,
type FilterOption,
type ProjectDeviceFilters,
} from "../../../frontend/utils/overview-filters";
import {
copyGlobalDeviceToProject,
copyDistributionBoard,
createFeederCircuitAndUpdateDistributionBoard,
copyProjectDeviceToGlobal,
createDistributionBoard,
createProjectDevice,
createRoom,
deleteProjectDevice,
deleteDistributionBoard,
disconnectProjectDeviceRows,
exportProjectTransfer,
getProject,
getProjectCircuitTrees,
getProjectDeviceSyncPreview,
listBuildings,
listCircuitLists,
listDistributionBoards,
listFloors,
listGlobalDevices,
listProjectDevices,
listRooms,
importProjectTransfer,
synchronizeProjectDeviceRows,
updateDistributionBoard,
updateProjectDevice,
updateProjectSettings,
} from "../../../frontend/utils/api";
import { createClientId } from "../../../frontend/utils/client-id";
import {
buildFeederCircuitSnapshot,
getDefaultFeederCircuitSection,
getNextFeederCircuitIdentifier,
} from "../../../frontend/utils/distribution-board-feeder-circuit";
import type {
BuildingDto,
CircuitListDto,
CircuitTreeResponseDto,
CreateRoomInput,
CreateProjectDeviceInput,
DistributionBoardDto,
FloorDto,
GlobalDeviceDto,
ProjectDeviceDto,
ProjectDeviceSyncPreviewDto,
ProjectDto,
RoomDto,
} from "../../../frontend/types";
import {
projectDeviceSyncFields,
type ProjectDeviceSyncField,
} from "../../../shared/constants/project-device-sync-fields";
import {
distributionBoardSupplyTypeLabels,
type DistributionBoardSupplyType,
} from "../../../shared/constants/distribution-board";
import { ProjectVersionHistory } from "../../../frontend/components/project-version-history";
import {
DistributionBoardInfoCard,
fromDistributionBoardInfoDraft,
toDistributionBoardInfoDraft,
type DistributionBoardInfoDraft,
} from "../../../frontend/components/distribution-board-info-card";
import { Printer } from "lucide-react";
import {
ProjectSettingsModal,
type ProjectSettingsInput,
} from "../../../frontend/components/project-settings-modal";
import { FormModal } from "../../../frontend/components/form-modal";
import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal";
import { RevitCsvModal } from "../../../frontend/components/revit-csv-modal";
import { FloorSelectOptions } from "../../../frontend/components/floor-select-options";
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
name: "Technischer Name",
displayName: "Anzeigename",
phaseType: "Phasenart",
connectionKind: "Anschlussart",
costGroup: "Kostengruppe",
category: "Kategorie",
quantity: "Anzahl",
powerPerUnit: "Leistung je Stück",
simultaneityFactor: "Gleichzeitigkeitsfaktor",
cosPhi: "cos Phi",
remark: "Bemerkung",
};
const projectDeviceCategoryFilterOptions: FilterOption[] =
circuitGroupCategories.map((category) => ({
id: category,
label: circuitGroupCategoryLabels[category],
}));
const projectDevicePhaseTypeLabels: Record<ProjectDeviceDto["phaseType"], string> = {
single_phase: "1-phasig",
three_phase: "3-phasig",
};
const projectDevicePhaseTypeFilterOptions: FilterOption[] = (
["single_phase", "three_phase"] as const
).map((phaseType) => ({
id: phaseType,
label: projectDevicePhaseTypeLabels[phaseType],
}));
const boardFilterCollapsedStorageKey =
"leistungsbilanz:overview-filter-collapsed:distribution-boards";
const projectDeviceFilterCollapsedStorageKey =
"leistungsbilanz:overview-filter-collapsed:project-devices";
const createFeederCircuitSelectValue = "__create_feeder_circuit__";
export default function ProjectDetailPage() {
const params = useParams<{ projectId: string }>();
const [projectId, setProjectId] = useState("");
const [project, setProject] = useState<ProjectDto | null>(null);
const [boards, setBoards] = useState<DistributionBoardDto[]>([]);
const [circuitLists, setCircuitLists] = useState<CircuitListDto[]>([]);
const [circuitTrees, setCircuitTrees] = useState<CircuitTreeResponseDto[]>([]);
const [buildings, setBuildings] = useState<BuildingDto[]>([]);
const [floors, setFloors] = useState<FloorDto[]>([]);
const [rooms, setRooms] = useState<RoomDto[]>([]);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
const [globalDevices, setGlobalDevices] = useState<GlobalDeviceDto[]>([]);
const [boardName, setBoardName] = useState("");
const [boardFloorId, setBoardFloorId] = useState("");
const [boardSupplyType, setBoardSupplyType] =
useState<DistributionBoardSupplyType>("AV");
const [editingBoard, setEditingBoard] =
useState<DistributionBoardDto | null>(null);
const [editingBoardFloorId, setEditingBoardFloorId] = useState("");
const [editingBoardSupplyType, setEditingBoardSupplyType] =
useState<DistributionBoardSupplyType>("AV");
const [
editingBoardSimultaneityFactor,
setEditingBoardSimultaneityFactor,
] = useState("1");
const [editingBoardCopyName, setEditingBoardCopyName] = useState("");
const [editingFeedingBoardId, setEditingFeedingBoardId] = useState("");
const [editingFeedingCircuitId, setEditingFeedingCircuitId] = useState("");
const [isCreatingFeederCircuit, setIsCreatingFeederCircuit] =
useState(false);
const [editingFeederCircuitSectionId, setEditingFeederCircuitSectionId] =
useState("");
const [editingFeederCircuitDisplayName, setEditingFeederCircuitDisplayName] =
useState("");
const [editingBoardInfo, setEditingBoardInfo] =
useState<DistributionBoardInfoDraft>(
toDistributionBoardInfoDraft({
boardNumber: null,
location: null,
fedFrom: null,
remark: null,
})
);
const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false);
const [isRevitCsvOpen, setIsRevitCsvOpen] = useState(false);
const [structureModal, setStructureModal] = useState<"board" | null>(null);
const [isProjectDeviceModalOpen, setIsProjectDeviceModalOpen] =
useState(false);
const [editingProjectDevice, setEditingProjectDevice] =
useState<ProjectDeviceDto | null>(null);
const [boardFilters, setBoardFilters] = useState<DistributionBoardFilters>(
emptyDistributionBoardFilters
);
const [projectDeviceFilters, setProjectDeviceFilters] =
useState<ProjectDeviceFilters>(emptyProjectDeviceFilters);
const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
const [error, setError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
setProjectId(params.projectId);
}, [params.projectId]);
useEffect(() => {
if (!projectId) {
return;
}
Promise.all([
getProject(projectId),
listDistributionBoards(projectId),
listCircuitLists(projectId),
getProjectCircuitTrees(projectId),
listBuildings(projectId),
listFloors(projectId),
listRooms(projectId),
listProjectDevices(projectId),
listGlobalDevices(),
])
.then(([
currentProject,
distributionBoards,
loadedCircuitLists,
loadedCircuitTrees,
loadedBuildings,
loadedFloors,
loadedRooms,
loadedProjectDevices,
loadedGlobalDevices,
]) => {
setProject(currentProject);
setBoards(distributionBoards);
setCircuitLists(loadedCircuitLists);
setCircuitTrees(loadedCircuitTrees.trees);
setBuildings(loadedBuildings);
setFloors(loadedFloors);
setRooms(loadedRooms);
setProjectDevices(loadedProjectDevices);
setGlobalDevices(loadedGlobalDevices);
setError(null);
})
.catch((err: unknown) =>
setError(err instanceof Error ? err.message : "Projektdaten konnten nicht geladen werden.")
);
}, [projectId]);
const boardCount = useMemo(() => boards.length, [boards.length]);
const buildingCount = useMemo(() => buildings.length, [buildings.length]);
const floorCount = useMemo(() => floors.length, [floors.length]);
const roomCount = useMemo(() => rooms.length, [rooms.length]);
const projectDeviceCount = useMemo(() => projectDevices.length, [projectDevices.length]);
const costGroupFilterOptions = useMemo(
() => buildCostGroupFilterOptions(projectDevices),
[projectDevices]
);
const effectiveProjectDeviceFilters = useMemo<ProjectDeviceFilters>(
() => ({
...projectDeviceFilters,
costGroups: retainAvailableFilterValues(
projectDeviceFilters.costGroups,
costGroupFilterOptions
),
}),
[costGroupFilterOptions, projectDeviceFilters]
);
const visibleProjectDevices = useMemo(
() =>
filterProjectDevices(
projectDevices,
effectiveProjectDeviceFilters,
circuitGroupCategoryLabels
),
[effectiveProjectDeviceFilters, projectDevices]
);
const floorById = useMemo(() => new Map(floors.map((item) => [item.id, item])), [floors]);
const floorFilterOptions = useMemo(
() => buildFloorFilterOptions(floors),
[floors]
);
const supplyTypeFilterOptions = useMemo(
() =>
buildSupplyTypeFilterOptions(
distributionBoardSupplyTypes,
project?.enabledDistributionBoardSupplyTypes ?? [],
boards,
distributionBoardSupplyTypeLabels
),
[boards, project?.enabledDistributionBoardSupplyTypes]
);
const effectiveBoardFilters = useMemo<DistributionBoardFilters>(
() => ({
...boardFilters,
floorIds: retainAvailableFilterValues(
boardFilters.floorIds,
floorFilterOptions
),
supplyTypes: retainAvailableFilterValues(
boardFilters.supplyTypes,
supplyTypeFilterOptions
),
}),
[boardFilters, floorFilterOptions, supplyTypeFilterOptions]
);
const visibleBoards = useMemo(
() =>
filterDistributionBoards(
boards,
effectiveBoardFilters,
new Map(floors.map((floor) => [floor.id, floor.name]))
),
[boards, effectiveBoardFilters, floors]
);
const circuitListByBoardId = useMemo(
() => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])),
[circuitLists]
);
const circuitTreeByBoardId = useMemo(
() => new Map(circuitTrees.map((tree) => [tree.distributionBoardId, tree])),
[circuitTrees]
);
const feedingBoardOptions = useMemo(() => {
if (!editingBoard) return [];
const boardById = new Map(boards.map((board) => [board.id, board]));
return boards.filter((candidate) => {
if (candidate.id === editingBoard.id) return false;
const visited = new Set<string>();
let current: DistributionBoardDto | undefined = candidate;
while (current && current.feedingDistributionBoardId && !visited.has(current.id)) {
if (current.feedingDistributionBoardId === editingBoard.id) return false;
visited.add(current.id);
current = boardById.get(current.feedingDistributionBoardId);
}
return true;
});
}, [boards, editingBoard]);
const selectedFeedingTree = editingFeedingBoardId
? circuitTreeByBoardId.get(editingFeedingBoardId)
: undefined;
const selectedFeedingCircuits = useMemo(
() =>
selectedFeedingTree?.sections.flatMap((section) =>
section.circuits.map((circuit) => ({ circuit, section }))
) ?? [],
[selectedFeedingTree]
);
const selectedFeedingCircuit = selectedFeedingCircuits.find(
({ circuit }) => circuit.id === editingFeedingCircuitId
);
const feederCircuitSections =
selectedFeedingTree?.sections.filter(
(section) => section.category !== undefined
) ?? [];
const selectedNewFeederCircuitSection = feederCircuitSections.find(
(section) => section.id === editingFeederCircuitSectionId
);
const newFeederCircuitIdentifier = selectedNewFeederCircuitSection
? getNextFeederCircuitIdentifier(selectedNewFeederCircuitSection)
: null;
const selectedFeedingCircuitCategory =
selectedFeedingCircuit?.section.category ??
(isCreatingFeederCircuit
? selectedNewFeederCircuitSection?.category
: undefined);
const selectedFeedingBoard = boards.find(
(board) => board.id === editingFeedingBoardId
);
const selectedFeedingCircuitUseCount = editingFeedingCircuitId
? boards.filter(
(board) =>
board.id !== editingBoard?.id &&
board.feedingCircuitId === editingFeedingCircuitId
).length
: 0;
const enabledBoardSupplyTypes =
project?.enabledDistributionBoardSupplyTypes ?? [];
const usedBoardSupplyTypes = useMemo(
() =>
[
...new Set(
boards
.map((board) => board.supplyType)
.filter(
(
supplyType
): supplyType is DistributionBoardSupplyType =>
supplyType !== null
)
),
],
[boards]
);
function formatBoardFeedingReference(board: DistributionBoardDto) {
if (!board.feedingDistributionBoardId) return board.fedFrom ?? "–";
const feedingBoard = boards.find(
(candidate) => candidate.id === board.feedingDistributionBoardId
);
if (!feedingBoard) return "Unbekannte Verteilung";
const boardLabel = feedingBoard.boardNumber ?? feedingBoard.name;
if (!board.feedingCircuitId) return boardLabel;
const circuit = circuitTreeByBoardId
.get(feedingBoard.id)
?.sections.flatMap((section) => section.circuits)
.find((candidate) => candidate.id === board.feedingCircuitId);
return `${boardLabel}${circuit?.equipmentIdentifier ?? ""}`;
}
function applyProjectRevision(currentRevision: number) {
setProject((current) =>
current ? { ...current, currentRevision } : current
);
}
async function handleCreateBoard(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!projectId || !project || !boardName.trim()) {
return;
}
setIsSaving(true);
setError(null);
try {
const result = await createDistributionBoard(
projectId,
{
name: boardName.trim(),
floorId: boardFloorId || null,
supplyType: boardSupplyType,
},
project.currentRevision
);
setBoards((current) => [
...current,
result.distributionBoard,
]);
setCircuitLists(await listCircuitLists(projectId));
applyProjectRevision(result.history.currentRevision);
setCircuitTrees((await getProjectCircuitTrees(projectId)).trees);
setBoardName("");
setBoardFloorId("");
setBoardSupplyType(
project.enabledDistributionBoardSupplyTypes[0]
);
setStructureModal(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Verteilung konnte nicht erstellt werden.");
} finally {
setIsSaving(false);
}
}
async function handleSaveProjectSettings(input: ProjectSettingsInput) {
if (!projectId || !project) {
return;
}
setIsSaving(true);
setError(null);
try {
const result = await updateProjectSettings(
projectId,
project.currentRevision,
input
);
setProject(result.project);
setIsProjectSettingsOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Projekteigenschaften konnten nicht gespeichert werden.");
} finally {
setIsSaving(false);
}
}
async function handleCreateProjectDevice(
payload: CreateProjectDeviceInput
) {
if (!projectId || !project || !payload.name.trim()) {
return;
}
setIsSaving(true);
setError(null);
try {
const result = await createProjectDevice(
projectId,
payload,
project.currentRevision
);
setProjectDevices((current) => [...current, result.projectDevice]);
applyProjectRevision(result.history.currentRevision);
setIsProjectDeviceModalOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht erstellt werden.");
} finally {
setIsSaving(false);
}
}
async function handleDeleteProjectDevice(projectDeviceId: string) {
if (!projectId || !project) {
return;
}
const device = projectDevices.find((item) => item.id === projectDeviceId);
const deviceName = device?.displayName || device?.name || "Projektgerät";
if (!window.confirm(`Projektgerät „${deviceName}“ wirklich löschen?`)) {
return;
}
setIsSaving(true);
setError(null);
try {
const result = await deleteProjectDevice(
projectId,
projectDeviceId,
project.currentRevision
);
setProjectDevices((current) => current.filter((item) => item.id !== projectDeviceId));
applyProjectRevision(result.history.currentRevision);
} catch (err) {
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht gelöscht werden.");
} finally {
setIsSaving(false);
}
}
async function handleUpdateProjectDevice(
device: ProjectDeviceDto,
payload: CreateProjectDeviceInput
) {
if (!projectId || !project) {
return;
}
setIsSaving(true);
setError(null);
try {
const result = await updateProjectDevice(
projectId,
device.id,
payload,
project.currentRevision
);
setProjectDevices((current) =>
current.map((item) =>
item.id === device.id ? result.projectDevice : item
)
);
applyProjectRevision(result.history.currentRevision);
setEditingProjectDevice(null);
await openProjectDeviceSyncPreview(result.projectDevice.id);
} catch (err) {
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht aktualisiert werden.");
} finally {
setIsSaving(false);
}
}
function openBoardEditor(board: DistributionBoardDto) {
setEditingBoard(board);
setEditingBoardFloorId(board.floorId ?? "");
setEditingBoardSupplyType(
board.supplyType ??
project?.enabledDistributionBoardSupplyTypes[0] ??
"AV"
);
setEditingBoardSimultaneityFactor(
String(board.simultaneityFactor)
);
setEditingBoardCopyName(`${board.name} Kopie`);
setEditingBoardInfo(toDistributionBoardInfoDraft(board));
setEditingFeedingBoardId(board.feedingDistributionBoardId ?? "");
setEditingFeedingCircuitId(board.feedingCircuitId ?? "");
setIsCreatingFeederCircuit(false);
setEditingFeederCircuitSectionId("");
setEditingFeederCircuitDisplayName("");
}
function openBoardCreator() {
setBoardSupplyType(
project?.enabledDistributionBoardSupplyTypes[0] ?? "AV"
);
setStructureModal("board");
}
async function handleUpdateBoard(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!projectId || !project || !editingBoard) {
return;
}
const simultaneityFactor = Number(
editingBoardSimultaneityFactor
);
if (
!editingBoardSimultaneityFactor.trim() ||
!Number.isFinite(simultaneityFactor) ||
simultaneityFactor < 0 ||
simultaneityFactor > 1
) {
return;
}
setIsSaving(true);
setError(null);
try {
const boardInput = {
floorId: editingBoardFloorId || null,
supplyType: editingBoardSupplyType,
simultaneityFactor,
feedingDistributionBoardId: editingFeedingBoardId || null,
feedingCircuitId: editingFeedingCircuitId || null,
...fromDistributionBoardInfoDraft(editingBoardInfo),
};
let result;
if (isCreatingFeederCircuit) {
if (!editingFeedingBoardId || !selectedFeedingTree) {
throw new Error("Bitte eine speisende Verteilung wählen.");
}
if (!selectedNewFeederCircuitSection) {
throw new Error(
"Bitte eine Stromkreisgruppe für den neuen Abgang wählen."
);
}
const circuit = buildFeederCircuitSnapshot({
id: createClientId(),
tree: selectedFeedingTree,
sectionId: selectedNewFeederCircuitSection.id,
displayName: editingFeederCircuitDisplayName,
});
result = await createFeederCircuitAndUpdateDistributionBoard(
projectId,
editingBoard.id,
{
...boardInput,
feedingDistributionBoardId: editingFeedingBoardId,
feedingCircuitId: circuit.id,
},
circuit,
project.currentRevision
);
} else {
result = await updateDistributionBoard(
projectId,
editingBoard.id,
boardInput,
project.currentRevision
);
}
applyProjectRevision(result.history.currentRevision);
const [updatedBoards, updatedTrees] = await Promise.all([
listDistributionBoards(projectId),
getProjectCircuitTrees(projectId),
]);
setBoards(updatedBoards);
setCircuitTrees(updatedTrees.trees);
setEditingBoard(null);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Verteilung konnte nicht bearbeitet werden."
);
} finally {
setIsSaving(false);
}
}
async function handleCreateRevitRoom(input: CreateRoomInput) {
if (!projectId || !project) throw new Error("Projekt ist nicht geladen.");
const result = await createRoom(projectId, input, project.currentRevision);
setRooms((current) => [...current, result.room]);
applyProjectRevision(result.history.currentRevision);
return result;
}
async function handleCreateRevitProjectDevice(input: CreateProjectDeviceInput) {
if (!projectId || !project) throw new Error("Projekt ist nicht geladen.");
const result = await createProjectDevice(projectId, input, project.currentRevision);
setProjectDevices((current) => [...current, result.projectDevice]);
applyProjectRevision(result.history.currentRevision);
return result;
}
async function handleCopyBoard() {
if (
!projectId ||
!project ||
!editingBoard ||
!editingBoardCopyName.trim()
) {
return;
}
setIsSaving(true);
setError(null);
try {
const result = await copyDistributionBoard(
projectId,
editingBoard.id,
editingBoardCopyName.trim(),
project.currentRevision
);
setBoards((current) => [
...current,
result.distributionBoard,
]);
setCircuitLists((current) => [
...current,
result.circuitList,
]);
applyProjectRevision(result.history.currentRevision);
setCircuitTrees((await getProjectCircuitTrees(projectId)).trees);
setEditingBoard(null);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Verteilung konnte nicht kopiert werden."
);
} finally {
setIsSaving(false);
}
}
async function handleDeleteBoard() {
if (!projectId || !project || !editingBoard) {
return;
}
const confirmed = window.confirm(
`Verteilung „${editingBoard.name}“ wirklich löschen?\n\n` +
"Dabei werden die Stromkreisliste, alle Gruppen, Stromkreise, Gerätezeilen, Schutzgeräte und weiteren Verteilergeräte entfernt. " +
"Der Vorgang kann anschließend über die projektweite Historie rückgängig gemacht werden."
);
if (!confirmed) {
return;
}
setIsSaving(true);
setError(null);
try {
const result = await deleteDistributionBoard(
projectId,
editingBoard.id,
project.currentRevision
);
setBoards((current) =>
current.filter((board) => board.id !== result.distributionBoardId)
);
setCircuitLists((current) =>
current.filter(
(circuitList) =>
circuitList.distributionBoardId !==
result.distributionBoardId
)
);
applyProjectRevision(result.history.currentRevision);
setCircuitTrees((await getProjectCircuitTrees(projectId)).trees);
setEditingBoard(null);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Verteilung konnte nicht gelöscht werden."
);
} finally {
setIsSaving(false);
}
}
async function handleExportProject() {
if (!project) {
return;
}
setIsSaving(true);
setError(null);
try {
const transfer = await exportProjectTransfer(project.id);
const blob = new Blob([JSON.stringify(transfer, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${project.name.replace(/[^a-zA-Z0-9_-]+/g, "-") || "projekt"}.leistungsbilanz.json`;
anchor.click();
URL.revokeObjectURL(url);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Projekt konnte nicht exportiert werden."
);
} finally {
setIsSaving(false);
}
}
async function handleImportProject(
transfer: unknown,
mode: "replace" | "duplicate"
) {
if (!project) {
return;
}
setIsSaving(true);
setError(null);
try {
const result = await importProjectTransfer(
project.id,
mode,
project.currentRevision,
transfer
);
if (mode === "duplicate" && "projectId" in result) {
window.location.href = `/projects/${result.projectId}`;
return;
}
window.location.reload();
} catch (err) {
setError(
err instanceof Error
? err.message
: "Projektdatei konnte nicht importiert werden."
);
setIsProjectSettingsOpen(false);
} finally {
setIsSaving(false);
}
}
async function openProjectDeviceSyncPreview(projectDeviceId: string) {
if (!projectId) {
return;
}
try {
const preview = await getProjectDeviceSyncPreview(projectId, projectDeviceId);
setSyncPreview(preview);
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
const differentFields = new Set(
preview.rows.flatMap((row) => row.differences.map((difference) => difference.field))
);
setSelectedSyncFields(
projectDeviceSyncFields.filter((field) => field !== "displayName" && differentFields.has(field))
);
} catch (err) {
setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht geladen werden.");
}
}
function toggleSyncRow(rowId: string) {
setSelectedSyncRowIds((current) =>
current.includes(rowId) ? current.filter((id) => id !== rowId) : [...current, rowId]
);
}
function toggleSyncField(field: ProjectDeviceSyncField) {
setSelectedSyncFields((current) =>
current.includes(field) ? current.filter((entry) => entry !== field) : [...current, field]
);
}
async function handleSynchronizeProjectDeviceRows() {
if (
!projectId ||
!project ||
!syncPreview ||
selectedSyncRowIds.length === 0 ||
selectedSyncFields.length === 0
) {
return;
}
setIsSaving(true);
setError(null);
try {
const result = await synchronizeProjectDeviceRows(
projectId,
syncPreview.projectDevice.id,
selectedSyncRowIds,
selectedSyncFields,
project.currentRevision
);
setSyncPreview(result.preview);
applyProjectRevision(result.history.currentRevision);
setSelectedSyncRowIds(
result.preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId)
);
} catch (err) {
setError(err instanceof Error ? err.message : "Gerätezeilen konnten nicht synchronisiert werden.");
} finally {
setIsSaving(false);
}
}
async function handleDisconnectProjectDeviceRows() {
if (
!projectId ||
!project ||
!syncPreview ||
selectedSyncRowIds.length === 0
) {
return;
}
setIsSaving(true);
setError(null);
try {
const result = await disconnectProjectDeviceRows(
projectId,
syncPreview.projectDevice.id,
selectedSyncRowIds,
project.currentRevision
);
setSyncPreview(result.preview);
setSelectedSyncRowIds(
result.preview.rows
.filter((row) => row.differences.length > 0)
.map((row) => row.rowId)
);
applyProjectRevision(result.history.currentRevision);
} catch (err) {
setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht getrennt werden.");
} finally {
setIsSaving(false);
}
}
async function handleCopyGlobalToProject(globalDeviceId: string) {
if (!projectId || !project || !globalDeviceId) {
return;
}
setIsSaving(true);
setError(null);
try {
const result = await copyGlobalDeviceToProject(
projectId,
globalDeviceId,
project.currentRevision
);
setProjectDevices((current) => [...current, result.projectDevice]);
applyProjectRevision(result.history.currentRevision);
setIsProjectDeviceModalOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Globales Gerät konnte nicht ins Projekt kopiert werden.");
} finally {
setIsSaving(false);
}
}
async function handleCopyProjectToGlobal(projectDeviceId: string) {
if (!projectId) {
return;
}
setIsSaving(true);
setError(null);
try {
const created = await copyProjectDeviceToGlobal(projectId, projectDeviceId);
setGlobalDevices((current) => [...current, created]);
} catch (err) {
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht global kopiert werden.");
} finally {
setIsSaving(false);
}
}
return (
<main className="container py-4">
<div className="page-header">
<div>
<div className="kicker">Projekt</div>
<h1>{project?.name ?? "Projekt"}</h1>
<p className="text-secondary mb-0">
{project?.internalProjectNumber
? `Projektnummer ${project.internalProjectNumber} · `
: ""}
Verteilerübersicht und Einstieg in die Stromkreislisten
</p>
</div>
<div className="d-flex gap-2">
<Link
className="btn btn-primary d-inline-flex align-items-center gap-2"
href={`/projects/${projectId}/print`}
target="_blank"
>
<Printer aria-hidden="true" size={16} />
Seitenansicht
</Link>
<button
className="btn btn-outline-primary btn-with-icon"
disabled={!project}
onClick={() => setIsRevitCsvOpen(true)}
type="button"
>
<FileUp aria-hidden="true" size={16} strokeWidth={2} />
Revit-CSV
</button>
<button
className="btn btn-outline-primary btn-with-icon"
disabled={!project}
onClick={() => setIsProjectSettingsOpen(true)}
type="button"
>
<Settings aria-hidden="true" size={16} strokeWidth={2} />
Projekteinstellungen
</button>
<Link
className="btn btn-outline-secondary btn-with-icon"
href="/projects"
>
<ArrowLeft aria-hidden="true" size={16} strokeWidth={2} />
Zur Projektübersicht
</Link>
</div>
</div>
{error ? <div className="alert alert-warning">{error}</div> : null}
<div className="kpi-grid">
<a className="kpi" href="#verteilungen">
<div className="kpi-label">Verteilungen</div>
<div className="kpi-value">{boardCount}</div>
</a>
<Link className="kpi" href={`/projects/${projectId}/struktur`}>
<div className="kpi-label">Gebäude</div>
<div className="kpi-value">{buildingCount}</div>
</Link>
<Link className="kpi" href={`/projects/${projectId}/struktur`}>
<div className="kpi-label">Etagen</div>
<div className="kpi-value">{floorCount}</div>
</Link>
<Link className="kpi" href={`/projects/${projectId}/struktur`}>
<div className="kpi-label">Räume</div>
<div className="kpi-value">{roomCount}</div>
</Link>
<a className="kpi" href="#projektgeraete">
<div className="kpi-label">Projektgeräte</div>
<div className="kpi-value">{projectDeviceCount}</div>
</a>
</div>
<div className="row g-4">
{project ? (
<section className="col-12" id="verlauf">
<ProjectVersionHistory
currentRevision={project.currentRevision}
onProjectStateChange={() => window.location.reload()}
projectId={projectId}
/>
</section>
) : null}
<section className="col-12" id="verteilungen">
<div className="card shadow-sm">
<div className="card-header d-flex justify-content-between align-items-center">
<div>
<span>Verteilungen</span>
<span className="badge text-bg-secondary ms-2">{boardCount}</span>
</div>
<div className="overview-actions">
<IconButton
icon={Plus}
label="Verteilung hinzufügen"
onClick={openBoardCreator}
showLabel
variant="primary"
/>
</div>
</div>
<CollapsibleFilterPanel
isFilterActive={isDistributionBoardFilterActive(
effectiveBoardFilters
)}
onReset={() => setBoardFilters(emptyDistributionBoardFilters)}
storageKey={boardFilterCollapsedStorageKey}
totalCount={boards.length}
visibleCount={visibleBoards.length}
>
<FilterSearchField
label="Suche"
onChange={(query) =>
setBoardFilters((current) => ({ ...current, query }))
}
placeholder="Verteilungen durchsuchen …"
value={boardFilters.query}
/>
<MultiSelectDropdown
emptyLabel="Keine Etagen vorhanden"
label="Etage"
onChange={(floorIds) =>
setBoardFilters((current) => ({ ...current, floorIds }))
}
options={floorFilterOptions}
selected={effectiveBoardFilters.floorIds}
/>
<MultiSelectDropdown
emptyLabel="Keine Netzarten vorhanden"
label="Netzart"
onChange={(supplyTypes) =>
setBoardFilters((current) => ({ ...current, supplyTypes }))
}
options={supplyTypeFilterOptions}
selected={effectiveBoardFilters.supplyTypes}
/>
</CollapsibleFilterPanel>
<div className="table-responsive">
<table className="table table-hover align-middle mb-0">
<thead>
<tr>
<th>Nr.</th>
<th>Verteilung</th>
<th>Örtlichkeit</th>
<th>Etage</th>
<th>Netzart</th>
<th>Gespeist von</th>
<th>GZF</th>
<th className="text-end">Aktionen</th>
</tr>
</thead>
<tbody>
{visibleBoards.map((board) => {
const circuitList = circuitListByBoardId.get(board.id);
return (
<tr key={board.id}>
<td className="fw-semibold">
{board.boardNumber ?? "–"}
</td>
<td>{board.name}</td>
<td>{board.location ?? "–"}</td>
<td>
{board.floorId
? floorById.get(board.floorId)?.name ?? "Unbekannt"
: "Ohne Etage"}
</td>
<td>
{board.supplyType
? distributionBoardSupplyTypeLabels[
board.supplyType
]
: "Nicht festgelegt"}
</td>
<td>{formatBoardFeedingReference(board)}</td>
<td>
{board.simultaneityFactor.toLocaleString(
"de-DE",
{
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}
)}
</td>
<td className="text-end">
<div className="overview-actions justify-content-end">
<IconButton
disabled={isSaving}
icon={Pencil}
label="Bearbeiten"
onClick={() => openBoardEditor(board)}
/>
{circuitList ? (
<IconLink
href={`/projects/${projectId}/circuit-lists/${circuitList.id}/tree-edit`}
icon={ListTree}
label="Stromkreisliste öffnen"
showLabel
variant="primary"
/>
) : (
<span className="text-secondary small align-self-center">
Keine Stromkreisliste vorhanden
</span>
)}
</div>
</td>
</tr>
);
})}
{!boards.length ? (
<tr>
<td colSpan={7} className="text-center text-secondary py-4">
Noch keine Verteilungen vorhanden.
</td>
</tr>
) : null}
</tbody>
</table>
{boards.length > 0 && visibleBoards.length === 0 ? (
<p className="text-center text-secondary py-4 mb-0">
Keine Verteilungen entsprechen den Filtern.
</p>
) : null}
</div>
</div>
</section>
</div>
<section className="card shadow-sm mt-4" id="projektgeraete">
<div className="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<span>Projektgeräte</span>
<span className="badge text-bg-secondary ms-2">{projectDeviceCount}</span>
</div>
<div className="overview-actions">
<IconButton
icon={Plus}
label="Gerät hinzufügen"
onClick={() => setIsProjectDeviceModalOpen(true)}
showLabel
variant="primary"
/>
</div>
</div>
<CollapsibleFilterPanel
isFilterActive={isProjectDeviceFilterActive(
effectiveProjectDeviceFilters
)}
onReset={() => setProjectDeviceFilters(emptyProjectDeviceFilters)}
storageKey={projectDeviceFilterCollapsedStorageKey}
totalCount={projectDevices.length}
visibleCount={visibleProjectDevices.length}
>
<FilterSearchField
label="Suche"
onChange={(query) =>
setProjectDeviceFilters((current) => ({ ...current, query }))
}
placeholder="Gerät, Anschlussart, KG …"
value={projectDeviceFilters.query}
/>
<MultiSelectDropdown
label="Kategorie"
onChange={(categories) =>
setProjectDeviceFilters((current) => ({ ...current, categories }))
}
options={projectDeviceCategoryFilterOptions}
selected={effectiveProjectDeviceFilters.categories}
/>
<MultiSelectDropdown
label="Anschluss"
onChange={(phaseTypes) =>
setProjectDeviceFilters((current) => ({ ...current, phaseTypes }))
}
options={projectDevicePhaseTypeFilterOptions}
selected={effectiveProjectDeviceFilters.phaseTypes}
/>
<MultiSelectDropdown
label="Kostengruppe"
onChange={(costGroups) =>
setProjectDeviceFilters((current) => ({ ...current, costGroups }))
}
options={costGroupFilterOptions}
selected={effectiveProjectDeviceFilters.costGroups}
/>
</CollapsibleFilterPanel>
<div className="table-responsive">
<table className="table table-sm table-striped align-middle mb-0">
<thead>
<tr>
<th>Gerät</th>
<th>Anschluss</th>
<th>Leistung</th>
<th>Kostengruppe</th>
<th className="text-end">Aktionen</th>
</tr>
</thead>
<tbody>
{visibleProjectDevices.map((device) => (
<tr key={device.id}>
<td>
<strong>{device.displayName}</strong>
<div className="small text-secondary">
{device.name}
{device.category
? ` · ${circuitGroupCategoryLabels[device.category] ?? device.category}`
: ""}
</div>
</td>
<td>
{projectDevicePhaseTypeLabels[device.phaseType]}
{device.connectionKind ? (
<div className="small text-secondary">
{device.connectionKind}
</div>
) : null}
</td>
<td>
{device.totalPower} kW
<div className="small text-secondary">
{device.quantity} × {device.powerPerUnit} kW · GZF{" "}
{device.simultaneityFactor}
</div>
</td>
<td>{device.costGroup ?? "-"}</td>
<td className="text-end">
<div className="overview-actions justify-content-end">
<IconButton
disabled={isSaving}
icon={Pencil}
label="Bearbeiten"
onClick={() => setEditingProjectDevice(device)}
/>
<IconButton
disabled={isSaving}
icon={Link2}
label="Verknüpfungen"
onClick={() => void openProjectDeviceSyncPreview(device.id)}
/>
<IconButton
disabled={isSaving}
icon={Library}
label="Nach global kopieren"
onClick={() => void handleCopyProjectToGlobal(device.id)}
/>
<IconButton
disabled={isSaving}
icon={Trash2}
label="Löschen"
onClick={() => void handleDeleteProjectDevice(device.id)}
variant="danger"
/>
</div>
</td>
</tr>
))}
{!visibleProjectDevices.length ? (
<tr>
<td colSpan={5} className="text-center text-secondary py-4">
{projectDevices.length
? "Keine Projektgeräte entsprechen den Filtern."
: "Noch keine Projektgeräte vorhanden."}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
{syncPreview ? (
<div className="card-body border-top">
<div className="d-flex flex-wrap justify-content-between align-items-start gap-2 mb-3">
<div>
<h3 className="h6 mb-1">Verknüpfte Zeilen: {syncPreview.projectDevice.displayName}</h3>
<p className="text-secondary small mb-0">
Nur ausgewählte Felder und Zeilen werden übernommen. Der Anzeigename bleibt standardmäßig lokal.
</p>
</div>
<div className="overview-actions">
<IconButton
icon={X}
label="Schließen"
onClick={() => setSyncPreview(null)}
/>
</div>
</div>
<div className="d-flex flex-wrap gap-3 mb-3">
{projectDeviceSyncFields.map((field) => (
<label className="form-check" key={field}>
<input
className="form-check-input"
type="checkbox"
checked={selectedSyncFields.includes(field)}
onChange={() => toggleSyncField(field)}
/>
<span className="form-check-label">
{projectDeviceSyncFieldLabels[field]}
{field === "displayName" ? " (lokal)" : ""}
</span>
</label>
))}
</div>
<div className="table-responsive border rounded mb-3">
<table className="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Auswahl</th>
<th>Verteilung / Stromkreis</th>
<th>Gerätezeile</th>
<th>Abweichungen</th>
</tr>
</thead>
<tbody>
{syncPreview.rows.map((row) => (
<tr key={row.rowId}>
<td>
<input
className="form-check-input"
type="checkbox"
checked={selectedSyncRowIds.includes(row.rowId)}
onChange={() => toggleSyncRow(row.rowId)}
/>
</td>
<td>
<strong>{row.distributionBoardName}</strong>
<div className="small text-secondary">
{row.equipmentIdentifier}
{row.circuitDisplayName ? ` – ${row.circuitDisplayName}` : ""}
</div>
</td>
<td>{row.rowDisplayName}</td>
<td>
<div className="d-flex flex-wrap gap-1">
{row.differences.map((difference) => (
<span
className={`badge ${difference.isOverridden ? "text-bg-warning" : "text-bg-light"}`}
key={difference.field}
title={difference.isOverridden ? "Lokal überschrieben" : undefined}
>
{projectDeviceSyncFieldLabels[difference.field]}: {String(difference.currentValue ?? "–")} →{" "}
{String(difference.sourceValue ?? "–")}
</span>
))}
{row.differences.length === 0 ? (
<span className="text-success small">Aktuell</span>
) : null}
</div>
</td>
</tr>
))}
{syncPreview.rows.length === 0 ? (
<tr>
<td colSpan={4} className="text-center text-secondary py-3">
Keine verknüpften Gerätezeilen im neuen Stromkreismodell vorhanden.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<div className="overview-actions">
<IconButton
disabled={isSaving || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0}
icon={RefreshCw}
label="Auswahl synchronisieren"
onClick={() => void handleSynchronizeProjectDeviceRows()}
showLabel
variant="primary"
/>
<IconButton
disabled={isSaving || selectedSyncRowIds.length === 0}
icon={Unlink}
label="Auswahl trennen"
onClick={() => void handleDisconnectProjectDeviceRows()}
showLabel
variant="danger"
/>
</div>
</div>
) : null}
</section>
{structureModal === "board" ? (
<FormModal
description="Eine Stromkreisliste mit den drei Standardgruppen wird automatisch angelegt."
isSaving={isSaving}
onClose={() => setStructureModal(null)}
onSubmit={handleCreateBoard}
submitDisabled={!boardName.trim()}
submitLabel="Verteilung erstellen"
title="Verteilung hinzufügen"
>
<label className="form-label" htmlFor="distribution-board-name">
Bezeichnung
</label>
<input
autoFocus
className="form-control"
id="distribution-board-name"
onChange={(event) => setBoardName(event.target.value)}
placeholder="z. B. UV-01"
required
value={boardName}
/>
<div className="row g-3 mt-0">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="distribution-board-floor">
Etage
</label>
<select
className="form-select"
id="distribution-board-floor"
onChange={(event) => setBoardFloorId(event.target.value)}
value={boardFloorId}
>
<option value="">Ohne Etage</option>
<FloorSelectOptions buildings={buildings} floors={floors} />
</select>
</div>
<div className="col-12 col-md-6">
<label
className="form-label"
htmlFor="distribution-board-supply-type"
>
Netzart
</label>
<select
className="form-select"
id="distribution-board-supply-type"
onChange={(event) =>
setBoardSupplyType(
event.target.value as DistributionBoardSupplyType
)
}
value={boardSupplyType}
>
{enabledBoardSupplyTypes.map((supplyType) => (
<option key={supplyType} value={supplyType}>
{distributionBoardSupplyTypeLabels[supplyType]}
</option>
))}
</select>
</div>
</div>
</FormModal>
) : null}
{editingBoard ? (
<FormModal
description={`${editingBoard.name}: Etagenzuordnung, Netzart, Einspeisung und Gleichzeitigkeitsfaktor ändern.`}
isSaving={isSaving}
onClose={() => setEditingBoard(null)}
onSubmit={handleUpdateBoard}
submitDisabled={
!editingBoardSimultaneityFactor.trim() ||
!Number.isFinite(
Number(editingBoardSimultaneityFactor)
) ||
Number(editingBoardSimultaneityFactor) < 0 ||
Number(editingBoardSimultaneityFactor) > 1
}
submitLabel="Änderungen speichern"
title="Verteilung bearbeiten"
>
<div className="row g-3">
<div className="col-12 col-md-6">
<label
className="form-label"
htmlFor="edit-distribution-board-floor"
>
Etage
</label>
<select
autoFocus
className="form-select"
id="edit-distribution-board-floor"
onChange={(event) =>
setEditingBoardFloorId(event.target.value)
}
value={editingBoardFloorId}
>
<option value="">Ohne Etage</option>
<FloorSelectOptions buildings={buildings} floors={floors} />
</select>
</div>
<div className="col-12 col-md-6">
<label
className="form-label"
htmlFor="edit-distribution-board-supply-type"
>
Netzart
</label>
<select
className="form-select"
id="edit-distribution-board-supply-type"
onChange={(event) =>
setEditingBoardSupplyType(
event.target.value as DistributionBoardSupplyType
)
}
value={editingBoardSupplyType}
>
{enabledBoardSupplyTypes.map((supplyType) => (
<option key={supplyType} value={supplyType}>
{distributionBoardSupplyTypeLabels[supplyType]}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label
className="form-label"
htmlFor="edit-distribution-board-simultaneity-factor"
>
Verteilerweiter Gleichzeitigkeitsfaktor
</label>
<input
className="form-control"
id="edit-distribution-board-simultaneity-factor"
max="1"
min="0"
onChange={(event) =>
setEditingBoardSimultaneityFactor(
event.target.value
)
}
required
step="0.01"
type="number"
value={editingBoardSimultaneityFactor}
/>
<div className="form-text">
Wird auf die Gesamtleistung des Verteilers angewendet.
</div>
</div>
</div>
<section className="card mt-4" aria-labelledby="feeding-reference-heading">
<div className="card-header" id="feeding-reference-heading">
Logische Einspeisung
</div>
<div className="card-body row g-3">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="feeding-distribution-board">
Speisende Verteilung
</label>
<select
className="form-select"
id="feeding-distribution-board"
onChange={(event) => {
const boardId = event.target.value;
setEditingFeedingBoardId(boardId);
setEditingFeedingCircuitId("");
setIsCreatingFeederCircuit(false);
setEditingFeederCircuitSectionId(
getDefaultFeederCircuitSection(
circuitTreeByBoardId.get(boardId)
)?.id ?? ""
);
setEditingFeederCircuitDisplayName("");
}}
value={editingFeedingBoardId}
>
<option value="">Keine Projektverteilung</option>
{feedingBoardOptions.map((board) => (
<option key={board.id} value={board.id}>
{board.boardNumber ?? board.name}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="feeding-circuit">
Speisender Abgang
</label>
<select
className="form-select"
disabled={!editingFeedingBoardId}
id="feeding-circuit"
onChange={(event) => {
if (event.target.value === createFeederCircuitSelectValue) {
setEditingFeedingCircuitId("");
setIsCreatingFeederCircuit(true);
setEditingFeederCircuitSectionId(
(current) =>
current ||
getDefaultFeederCircuitSection(selectedFeedingTree)?.id ||
""
);
return;
}
setIsCreatingFeederCircuit(false);
setEditingFeedingCircuitId(event.target.value);
}}
value={
isCreatingFeederCircuit
? createFeederCircuitSelectValue
: editingFeedingCircuitId
}
>
<option value="">Nur Verteilung bekannt</option>
<option value={createFeederCircuitSelectValue}>
+ Neuen Einspeiseabgang anlegen …
</option>
{selectedFeedingCircuits.map(({ circuit }) => (
<option key={circuit.id} value={circuit.id}>
{circuit.equipmentIdentifier}
{circuit.displayName ? ` · ${circuit.displayName}` : ""}
</option>
))}
</select>
</div>
{isCreatingFeederCircuit ? (
<div className="col-12">
<div className="border rounded p-3">
<div className="row g-3">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="new-feeder-circuit-section">
Stromkreisgruppe
</label>
<select
className="form-select"
id="new-feeder-circuit-section"
onChange={(event) =>
setEditingFeederCircuitSectionId(event.target.value)
}
required
value={editingFeederCircuitSectionId}
>
{feederCircuitSections.map((section) => (
<option key={section.id} value={section.id}>
{section.displayName}
</option>
))}
</select>
<div className="form-text">
Vorgesehenes BMK: {newFeederCircuitIdentifier ?? "–"}
</div>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="new-feeder-circuit-name">
Abgangsbezeichnung (optional)
</label>
<input
className="form-control"
id="new-feeder-circuit-name"
onChange={(event) =>
setEditingFeederCircuitDisplayName(event.target.value)
}
placeholder="z. B. Steigetrasse Ost"
value={editingFeederCircuitDisplayName}
/>
<div className="form-text">
Ohne Zusatzbezeichnung wird der Name der gespeisten
Verteilung angezeigt.
</div>
</div>
</div>
<div className="form-text mt-2">
Der Abgang wird erst beim Speichern angelegt und gemeinsam
mit dieser Verteilung verknüpft. Es entsteht keine Gerätezeile.
</div>
</div>
</div>
) : null}
{editingFeedingBoardId &&
!editingFeedingCircuitId &&
!isCreatingFeederCircuit ? (
<div className="col-12">
<div className="alert alert-warning mb-0 py-2">
Ohne speisenden Abgang wird die Hierarchie angezeigt, die
Leistung dieser Verteilung aber keinem Stromkreis zugerechnet.
</div>
</div>
) : null}
{selectedFeedingBoard?.supplyType &&
editingBoardSupplyType !== selectedFeedingBoard.supplyType ? (
<div className="col-12">
<div className="alert alert-warning mb-0 py-2">
Die Netzarten weichen ab: {selectedFeedingBoard.supplyType}
{" → "}{editingBoardSupplyType}. Bitte Umschaltung und
Versorgungskonzept prüfen.
</div>
</div>
) : null}
{selectedFeedingCircuitCategory !== undefined &&
selectedFeedingCircuitCategory !== "three_phase" ? (
<div className="col-12">
<div className="alert alert-warning mb-0 py-2">
Der gewählte Abgang ist 1-phasig. Bitte prüfen, ob diese
Einspeisung für die Unterverteilung geeignet ist.
</div>
</div>
) : null}
{selectedFeedingCircuitUseCount > 0 ? (
<div className="col-12">
<div className="alert alert-info mb-0 py-2">
Dieser Abgang speist bereits {selectedFeedingCircuitUseCount}
{selectedFeedingCircuitUseCount === 1
? " weitere Verteilung."
: " weitere Verteilungen."}
</div>
</div>
) : null}
</div>
</section>
<div className="mt-4">
<DistributionBoardInfoCard
draft={editingBoardInfo}
onChange={setEditingBoardInfo}
/>
</div>
<hr className="my-4" />
<section aria-labelledby="copy-distribution-board-heading">
<h3
className="h6"
id="copy-distribution-board-heading"
>
Verteilung kopieren
</h3>
<p className="text-secondary small">
Erstellt eine vollständige Kopie des zuletzt gespeicherten
Stands einschließlich Gruppen, Stromkreisen, Gerätezeilen und
Schutzgeräten. Noch nicht gespeicherte Änderungen oben werden
nicht übernommen.
</p>
<div className="row g-2 align-items-end">
<div className="col-12 col-md">
<label
className="form-label"
htmlFor="copy-distribution-board-name"
>
Bezeichnung der Kopie
</label>
<input
className="form-control"
id="copy-distribution-board-name"
maxLength={200}
onChange={(event) =>
setEditingBoardCopyName(event.target.value)
}
value={editingBoardCopyName}
/>
</div>
<div className="col-12 col-md-auto">
<button
className="btn btn-outline-primary"
disabled={isSaving || !editingBoardCopyName.trim()}
onClick={() => void handleCopyBoard()}
type="button"
>
Verteilung kopieren
</button>
</div>
</div>
</section>
<hr className="my-4" />
<section
aria-labelledby="delete-distribution-board-heading"
className="border border-danger-subtle rounded p-3"
>
<h3
className="h6 text-danger"
id="delete-distribution-board-heading"
>
Gefahrenbereich
</h3>
<p className="text-secondary small mb-3">
Entfernt die vollständige Verteilung. Die Aktion wird erst
nach einer ausdrücklichen Warnung ausgeführt und kann über die
projektweite Historie rückgängig gemacht werden.
</p>
<button
className="btn btn-outline-danger"
disabled={isSaving}
onClick={() => void handleDeleteBoard()}
type="button"
>
Verteilung löschen
</button>
</section>
</FormModal>
) : null}
{isProjectDeviceModalOpen ? (
<ProjectDeviceModal
globalDevices={globalDevices}
isSaving={isSaving}
onClose={() => setIsProjectDeviceModalOpen(false)}
onImportGlobal={handleCopyGlobalToProject}
onSave={handleCreateProjectDevice}
/>
) : null}
{editingProjectDevice ? (
<ProjectDeviceModal
globalDevices={globalDevices}
initialDevice={editingProjectDevice}
isSaving={isSaving}
onClose={() => setEditingProjectDevice(null)}
onImportGlobal={handleCopyGlobalToProject}
onSave={(input) =>
handleUpdateProjectDevice(editingProjectDevice, input)
}
/>
) : null}
{project && isProjectSettingsOpen ? (
<ProjectSettingsModal
isSaving={isSaving}
onClose={() => setIsProjectSettingsOpen(false)}
onExport={handleExportProject}
onImport={handleImportProject}
onSave={handleSaveProjectSettings}
project={project}
usedDistributionBoardSupplyTypes={usedBoardSupplyTypes}
/>
) : null}
{project && isRevitCsvOpen ? (
<RevitCsvModal
currentRevision={project.currentRevision}
onClose={() => setIsRevitCsvOpen(false)}
onCreateProjectDevice={handleCreateRevitProjectDevice}
onCreateRoom={handleCreateRevitRoom}
onRevisionChange={applyProjectRevision}
projectId={projectId}
/>
) : null}
</main>
);
}