leistungsbilanz-ts/src/app/projects/[projectId]/page.tsx
Julian Appel b45dc5002d Fix code-review findings across domain, persistence, server and frontend
Full-codebase review turned up five real correctness/security bugs and
a dozen smaller inconsistencies; all are fixed here with matching test
coverage:

- BMK uniqueness silently allowed German-umlaut duplicates ("Ä1" vs
  "ä1") because the DB's normalized index only folds ASCII case. Added
  a shared Unicode-aware pre-check used by every circuit/component
  insert and rename path (one of which had no pre-check at all).
- CircuitDeviceRow.simultaneityFactor had no upper bound at the row
  level (command model and snapshot/restore schema), unlike every
  sibling entity, letting a bad value silently corrupt power totals.
- Grid cell editing silently misread German thousands-separator input
  ("1.500" parsed as 1.5); "." is now rejected outright with a clear
  message instead of guessing.
- The editor's shared command runner (runCommand/applyHistory) had no
  re-entrancy guard, so a double click/drop could fire the same
  command twice and race a BMK collision or revision conflict. Added a
  synchronous ref guard plus isSaving on the buttons that lacked it.
- GET .../next-identifier leaked circuit-numbering state for sections
  in other projects (no ownership check, 400 instead of 404). Moved
  under /projects/:projectId and scoped it.

Also: added the missing circuits.section_id / circuit_device_rows.
circuit_id indexes (migration 0006), gave FormModal a focus trap /
Escape-to-close / focus restore and rebuilt ProjectSettingsModal on
top of it instead of duplicated markup, removed dead code (3 orphaned
domain model files, an unused persistence helper, a wrapper only used
by its own test), pointed the project page at GET /projects/:id
instead of listing+filtering client-side, closed the gap between the
documented 18 MB CSV limit and the ~17.17 MiB actually enforced, added
missing upper bounds on several free-text fields, filled in nine
missing German labels in the revision timeline, replaced a
key-order-fragile JSON.stringify equality check with a real field
comparison, made an implicit sort-order assumption in three
renumbering helpers explicit, cleared the sidebar's target selection
when it no longer resolves after a tree reload, and fixed
updateGlobalDevice to check-then-write instead of write-then-check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 21:31:16 +02:00

1638 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { FormEvent, useEffect, useMemo, useState } from "react";
import {
copyGlobalDeviceToProject,
copyDistributionBoard,
copyProjectDeviceToGlobal,
createDistributionBoard,
createFloor,
createProjectDevice,
createRoom,
deleteFloor,
deleteProjectDevice,
deleteRoom,
deleteDistributionBoard,
disconnectProjectDeviceRows,
exportProjectTransfer,
getProject,
getProjectDeviceSyncPreview,
listCircuitLists,
listDistributionBoards,
listFloors,
listGlobalDevices,
listProjectDevices,
listRooms,
importProjectTransfer,
synchronizeProjectDeviceRows,
updateDistributionBoard,
updateFloor,
updateProjectDevice,
updateProjectSettings,
updateRoom,
} from "../../../frontend/utils/api";
import type {
CircuitListDto,
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 {
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 { RoomModal } from "../../../frontend/components/room-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 [editingRoom, setEditingRoom] = useState<RoomDto | null>(null);
const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false);
const [isRevitCsvOpen, setIsRevitCsvOpen] = 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([
getProject(projectId),
listDistributionBoards(projectId),
listCircuitLists(projectId),
listFloors(projectId),
listRooms(projectId),
listProjectDevices(projectId),
listGlobalDevices(),
])
.then(([
currentProject,
distributionBoards,
loadedCircuitLists,
loadedFloors,
loadedRooms,
loadedProjectDevices,
loadedGlobalDevices,
]) => {
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(input: CreateRoomInput) {
if (!projectId || !project) return;
setIsSaving(true);
setError(null);
try {
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);
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);
}
}
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;
}
function openCreateFloor() {
setEditingFloor(null);
setFloorName("");
setStructureModal("floor");
}
function openEditFloor(floor: FloorDto) {
setEditingFloor(floor);
setFloorName(floor.name);
setStructureModal("floor");
}
function openCreateRoom() {
setEditingRoom(null);
setStructureModal("room");
}
function openEditRoom(room: RoomDto) {
setEditingRoom(room);
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);
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={() => setIsRevitCsvOpen(true)}
type="button"
>
Revit-CSV
</button>
<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" ? (
<RoomModal
floors={floors}
initialRoom={editingRoom ?? undefined}
isSaving={isSaving}
onClose={() => {
setStructureModal(null);
setEditingRoom(null);
}}
onDelete={editingRoom ? () => void handleDeleteRoom() : undefined}
onSave={handleSaveRoom}
/>
) : 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>
);
}