Complete Revit object editor assignments

This commit is contained in:
Julian Appel 2026-09-30 14:35:14 +02:00
parent 4489cd76f0
commit 77d775fde1
25 changed files with 853 additions and 77 deletions

View file

@ -120,6 +120,11 @@ import {
deleteCircuitGroupCommand,
deleteCircuitGroupSubtreeCommand,
deleteDistributionBoardComponentCommand,
assignExternalProjectObjectsToRow,
assignExternalProjectObjectsToNewRow,
assignExternalProjectObjectsToNewCircuit,
detachExternalProjectObjectsFromRow,
getExternalProjectObjects,
getCircuitTree,
getNextCircuitIdentifier,
getProjectHistory,
@ -154,6 +159,7 @@ import type {
ProjectCommandResultDto,
ProjectDeviceDto,
ProjectHistoryStateDto,
ExternalProjectObjectsDto,
} from "../types";
import type {
CircuitDeviceRowSnapshot,
@ -330,6 +336,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [cableSizingEditorCircuit, setCableSizingEditorCircuit] =
useState<CircuitTreeCircuitDto | null>(null);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
const [externalObjects, setExternalObjects] = useState<ExternalProjectObjectsDto | null>(null);
const [isRevitDrawerOpen, setIsRevitDrawerOpen] = useState(false);
const [draggingExternalObjectIds, setDraggingExternalObjectIds] = useState<string[]>([]);
const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] =
useState(false);
const [projectDeviceSearch, setProjectDeviceSearch] = useState("");
@ -381,6 +390,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
() => orderedColumns.filter((column) => visibleColumnKeys.includes(column.key)),
[orderedColumns, visibleColumnKeys]
);
const externalObjectGroups = useMemo(() => {
if (!data || !externalObjects) return [];
const groups = new Map<string, typeof externalObjects.objects>();
for (const object of externalObjects.objects) {
if (object.presenceStatus !== "present" || object.circuitDeviceRowId !== null || object.distributionBoardId !== data.distributionBoardId) continue;
const key = JSON.stringify([
object.roomId, object.selectionMarker, object.deviceTypeId,
object.planningValues.category, object.linkedProjectDeviceId,
object.planningValues,
]);
const entries = groups.get(key) ?? [];
entries.push(object);
groups.set(key, entries);
}
return [...groups.values()].map((objects) => ({
key: objects.map((object) => object.id).sort().join("|"),
objects,
quantity: objects.reduce((sum, object) => sum + object.planningValues.effectiveQuantity, 0),
}));
}, [data, externalObjects]);
async function loadTree(options?: { showLoading?: boolean }) {
const showLoading = options?.showLoading ?? false;
@ -389,13 +418,17 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
setError(null);
try {
const { tree, history } = await loadCircuitEditorSnapshot(
() => getCircuitTree(projectId, circuitListId),
() => getProjectHistory(projectId)
);
const [{ tree, history }, external] = await Promise.all([
loadCircuitEditorSnapshot(
() => getCircuitTree(projectId, circuitListId),
() => getProjectHistory(projectId)
),
getExternalProjectObjects(projectId),
]);
projectRevisionRef.current = history.currentRevision;
setHistoryState(history);
setData(tree);
setExternalObjects(external);
} catch (err) {
setError(normalizeUiError(err));
} finally {
@ -1321,12 +1354,20 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const summary = subtree
? summarizeCircuitGroupSubtree(subtree)
: null;
const externalObjectCount = section.circuits.reduce(
(total, circuit) => total + circuit.deviceRows.reduce(
(rowTotal, row) => rowTotal + (row.externalObjectCount ?? 0), 0
), 0
);
const externalWarning = externalObjectCount > 0
? `\n\n${externalObjectCount} Revit-Objekte werden wieder unzugeordnet.`
: "";
const warning = isEmpty
? `Leere Stromkreisgruppe „${section.displayName}“ entfernen?`
: `Stromkreisgruppe „${section.displayName}“ vollständig entfernen?\n\n` +
`Dabei werden ${summary!.circuitCount} Stromkreis(e), ${summary!.deviceRowCount} Gerätezeile(n) und ${summary!.protectionComponentCount} Gruppenschutzgerät(e) gelöscht.\n\n` +
"Die Änderung kann über die projektweite Versionshistorie rückgängig gemacht werden.";
if (!confirm(warning)) {
if (!confirm(warning + externalWarning)) {
return;
}
await runCommand({
@ -2878,11 +2919,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError("Die Gerätezeile ist ungültig.");
return;
}
const externalWarning = (rowSnapshot.externalObjectCount ?? 0) > 0
? `\n\n${rowSnapshot.externalObjectCount} Revit-Objekte werden wieder unzugeordnet.`
: "";
if (sourceCircuit.deviceRows.length === 1) {
const keepAsReserve = confirm(
`„${rowSnapshot.displayName}“ ist das letzte Gerät in ${sourceCircuit.equipmentIdentifier}.\n\n` +
"OK: Gerät löschen und den leeren Stromkreis als Reserve behalten.\n" +
"Abbrechen: anschließend entscheiden, ob stattdessen der gesamte Stromkreis gelöscht werden soll."
"Abbrechen: anschließend entscheiden, ob stattdessen der gesamte Stromkreis gelöscht werden soll." +
externalWarning
);
if (!keepAsReserve) {
const deleteCircuit = confirm(
@ -2894,7 +2939,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
return;
}
} else if (!confirm(`Gerätezeile „${rowSnapshot.displayName}“ löschen?`)) {
} else if (!confirm(`Gerätezeile „${rowSnapshot.displayName}“ löschen?${externalWarning}`)) {
return;
}
await runCommand({
@ -2917,11 +2962,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError("Der Stromkreis ist ungültig.");
return;
}
const externalObjectCount = circuit.deviceRows.reduce(
(total, row) => total + (row.externalObjectCount ?? 0), 0
);
const externalWarning = externalObjectCount > 0
? `\n\n${externalObjectCount} Revit-Objekte werden wieder unzugeordnet.`
: "";
if (
askForConfirmation &&
!confirm(
`Stromkreis ${circuit.equipmentIdentifier} und ${circuit.deviceRows.length} zugeordnete Gerätezeile(n) löschen?\n\n` +
"Bestehende Betriebsmittelkennzeichen werden nicht neu nummeriert."
"Bestehende Betriebsmittelkennzeichen werden nicht neu nummeriert." +
externalWarning
)
) {
return;
@ -2958,6 +3010,56 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await handleDeleteCircuit(intent.circuitId);
}
async function handleAssignExternalObjects(rowId: string, objectIds: string[]) {
if (!confirm(`${objectIds.length} Revit-Objekt(e) dieser Gerätezeile zuordnen?\n\nAbweichende Planungswerte werden ausdrücklich zugunsten der Gerätezeile bestätigt.`)) return;
await runCommand({
label: "Revit-Objekte zuordnen",
redo: async () => {
const result = await assignExternalProjectObjectsToRow(
projectId,
getExpectedProjectRevision(),
objectIds,
rowId,
objectIds
);
applyProjectCommandResult(result);
setDraggingExternalObjectIds([]);
return null;
},
});
}
async function handleAssignExternalObjectsToNewRow(circuitId: string, objectIds: string[]) {
if (!confirm(`${objectIds.length} Revit-Objekt(e) als neue Gerätezeile anlegen?`)) return;
await runCommand({ label: "Revit-Objekte als Gerätezeile zuordnen", redo: async () => {
const result = await assignExternalProjectObjectsToNewRow(projectId, getExpectedProjectRevision(), objectIds, circuitId);
applyProjectCommandResult(result); setDraggingExternalObjectIds([]); return null;
}});
}
async function handleAssignExternalObjectsToNewCircuit(sectionId: string, objectIds: string[]) {
if (!confirm(`${objectIds.length} Revit-Objekt(e) als neuen Stromkreis anlegen?`)) return;
await runCommand({ label: "Revit-Objekte als Stromkreis zuordnen", redo: async () => {
const result = await assignExternalProjectObjectsToNewCircuit(projectId, getExpectedProjectRevision(), objectIds, sectionId);
applyProjectCommandResult(result); setDraggingExternalObjectIds([]); return null;
}});
}
async function handleDetachExternalObjects(rowId: string) {
const objectIds = externalObjects?.objects
.filter((object) => object.circuitDeviceRowId === rowId)
.map((object) => object.id) ?? [];
if (objectIds.length === 0 || !confirm(`${objectIds.length} Revit-Objekt(e) lösen?\n\nDie aktuellen Werte der Gerätezeile werden als Planungswerte und Overrides übernommen.`)) return;
await runCommand({
label: "Revit-Objekte lösen",
redo: async () => {
const result = await detachExternalProjectObjectsFromRow(projectId, getExpectedProjectRevision(), objectIds);
applyProjectCommandResult(result);
return null;
},
});
}
// Explicit renumber action through project history. Sorting and device rows
// remain unchanged; the server handles collision-safe identifier swaps.
async function handleRenumberSection(sectionId: string) {
@ -3381,6 +3483,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
showLabel
/>
<IconButton
ariaControls="revit-object-drawer"
ariaExpanded={isRevitDrawerOpen}
className="project-device-drawer-toggle"
icon={isRevitDrawerOpen ? PanelRightClose : PanelRightOpen}
label={isRevitDrawerOpen ? "Revit-Objekte schließen" : `Revit-Objekte öffnen (${externalObjectGroups.reduce((sum, group) => sum + group.objects.length, 0)})`}
onClick={() => setIsRevitDrawerOpen((current) => !current)}
showLabel
/>
<IconButton
ariaControls="project-device-drawer"
ariaExpanded={isProjectDeviceDrawerOpen}
@ -3419,6 +3530,62 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
) : null}
{isSaving ? <div className="notice info">Wird gespeichert …</div> : null}
<div className="tree-editor-layout">
{isRevitDrawerOpen ? (
<aside className="project-device-sidebar project-device-drawer" id="revit-object-drawer">
<div className="project-device-drawer-header">
<div>
<h3>Revit-Objekte</h3>
<span>{externalObjectGroups.reduce((sum, group) => sum + group.objects.length, 0)} unzugeordnet</span>
</div>
<button type="button" onClick={() => setIsRevitDrawerOpen(false)}>Schließen</button>
</div>
<div className="project-device-list">
{externalObjectGroups.map((group) => {
const first = group.objects[0]!;
return (
<details
key={group.key}
className={`project-device-item ${draggingExternalObjectIds[0] === first.id ? "dragging" : ""}`}
>
<summary
draggable
onDragStart={(event) => {
const ids = group.objects.map((object) => object.id);
setDraggingExternalObjectIds(ids);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("application/x-revit-object-ids", JSON.stringify(ids));
}}
onDragEnd={() => setDraggingExternalObjectIds([])}
>
<strong>{first.planningValues.displayName || first.selectionMarker || first.familyAndType}</strong>
<span>{first.sourceRoomNumber} {first.sourceRoomName}</span>
<span>{group.objects.length} Objekt(e), Menge {formatValue(group.quantity, "quantity")}</span>
<span>{first.familyAndType}</span>
</summary>
{group.objects.map((object) => (
<button
key={object.id}
type="button"
draggable
className="btn btn-sm btn-outline-secondary mt-2 w-100 text-start"
onDragStart={(event) => {
setDraggingExternalObjectIds([object.id]);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("application/x-revit-object-ids", JSON.stringify([object.id]));
event.stopPropagation();
}}
onDragEnd={() => setDraggingExternalObjectIds([])}
>
{object.worksharingId}
</button>
))}
</details>
);
})}
{externalObjectGroups.length === 0 ? <p className="notice muted">Keine unzugeordneten Revit-Objekte für diesen Verteiler.</p> : null}
</div>
</aside>
) : null}
{isProjectDeviceDrawerOpen ? (
<aside
className="project-device-sidebar project-device-drawer"
@ -4202,6 +4369,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}`}
onClick={() => setActiveSectionId(row.sectionId)}
onDragOver={(event) => {
if (draggingExternalObjectIds.length > 0 && (row.device || row.circuit || row.rowType === "placeholder")) {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
return;
}
if (draggingCircuitCount > 0) {
const valid = isCircuitDropTargetValid(
row.sectionId
@ -4342,6 +4514,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
});
}}
onDrop={(event) => {
if (draggingExternalObjectIds.length > 0 && row.device) {
event.preventDefault();
void handleAssignExternalObjects(row.device.id, draggingExternalObjectIds);
return;
}
if (draggingExternalObjectIds.length > 0 && row.rowType === "placeholder") {
event.preventDefault();
void handleAssignExternalObjectsToNewCircuit(row.sectionId, draggingExternalObjectIds);
return;
}
if (draggingExternalObjectIds.length > 0 && row.circuit) {
event.preventDefault();
void handleAssignExternalObjectsToNewRow(row.circuit.id, draggingExternalObjectIds);
return;
}
if (draggingCircuitCount > 0) {
if (row.rowType === "placeholder") {
void handleCircuitReorderDrop(event, {
@ -4728,14 +4915,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</>
) : null}
{row.device ? (
<IconButton
disabled={isSaving}
icon={ListX}
label="Gerät löschen"
onClick={() => void handleDeleteDevice(row.device!.id)}
tabIndex={-1}
variant="danger"
/>
<>
{(row.device.externalObjectCount ?? 0) > 0 ? (
<button
type="button"
className="badge rounded-pill text-bg-info border-0"
title="Verknüpfte Revit-Objekte anzeigen und lösen"
onClick={() => void handleDetachExternalObjects(row.device!.id)}
>
Revit {row.device.externalObjectCount}
</button>
) : null}
<IconButton
disabled={isSaving}
icon={ListX}
label="Gerät löschen"
onClick={() => void handleDeleteDevice(row.device!.id)}
tabIndex={-1}
variant="danger"
/>
</>
) : null}
{dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? (
<span className="drop-hint">