forked from jappel/leistungsbilanz-ts
1912 lines
68 KiB
TypeScript
1912 lines
68 KiB
TypeScript
"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>
|
||
);
|
||
}
|
||
|
||
|