1686 lines
56 KiB
TypeScript
1686 lines
56 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useParams } from "next/navigation";
|
||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||
import {
|
||
copyGlobalDeviceToProject,
|
||
copyDistributionBoard,
|
||
copyProjectDeviceToGlobal,
|
||
createDistributionBoard,
|
||
createFloor,
|
||
createProjectDevice,
|
||
createRoom,
|
||
deleteFloor,
|
||
deleteProjectDevice,
|
||
deleteRoom,
|
||
deleteDistributionBoard,
|
||
disconnectProjectDeviceRows,
|
||
exportProjectTransfer,
|
||
getProjectDeviceSyncPreview,
|
||
listCircuitLists,
|
||
listDistributionBoards,
|
||
listFloors,
|
||
listGlobalDevices,
|
||
listProjectDevices,
|
||
listProjects,
|
||
listRooms,
|
||
importProjectTransfer,
|
||
synchronizeProjectDeviceRows,
|
||
updateDistributionBoard,
|
||
updateFloor,
|
||
updateProjectDevice,
|
||
updateProjectSettings,
|
||
updateRoom,
|
||
} from "../../../frontend/utils/api";
|
||
import type {
|
||
CircuitListDto,
|
||
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 {
|
||
ProjectSettingsModal,
|
||
type ProjectSettingsInput,
|
||
} from "../../../frontend/components/project-settings-modal";
|
||
import { FormModal } from "../../../frontend/components/form-modal";
|
||
import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal";
|
||
|
||
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",
|
||
};
|
||
|
||
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 [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 [floorName, setFloorName] = useState("");
|
||
const [editingFloor, setEditingFloor] = useState<FloorDto | null>(null);
|
||
const [roomNumber, setRoomNumber] = useState("");
|
||
const [roomName, setRoomName] = useState("");
|
||
const [roomFloorId, setRoomFloorId] = useState("");
|
||
const [editingRoom, setEditingRoom] = useState<RoomDto | null>(null);
|
||
const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false);
|
||
const [structureModal, setStructureModal] = useState<
|
||
"board" | "floor" | "room" | null
|
||
>(null);
|
||
const [isProjectDeviceModalOpen, setIsProjectDeviceModalOpen] =
|
||
useState(false);
|
||
const [editingProjectDevice, setEditingProjectDevice] =
|
||
useState<ProjectDeviceDto | null>(null);
|
||
const [projectDeviceQuery, setProjectDeviceQuery] = useState("");
|
||
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([
|
||
listProjects(),
|
||
listDistributionBoards(projectId),
|
||
listCircuitLists(projectId),
|
||
listFloors(projectId),
|
||
listRooms(projectId),
|
||
listProjectDevices(projectId),
|
||
listGlobalDevices(),
|
||
])
|
||
.then(([
|
||
projects,
|
||
distributionBoards,
|
||
loadedCircuitLists,
|
||
loadedFloors,
|
||
loadedRooms,
|
||
loadedProjectDevices,
|
||
loadedGlobalDevices,
|
||
]) => {
|
||
const currentProject = projects.find((item) => item.id === projectId) ?? null;
|
||
setProject(currentProject);
|
||
setBoards(distributionBoards);
|
||
setCircuitLists(loadedCircuitLists);
|
||
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 floorCount = useMemo(() => floors.length, [floors.length]);
|
||
const roomCount = useMemo(() => rooms.length, [rooms.length]);
|
||
const projectDeviceCount = useMemo(() => projectDevices.length, [projectDevices.length]);
|
||
const visibleProjectDevices = useMemo(() => {
|
||
const query = projectDeviceQuery.trim().toLocaleLowerCase("de");
|
||
if (!query) {
|
||
return projectDevices;
|
||
}
|
||
return projectDevices.filter((device) =>
|
||
[
|
||
device.name,
|
||
device.displayName,
|
||
device.category,
|
||
device.connectionKind,
|
||
device.costGroup,
|
||
].some((value) => value?.toLocaleLowerCase("de").includes(query))
|
||
);
|
||
}, [projectDeviceQuery, projectDevices]);
|
||
const floorById = useMemo(() => new Map(floors.map((item) => [item.id, item])), [floors]);
|
||
const circuitListByBoardId = useMemo(
|
||
() => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])),
|
||
[circuitLists]
|
||
);
|
||
const enabledBoardSupplyTypes =
|
||
project?.enabledDistributionBoardSupplyTypes ?? [];
|
||
const usedBoardSupplyTypes = useMemo(
|
||
() =>
|
||
[
|
||
...new Set(
|
||
boards
|
||
.map((board) => board.supplyType)
|
||
.filter(
|
||
(
|
||
supplyType
|
||
): supplyType is DistributionBoardSupplyType =>
|
||
supplyType !== null
|
||
)
|
||
),
|
||
],
|
||
[boards]
|
||
);
|
||
|
||
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);
|
||
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 handleSaveFloor(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (!projectId || !project || !floorName.trim()) {
|
||
return;
|
||
}
|
||
setIsSaving(true);
|
||
setError(null);
|
||
try {
|
||
const result = editingFloor
|
||
? await updateFloor(
|
||
projectId,
|
||
editingFloor.id,
|
||
{ name: floorName.trim() },
|
||
project.currentRevision
|
||
)
|
||
: await createFloor(
|
||
projectId,
|
||
{ name: floorName.trim() },
|
||
project.currentRevision
|
||
);
|
||
setFloors((current) =>
|
||
editingFloor
|
||
? current.map((floor) =>
|
||
floor.id === result.floor.id ? result.floor : floor
|
||
)
|
||
: [...current, result.floor]
|
||
);
|
||
applyProjectRevision(result.history.currentRevision);
|
||
setFloorName("");
|
||
setEditingFloor(null);
|
||
setStructureModal(null);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Etage konnte nicht gespeichert werden.");
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
}
|
||
|
||
async function handleSaveRoom(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (
|
||
!projectId ||
|
||
!project ||
|
||
!roomNumber.trim() ||
|
||
!roomName.trim()
|
||
) {
|
||
return;
|
||
}
|
||
setIsSaving(true);
|
||
setError(null);
|
||
try {
|
||
const input = {
|
||
floorId: roomFloorId || undefined,
|
||
roomNumber: roomNumber.trim(),
|
||
roomName: roomName.trim(),
|
||
};
|
||
const result = editingRoom
|
||
? await updateRoom(
|
||
projectId,
|
||
editingRoom.id,
|
||
input,
|
||
project.currentRevision
|
||
)
|
||
: await createRoom(projectId, input, project.currentRevision);
|
||
setRooms((current) =>
|
||
editingRoom
|
||
? current.map((room) =>
|
||
room.id === result.room.id ? result.room : room
|
||
)
|
||
: [...current, result.room]
|
||
);
|
||
applyProjectRevision(result.history.currentRevision);
|
||
setRoomNumber("");
|
||
setRoomName("");
|
||
setRoomFloorId("");
|
||
setEditingRoom(null);
|
||
setStructureModal(null);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Raum konnte nicht gespeichert 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;
|
||
}
|
||
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`);
|
||
}
|
||
|
||
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 result = await updateDistributionBoard(
|
||
projectId,
|
||
editingBoard.id,
|
||
{
|
||
floorId: editingBoardFloorId || null,
|
||
supplyType: editingBoardSupplyType,
|
||
simultaneityFactor,
|
||
},
|
||
project.currentRevision
|
||
);
|
||
setBoards((current) =>
|
||
current.map((board) =>
|
||
board.id === result.distributionBoard.id
|
||
? result.distributionBoard
|
||
: board
|
||
)
|
||
);
|
||
applyProjectRevision(result.history.currentRevision);
|
||
setEditingBoard(null);
|
||
} catch (err) {
|
||
setError(
|
||
err instanceof Error
|
||
? err.message
|
||
: "Verteilung konnte nicht bearbeitet werden."
|
||
);
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
}
|
||
|
||
function openCreateFloor() {
|
||
setEditingFloor(null);
|
||
setFloorName("");
|
||
setStructureModal("floor");
|
||
}
|
||
|
||
function openEditFloor(floor: FloorDto) {
|
||
setEditingFloor(floor);
|
||
setFloorName(floor.name);
|
||
setStructureModal("floor");
|
||
}
|
||
|
||
function openCreateRoom() {
|
||
setEditingRoom(null);
|
||
setRoomNumber("");
|
||
setRoomName("");
|
||
setRoomFloorId("");
|
||
setStructureModal("room");
|
||
}
|
||
|
||
function openEditRoom(room: RoomDto) {
|
||
setEditingRoom(room);
|
||
setRoomNumber(room.roomNumber);
|
||
setRoomName(room.roomName);
|
||
setRoomFloorId(room.floorId ?? "");
|
||
setStructureModal("room");
|
||
}
|
||
|
||
async function handleDeleteFloor() {
|
||
if (!projectId || !project || !editingFloor) return;
|
||
if (!window.confirm(`Etage „${editingFloor.name}“ wirklich löschen?`)) return;
|
||
setIsSaving(true);
|
||
setError(null);
|
||
try {
|
||
const result = await deleteFloor(projectId, editingFloor.id, project.currentRevision);
|
||
setFloors((current) => current.filter((floor) => floor.id !== editingFloor.id));
|
||
applyProjectRevision(result.history.currentRevision);
|
||
setEditingFloor(null);
|
||
setFloorName("");
|
||
setStructureModal(null);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Etage konnte nicht gelöscht werden.");
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
}
|
||
|
||
async function handleDeleteRoom() {
|
||
if (!projectId || !project || !editingRoom) return;
|
||
if (!window.confirm(`Raum „${editingRoom.roomNumber} ${editingRoom.roomName}“ wirklich löschen?`)) return;
|
||
setIsSaving(true);
|
||
setError(null);
|
||
try {
|
||
const result = await deleteRoom(projectId, editingRoom.id, project.currentRevision);
|
||
setRooms((current) => current.filter((room) => room.id !== editingRoom.id));
|
||
applyProjectRevision(result.history.currentRevision);
|
||
setEditingRoom(null);
|
||
setRoomNumber("");
|
||
setRoomName("");
|
||
setRoomFloorId("");
|
||
setStructureModal(null);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Raum konnte nicht gelöscht werden.");
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
}
|
||
|
||
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);
|
||
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);
|
||
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">
|
||
<button
|
||
className="btn btn-outline-primary"
|
||
disabled={!project}
|
||
onClick={() => setIsProjectSettingsOpen(true)}
|
||
type="button"
|
||
>
|
||
Projekteinstellungen
|
||
</button>
|
||
<Link className="btn btn-outline-secondary" href="/projects">
|
||
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>
|
||
<a className="kpi" href="#etagen">
|
||
<div className="kpi-label">Etagen</div>
|
||
<div className="kpi-value">{floorCount}</div>
|
||
</a>
|
||
<a className="kpi" href="#raeume">
|
||
<div className="kpi-label">Räume</div>
|
||
<div className="kpi-value">{roomCount}</div>
|
||
</a>
|
||
<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>
|
||
<button
|
||
className="btn btn-sm btn-primary"
|
||
onClick={openBoardCreator}
|
||
type="button"
|
||
>
|
||
Verteilung hinzufügen
|
||
</button>
|
||
</div>
|
||
<div className="table-responsive">
|
||
<table className="table table-hover align-middle mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th>Verteilung</th>
|
||
<th>Etage</th>
|
||
<th>Netzart</th>
|
||
<th>GZF</th>
|
||
<th className="text-end">Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{boards.map((board) => {
|
||
const circuitList = circuitListByBoardId.get(board.id);
|
||
return (
|
||
<tr key={board.id}>
|
||
<td>{board.name}</td>
|
||
<td>
|
||
{board.floorId
|
||
? floorById.get(board.floorId)?.name ?? "Unbekannt"
|
||
: "Ohne Etage"}
|
||
</td>
|
||
<td>
|
||
{board.supplyType
|
||
? distributionBoardSupplyTypeLabels[
|
||
board.supplyType
|
||
]
|
||
: "Nicht festgelegt"}
|
||
</td>
|
||
<td>
|
||
{board.simultaneityFactor.toLocaleString(
|
||
"de-DE",
|
||
{
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2,
|
||
}
|
||
)}
|
||
</td>
|
||
<td className="text-end">
|
||
<div className="d-inline-flex flex-wrap justify-content-end gap-2">
|
||
<button
|
||
className="btn btn-sm btn-outline-secondary"
|
||
disabled={isSaving}
|
||
onClick={() => openBoardEditor(board)}
|
||
type="button"
|
||
>
|
||
Bearbeiten
|
||
</button>
|
||
{circuitList ? (
|
||
<Link
|
||
className="btn btn-sm btn-primary"
|
||
href={`/projects/${projectId}/circuit-lists/${circuitList.id}/tree-edit`}
|
||
>
|
||
Stromkreisliste öffnen
|
||
</Link>
|
||
) : (
|
||
<span className="text-secondary small align-self-center">
|
||
Keine Stromkreisliste vorhanden
|
||
</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
{!boards.length ? (
|
||
<tr>
|
||
<td colSpan={5} className="text-center text-secondary py-4">
|
||
Noch keine Verteilungen vorhanden.
|
||
</td>
|
||
</tr>
|
||
) : null}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="col-12 col-lg-5" id="etagen">
|
||
<div className="card shadow-sm h-100">
|
||
<div className="card-header d-flex justify-content-between align-items-center">
|
||
<div>
|
||
<span>Etagen</span>
|
||
<span className="badge text-bg-secondary ms-2">{floorCount}</span>
|
||
</div>
|
||
<button
|
||
className="btn btn-sm btn-outline-primary"
|
||
onClick={openCreateFloor}
|
||
type="button"
|
||
>
|
||
Hinzufügen
|
||
</button>
|
||
</div>
|
||
<ul className="list-group list-group-flush">
|
||
{floors.map((floor) => (
|
||
<li className="list-group-item d-flex justify-content-between align-items-center gap-2" key={floor.id}>
|
||
<span>{floor.name}</span>
|
||
<button className="btn btn-sm btn-outline-secondary" onClick={() => openEditFloor(floor)} type="button">
|
||
Bearbeiten
|
||
</button>
|
||
</li>
|
||
))}
|
||
{!floors.length ? (
|
||
<li className="list-group-item text-secondary">Noch keine Etagen vorhanden.</li>
|
||
) : null}
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="col-12 col-lg-7" id="raeume">
|
||
<div className="card shadow-sm">
|
||
<div className="card-header d-flex justify-content-between align-items-center">
|
||
<div>
|
||
<span>Räume</span>
|
||
<span className="badge text-bg-secondary ms-2">{roomCount}</span>
|
||
</div>
|
||
<button
|
||
className="btn btn-sm btn-outline-primary"
|
||
onClick={openCreateRoom}
|
||
type="button"
|
||
>
|
||
Hinzufügen
|
||
</button>
|
||
</div>
|
||
<div className="table-responsive">
|
||
<table className="table table-hover align-middle mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th>Raumnummer</th>
|
||
<th>Raumname</th>
|
||
<th>Etage</th>
|
||
<th className="text-end">Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rooms.map((room) => (
|
||
<tr key={room.id}>
|
||
<td>{room.roomNumber}</td>
|
||
<td>{room.roomName}</td>
|
||
<td>{room.floorId ? floorById.get(room.floorId)?.name ?? "-" : "-"}</td>
|
||
<td className="text-end">
|
||
<button className="btn btn-sm btn-outline-secondary" onClick={() => openEditRoom(room)} type="button">
|
||
Bearbeiten
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{!rooms.length ? (
|
||
<tr>
|
||
<td colSpan={4} className="text-center text-secondary py-4">
|
||
Noch keine Räume vorhanden.
|
||
</td>
|
||
</tr>
|
||
) : null}
|
||
</tbody>
|
||
</table>
|
||
</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="d-flex gap-2">
|
||
<input
|
||
aria-label="Projektgeräte durchsuchen"
|
||
className="form-control form-control-sm"
|
||
onChange={(event) => setProjectDeviceQuery(event.target.value)}
|
||
placeholder="Geräte durchsuchen"
|
||
type="search"
|
||
value={projectDeviceQuery}
|
||
/>
|
||
<button
|
||
className="btn btn-sm btn-primary text-nowrap"
|
||
onClick={() => setIsProjectDeviceModalOpen(true)}
|
||
type="button"
|
||
>
|
||
Gerät hinzufügen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<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 ? ` · ${device.category}` : ""}
|
||
</div>
|
||
</td>
|
||
<td>
|
||
{device.phaseType === "three_phase" ? "3-phasig" : "1-phasig"}
|
||
{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="d-inline-flex flex-wrap justify-content-end gap-1">
|
||
<button
|
||
className="btn btn-sm btn-outline-secondary"
|
||
type="button"
|
||
onClick={() => setEditingProjectDevice(device)}
|
||
disabled={isSaving}
|
||
>
|
||
Bearbeiten
|
||
</button>
|
||
<button
|
||
className="btn btn-sm btn-outline-primary"
|
||
type="button"
|
||
onClick={() => void openProjectDeviceSyncPreview(device.id)}
|
||
disabled={isSaving}
|
||
>
|
||
Verknüpfungen
|
||
</button>
|
||
<button
|
||
className="btn btn-sm btn-outline-secondary"
|
||
type="button"
|
||
onClick={() => handleCopyProjectToGlobal(device.id)}
|
||
disabled={isSaving}
|
||
>
|
||
Nach global kopieren
|
||
</button>
|
||
<button
|
||
className="btn btn-sm btn-outline-danger"
|
||
type="button"
|
||
onClick={() => handleDeleteProjectDevice(device.id)}
|
||
disabled={isSaving}
|
||
>
|
||
Löschen
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{!visibleProjectDevices.length ? (
|
||
<tr>
|
||
<td colSpan={5} className="text-center text-secondary py-4">
|
||
{projectDevices.length
|
||
? "Keine Projektgeräte entsprechen der Suche."
|
||
: "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>
|
||
<button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSyncPreview(null)}>
|
||
Schließen
|
||
</button>
|
||
</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="d-flex flex-wrap gap-2">
|
||
<button
|
||
className="btn btn-primary"
|
||
type="button"
|
||
onClick={() => void handleSynchronizeProjectDeviceRows()}
|
||
disabled={isSaving || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0}
|
||
>
|
||
Auswahl synchronisieren
|
||
</button>
|
||
<button
|
||
className="btn btn-outline-danger"
|
||
type="button"
|
||
onClick={() => void handleDisconnectProjectDeviceRows()}
|
||
disabled={isSaving || selectedSyncRowIds.length === 0}
|
||
>
|
||
Auswahl trennen
|
||
</button>
|
||
</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>
|
||
{floors.map((floor) => (
|
||
<option key={floor.id} value={floor.id}>
|
||
{floor.name}
|
||
</option>
|
||
))}
|
||
</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 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>
|
||
{floors.map((floor) => (
|
||
<option key={floor.id} value={floor.id}>
|
||
{floor.name}
|
||
</option>
|
||
))}
|
||
</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>
|
||
<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}
|
||
|
||
{structureModal === "floor" ? (
|
||
<FormModal
|
||
isSaving={isSaving}
|
||
onClose={() => {
|
||
setStructureModal(null);
|
||
setEditingFloor(null);
|
||
}}
|
||
onSubmit={handleSaveFloor}
|
||
submitDisabled={!floorName.trim()}
|
||
submitLabel={editingFloor ? "Änderungen speichern" : "Etage hinzufügen"}
|
||
title={editingFloor ? "Etage bearbeiten" : "Etage hinzufügen"}
|
||
>
|
||
<label className="form-label" htmlFor="floor-name">
|
||
Bezeichnung
|
||
</label>
|
||
<input
|
||
autoFocus
|
||
className="form-control"
|
||
id="floor-name"
|
||
onChange={(event) => setFloorName(event.target.value)}
|
||
placeholder="z. B. EG"
|
||
required
|
||
value={floorName}
|
||
/>
|
||
{editingFloor ? (
|
||
<div className="border-top mt-4 pt-3">
|
||
<p className="text-secondary small mb-2">
|
||
Etagen mit zugeordneten Räumen oder Verteilungen können nicht gelöscht werden.
|
||
</p>
|
||
<button className="btn btn-outline-danger" disabled={isSaving} onClick={() => void handleDeleteFloor()} type="button">
|
||
Etage löschen
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</FormModal>
|
||
) : null}
|
||
|
||
{structureModal === "room" ? (
|
||
<FormModal
|
||
isSaving={isSaving}
|
||
onClose={() => {
|
||
setStructureModal(null);
|
||
setEditingRoom(null);
|
||
}}
|
||
onSubmit={handleSaveRoom}
|
||
submitDisabled={!roomNumber.trim() || !roomName.trim()}
|
||
submitLabel={editingRoom ? "Änderungen speichern" : "Raum anlegen"}
|
||
title={editingRoom ? "Raum bearbeiten" : "Raum hinzufügen"}
|
||
>
|
||
<div className="row g-3">
|
||
<div className="col-12 col-md-4">
|
||
<label className="form-label" htmlFor="room-number">
|
||
Raumnummer
|
||
</label>
|
||
<input
|
||
autoFocus
|
||
className="form-control"
|
||
id="room-number"
|
||
onChange={(event) => setRoomNumber(event.target.value)}
|
||
required
|
||
value={roomNumber}
|
||
/>
|
||
</div>
|
||
<div className="col-12 col-md-8">
|
||
<label className="form-label" htmlFor="room-name">
|
||
Raumname
|
||
</label>
|
||
<input
|
||
className="form-control"
|
||
id="room-name"
|
||
onChange={(event) => setRoomName(event.target.value)}
|
||
required
|
||
value={roomName}
|
||
/>
|
||
</div>
|
||
<div className="col-12">
|
||
<label className="form-label" htmlFor="room-floor">
|
||
Etage
|
||
</label>
|
||
<select
|
||
className="form-select"
|
||
id="room-floor"
|
||
onChange={(event) => setRoomFloorId(event.target.value)}
|
||
value={roomFloorId}
|
||
>
|
||
<option value="">Ohne Etage</option>
|
||
{floors.map((floor) => (
|
||
<option key={floor.id} value={floor.id}>
|
||
{floor.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
{editingRoom ? (
|
||
<div className="border-top mt-4 pt-3">
|
||
<p className="text-secondary small mb-2">
|
||
In Stromkreisen verwendete Räume können nicht gelöscht werden.
|
||
</p>
|
||
<button className="btn btn-outline-danger" disabled={isSaving} onClick={() => void handleDeleteRoom()} type="button">
|
||
Raum löschen
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</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}
|
||
</main>
|
||
);
|
||
}
|
||
|
||
|