From 4489cd76f0fb5b38a0399d4cac59b75a05a6d85e Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Wed, 30 Sep 2026 13:51:43 +0200 Subject: [PATCH 1/3] Allow saving default Revit settings --- src/app/projects/[projectId]/revit/page.tsx | 2 +- src/frontend/components/revit-xlsx-settings-modal.tsx | 5 +++-- tests/revit-xlsx-settings-api.test.ts | 2 ++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/app/projects/[projectId]/revit/page.tsx b/src/app/projects/[projectId]/revit/page.tsx index fc8b85e..46c3d5d 100644 --- a/src/app/projects/[projectId]/revit/page.tsx +++ b/src/app/projects/[projectId]/revit/page.tsx @@ -45,7 +45,7 @@ export default function RevitPage() { {error ?
{error}
: null}

XLSX-Konfiguration

{snapshot ? "Die Linear-Spalten und das Tabellenlayout sind konfiguriert." : "Noch keine XLSX-Konfiguration gespeichert."}

{snapshot ? "Konfiguriert" : "Offen"}

Importstatus

{importStatus?.imported ? <>

{importStatus.objectCount} Revit-Objekte{importStatus.fileName ? ` aus ${importStatus.fileName}` : ""}

Quelle: {importStatus.sourceName}

:

Noch kein Linear-XLSX-Import vorhanden.

}
{importStatus?.imported ? setProject((current) => current ? { ...current, currentRevision: revision } : current)} /> : null} - {settingsOpen ? setSettingsOpen(false)} onInspectSample={async (file, configuration) => { const result = await detectExternalModelWorkbook(projectId, file, configuration); return `Muster erkannt: ${result.sheetName}, ${result.columnCount} Spalten, ${result.objectCount} Objekte.`; }} onSave={save} /> : null} + {settingsOpen ? setSettingsOpen(false)} onInspectSample={async (file, configuration) => { const result = await detectExternalModelWorkbook(projectId, file, configuration); return `Muster erkannt: ${result.sheetName}, ${result.columnCount} Spalten, ${result.objectCount} Objekte.`; }} onSave={save} /> : null} {importOpen && project && snapshot ? setImportOpen(false)} onImported={({ history }) => { setProject({ ...project, currentRevision: history.currentRevision }); setImportOpen(false); void Promise.all([getExternalInitialImportStatus(projectId), getExternalModelConfiguration(projectId)]).then(([status, configuration]) => { setImportStatus(status); setSnapshot(configuration.configuration); }); }} /> : null} ; } diff --git a/src/frontend/components/revit-xlsx-settings-modal.tsx b/src/frontend/components/revit-xlsx-settings-modal.tsx index 7041ebc..899c3ca 100644 --- a/src/frontend/components/revit-xlsx-settings-modal.tsx +++ b/src/frontend/components/revit-xlsx-settings-modal.tsx @@ -13,8 +13,9 @@ const labels: Record = { electricalPowerW: "Elektrische Leistung", voltageV: "Spannung", currentA: "Stromstärke", }; -export function RevitXlsxSettingsModal({ configuration, isSaving, onClose, onInspectSample, onSave }: { +export function RevitXlsxSettingsModal({ configuration, isNewConfiguration = false, isSaving, onClose, onInspectSample, onSave }: { configuration: ExternalModelConfiguration; + isNewConfiguration?: boolean; isSaving: boolean; onClose: () => void; onInspectSample: (file: File, configuration: ExternalModelConfiguration) => Promise; @@ -33,7 +34,7 @@ export function RevitXlsxSettingsModal({ configuration, isSaving, onClose, onIns } } - return { event.preventDefault(); void onSave(draft); }}> + return { event.preventDefault(); void onSave(draft); }}>
void inspectSample(event.target.files?.[0])} />{sampleStatus ?
{sampleStatus}
: null}
{(["headerRow", "firstDataRow", "firstDataColumn"] as const).map((key) =>
setDraft({ ...draft, layout: { ...draft.layout, [key]: Number(event.target.value) } })} />
)} diff --git a/tests/revit-xlsx-settings-api.test.ts b/tests/revit-xlsx-settings-api.test.ts index ed91b57..4f13b70 100644 --- a/tests/revit-xlsx-settings-api.test.ts +++ b/tests/revit-xlsx-settings-api.test.ts @@ -59,6 +59,8 @@ describe("Revit XLSX settings", () => { assert.match(revitPage, /Importstatus/); assert.match(settings, /Musterdatei prüfen/); assert.match(settings, /Kombinierte Stromkreisspalte/); + assert.match(settings, /isNewConfiguration/); + assert.match(revitPage, /isNewConfiguration={snapshot === null}/); assert.match(wizard, /Schritt.*von/); assert.match(wizard, /keine Gerätezeilen, Stromkreise oder BMKs/); assert.match(wizard, /GZF 1/); From 77d775fde11224b4297de9206c5f89633ef36d8f Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Wed, 30 Sep 2026 14:35:14 +0200 Subject: [PATCH 2/3] Complete Revit object editor assignments --- .../revit-excel-integration-requirements.md | 10 + docs/spec/revit-excel-phase-14-plan.md | 2 +- src/app/globals.css | 11 + ...ow-structure-project-command.repository.ts | 53 ++-- ...roup-subtree-project-command.repository.ts | 41 +++- ...it-structure-project-command.repository.ts | 65 +++-- ...rd-structure-project-command.repository.ts | 2 + ...oard-subtree-project-command.repository.ts | 2 + .../external-object-assignment.persistence.ts | 29 ++- ...ernal-object-deletion-guard.persistence.ts | 37 +++ ...on-structure-project-command.repository.ts | 2 + ...ice-row-structure-project-command.model.ts | 55 ++++- ...uit-group-subtree-project-command.model.ts | 15 +- ...circuit-structure-project-command.model.ts | 16 +- src/domain/models/circuit-tree.model.ts | 1 + ...bject-new-circuit-project-command.model.ts | 4 +- ...al-object-new-row-project-command.model.ts | 4 +- ...ct-row-assignment-project-command.model.ts | 19 +- .../components/circuit-tree-editor.tsx | 231 ++++++++++++++++-- src/frontend/types.ts | 1 + src/frontend/utils/api.ts | 26 ++ .../controllers/circuit-tree.controller.ts | 9 + .../external-objects.controller.ts | 223 +++++++++++++++++ src/server/routes/project.routes.ts | 6 +- ...ignment-project-command.repository.test.ts | 66 +++++ 25 files changed, 853 insertions(+), 77 deletions(-) create mode 100644 src/db/repositories/external-object-deletion-guard.persistence.ts diff --git a/docs/spec/revit-excel-integration-requirements.md b/docs/spec/revit-excel-integration-requirements.md index 29a4aaf..5add6fb 100644 --- a/docs/spec/revit-excel-integration-requirements.md +++ b/docs/spec/revit-excel-integration-requirements.md @@ -306,6 +306,16 @@ aktuellen Row-Werte als Planungswerte und entsprechende Overrides. ## 11. Editor-Zuordnung und Löschen +Umsetzungsstand R7: Der Stromkreislisteneditor lädt die kompakte Projektliste +gemeinsam mit dem Baum, gruppiert unzugeordnete Objekte des aktuellen +Verteilers im Client und bietet Gruppen sowie einzelne Worksharing-IDs als +Drag-Quelle an. Drop auf Row, Circuit und freien Gruppenbereich verwendet die +drei persistenten Zuordnungscommands. Row-Badges zeigen die Linkzahl; Lösen +kopiert die aktuellen Row-Planungswerte als Overrides. Row-, Circuit- und +Gruppenlöschungen speichern die vollständigen Linkübergänge im Forward- und +Inverse-Command. Raum- und Verteilerlöschungen prüfen externe Referenzen +explizit. + Der rechte, überlagernde Editorbereich zeigt unzugeordnete konfliktfreie Objekte des aktuellen Verteilers. Gruppierungsschlüssel sind interner Raum, Auswahlkenner, Geräteart, Kategorie, identische Planungswerte und kompatibler diff --git a/docs/spec/revit-excel-phase-14-plan.md b/docs/spec/revit-excel-phase-14-plan.md index 9b43c4f..700db4c 100644 --- a/docs/spec/revit-excel-phase-14-plan.md +++ b/docs/spec/revit-excel-phase-14-plan.md @@ -15,7 +15,7 @@ Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben. | R4 | abgeschlossen | Revit-Seite, Navigation und revisionierte XLSX-Einstellungen mit Musterdateiprüfung sind umgesetzt; das alte Projektseitenmodal ist entfernt. | | R5 | abgeschlossen | Zustandslose XLSX-Vorschau, sechsstufiger Erstimportwizard und atomarer Importcommand mit parserfreiem Undo/Redo. | | R6 | abgeschlossen | Projektweite kompakte Objektliste, clientseitige Filter/Sortierung, Virtualisierung und revisionierte Sammeländerungen. | -| R7 | offen | Editorbereich, Gruppierung, Drag-and-drop, Lösen und integrierte Löschübergänge. | +| R7 | abgeschlossen | Gruppierter Editorbereich, drei Drag-and-drop-Ziele, Row-Indikator, ausdrückliches Lösen und atomare Löschübergänge mit exaktem Undo/Redo. | | R8 | offen | Rückgabe-Excel und append-only Exportnachweis. | | R9 | offen | Vollständiger Folgeimport nach Worksharing-ID. | | R10 | offen | Ende-zu-Ende-Härtung, große Datenmengen und Abschlussdokumentation. | diff --git a/src/app/globals.css b/src/app/globals.css index 8b55c75..67985f4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1128,6 +1128,17 @@ a.kpi:hover { border-style: dashed; } +.project-device-item > summary { + display: flex; + flex-direction: column; + gap: 0.1rem; + cursor: grab; +} + +.project-device-item > summary::marker { + color: var(--text-faint); +} + .sidebar-actions { display: flex; flex-direction: column; diff --git a/src/db/repositories/circuit-device-row-structure-project-command.repository.ts b/src/db/repositories/circuit-device-row-structure-project-command.repository.ts index 6919d20..f69dc78 100644 --- a/src/db/repositories/circuit-device-row-structure-project-command.repository.ts +++ b/src/db/repositories/circuit-device-row-structure-project-command.repository.ts @@ -23,8 +23,15 @@ import { toCircuitDeviceRowInsertValues, toCircuitDeviceRowSnapshot, } from "./circuit-device-row-structure.persistence.js"; -import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; +import { executeProjectCommandTransactionWithAppliedForward, type AppliedProjectCommand } from "./project-command-transaction.persistence.js"; import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js"; +import { + applyExternalObjectLinks, + captureExternalObjectDetachTransitions, + invertExternalObjectLinkTransitions, + loadExpectedExternalObjectTransitions, + type ExternalObjectLinkTransition, +} from "./external-object-assignment.persistence.js"; export class CircuitDeviceRowStructureProjectCommandRepository implements CircuitDeviceRowStructureProjectCommandStore @@ -32,7 +39,11 @@ export class CircuitDeviceRowStructureProjectCommandRepository constructor(private readonly database: AppDatabase) {} execute(input: ExecuteCircuitDeviceRowStructureCommandInput) { - return executeProjectCommandTransaction( + return executeProjectCommandTransactionWithAppliedForward< + CircuitDeviceRowStructureProjectCommand, + CircuitDeviceRowStructureProjectCommand, + CircuitDeviceRowStructureProjectCommand + >( this.database, input, (tx) => @@ -50,14 +61,15 @@ export class CircuitDeviceRowStructureProjectCommandRepository projectId: string, source: ExecuteCircuitDeviceRowStructureCommandInput["source"], command: CircuitDeviceRowStructureProjectCommand - ): CircuitDeviceRowStructureProjectCommand { + ): AppliedProjectCommand { if (command.type === circuitDeviceRowInsertCommandType) { assertCircuitDeviceRowInsertProjectCommand(command); return this.insert( database, projectId, source, - command.payload.row + command.payload.row, + command.payload.externalObjects ?? [] ); } if (command.type === circuitDeviceRowDeleteCommandType) { @@ -66,7 +78,8 @@ export class CircuitDeviceRowStructureProjectCommandRepository database, projectId, command.payload.rowId, - command.payload.expectedCircuitId + command.payload.expectedCircuitId, + command.payload.externalObjects ); } throw new Error("Unsupported circuit device-row structure command."); @@ -76,7 +89,8 @@ export class CircuitDeviceRowStructureProjectCommandRepository database: AppDatabase, projectId: string, source: ExecuteCircuitDeviceRowStructureCommandInput["source"], - row: CircuitDeviceRowSnapshot + row: CircuitDeviceRowSnapshot, + externalObjects: ExternalObjectLinkTransition[] ) { if (source === "user" && !isElectricalPhaseType(row.phaseType)) { throw new Error("Device-row phase type is invalid."); @@ -107,17 +121,23 @@ export class CircuitDeviceRowStructureProjectCommandRepository } updateDerivedCircuitVoltage(database, projectId, row.circuitId); - return createCircuitDeviceRowDeleteProjectCommand( - row.id, - row.circuitId - ); + if (externalObjects.length > 0) { + loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Wiederherstellen geändert."); + applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Wiederherstellen geändert."); + } + + return { + forward: createCircuitDeviceRowInsertProjectCommand(row, externalObjects), + inverse: createCircuitDeviceRowDeleteProjectCommand(row.id, row.circuitId, invertExternalObjectLinkTransitions(externalObjects)), + }; } private delete( database: AppDatabase, projectId: string, rowId: string, - expectedCircuitId: string + expectedCircuitId: string, + suppliedExternalObjects?: ExternalObjectLinkTransition[] ) { const row = database .select() @@ -130,6 +150,10 @@ export class CircuitDeviceRowStructureProjectCommandRepository ); } this.assertCircuitInProject(database, projectId, row.circuitId); + const externalObjects = suppliedExternalObjects ?? + captureExternalObjectDetachTransitions(database, projectId, [rowId]); + loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Löschen geändert."); + applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Löschen geändert."); const result = database .delete(circuitDeviceRows) @@ -164,9 +188,10 @@ export class CircuitDeviceRowStructureProjectCommandRepository expectedCircuitId ); - return createCircuitDeviceRowInsertProjectCommand( - toCircuitDeviceRowSnapshot(row) - ); + return { + forward: createCircuitDeviceRowDeleteProjectCommand(rowId, expectedCircuitId, externalObjects), + inverse: createCircuitDeviceRowInsertProjectCommand(toCircuitDeviceRowSnapshot(row), invertExternalObjectLinkTransitions(externalObjects)), + }; } private assertCircuitInProject( diff --git a/src/db/repositories/circuit-group-subtree-project-command.repository.ts b/src/db/repositories/circuit-group-subtree-project-command.repository.ts index 9bf27a0..b4baeb6 100644 --- a/src/db/repositories/circuit-group-subtree-project-command.repository.ts +++ b/src/db/repositories/circuit-group-subtree-project-command.repository.ts @@ -25,8 +25,14 @@ import { circuits } from "../schema/circuits.js"; import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js"; import { distributionBoardComponents } from "../schema/distribution-board-components.js"; import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js"; -import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; +import { executeProjectCommandTransactionWithAppliedForward, type AppliedProjectCommand } from "./project-command-transaction.persistence.js"; import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js"; +import { + applyExternalObjectLinks, + captureExternalObjectDetachTransitions, + invertExternalObjectLinkTransitions, + loadExpectedExternalObjectTransitions, +} from "./external-object-assignment.persistence.js"; export class CircuitGroupSubtreeProjectCommandRepository implements CircuitGroupSubtreeProjectCommandStore @@ -43,7 +49,11 @@ export class CircuitGroupSubtreeProjectCommandRepository } else { assertCircuitGroupRestoreSubtreeProjectCommand(input.command); } - return executeProjectCommandTransaction( + return executeProjectCommandTransactionWithAppliedForward< + CircuitGroupSubtreeProjectCommand, + CircuitGroupSubtreeProjectCommand, + CircuitGroupSubtreeProjectCommand + >( this.database, input, (tx) => this.applyCommand(tx, input.projectId, input.command) @@ -54,7 +64,7 @@ export class CircuitGroupSubtreeProjectCommandRepository database: AppDatabase, projectId: string, command: CircuitGroupSubtreeProjectCommand - ) { + ): AppliedProjectCommand { if (command.type === circuitGroupDeleteSubtreeCommandType) { const current = this.captureFrom( database, @@ -71,6 +81,14 @@ export class CircuitGroupSubtreeProjectCommandRepository projectId, current.circuits.map((entry) => entry.circuit.id) ); + const externalObjects = command.payload.externalObjects ?? + captureExternalObjectDetachTransitions( + database, + projectId, + current.circuits.flatMap((entry) => entry.circuit.deviceRows.map((row) => row.id)) + ); + loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Löschen geändert."); + applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Löschen geändert."); const deleted = database .delete(circuitSections) .where(eq(circuitSections.id, current.group.id)) @@ -78,15 +96,20 @@ export class CircuitGroupSubtreeProjectCommandRepository if (deleted.changes !== 1) { throw new Error("Circuit-group subtree could not be deleted."); } - return createCircuitGroupRestoreSubtreeProjectCommand( - command.payload.snapshot - ); + return { + forward: createCircuitGroupDeleteSubtreeProjectCommand(command.payload.snapshot, externalObjects), + inverse: createCircuitGroupRestoreSubtreeProjectCommand(command.payload.snapshot, invertExternalObjectLinkTransitions(externalObjects)), + }; } if (command.type === circuitGroupRestoreSubtreeCommandType) { this.restore(database, projectId, command.payload.snapshot); - return createCircuitGroupDeleteSubtreeProjectCommand( - command.payload.snapshot - ); + const externalObjects = command.payload.externalObjects ?? []; + loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Wiederherstellen geändert."); + applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Wiederherstellen geändert."); + return { + forward: createCircuitGroupRestoreSubtreeProjectCommand(command.payload.snapshot, externalObjects), + inverse: createCircuitGroupDeleteSubtreeProjectCommand(command.payload.snapshot, invertExternalObjectLinkTransitions(externalObjects)), + }; } throw new Error("Unsupported circuit-group subtree command."); } diff --git a/src/db/repositories/circuit-structure-project-command.repository.ts b/src/db/repositories/circuit-structure-project-command.repository.ts index 4841616..f4fc420 100644 --- a/src/db/repositories/circuit-structure-project-command.repository.ts +++ b/src/db/repositories/circuit-structure-project-command.repository.ts @@ -25,10 +25,17 @@ import { toCircuitDeviceRowInsertValues, toCircuitDeviceRowSnapshot, } from "./circuit-device-row-structure.persistence.js"; -import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; +import { executeProjectCommandTransactionWithAppliedForward, type AppliedProjectCommand } from "./project-command-transaction.persistence.js"; import { assertCircuitsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js"; import { resolveCircuitVoltage } from "./project-voltage.persistence.js"; import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqueness.persistence.js"; +import { + applyExternalObjectLinks, + captureExternalObjectDetachTransitions, + invertExternalObjectLinkTransitions, + loadExpectedExternalObjectTransitions, + type ExternalObjectLinkTransition, +} from "./external-object-assignment.persistence.js"; export class CircuitStructureProjectCommandRepository implements CircuitStructureProjectCommandStore @@ -36,11 +43,15 @@ export class CircuitStructureProjectCommandRepository constructor(private readonly database: AppDatabase) {} execute(input: ExecuteCircuitStructureCommandInput) { - return executeProjectCommandTransaction( + return executeProjectCommandTransactionWithAppliedForward< + CircuitStructureProjectCommand, + CircuitStructureProjectCommand, + CircuitStructureProjectCommand + >( this.database, input, (tx) => - this.applyInTransaction( + this.applyWithForward( tx, input.projectId, input.source, @@ -59,13 +70,23 @@ export class CircuitStructureProjectCommandRepository source: ExecuteCircuitStructureCommandInput["source"], command: CircuitStructureProjectCommand ): CircuitStructureProjectCommand { + return this.applyWithForward(database, projectId, source, command).inverse; + } + + private applyWithForward( + database: AppDatabase, + projectId: string, + source: ExecuteCircuitStructureCommandInput["source"], + command: CircuitStructureProjectCommand + ): AppliedProjectCommand { if (command.type === circuitInsertCommandType) { assertCircuitInsertProjectCommand(command); return this.insert( database, projectId, source, - command.payload.circuit + command.payload.circuit, + command.payload.externalObjects ?? [] ); } if (command.type === circuitDeleteCommandType) { @@ -74,7 +95,8 @@ export class CircuitStructureProjectCommandRepository database, projectId, command.payload.circuitId, - command.payload.expectedCircuitListId + command.payload.expectedCircuitListId, + command.payload.externalObjects ); } throw new Error("Unsupported circuit structure command."); @@ -84,7 +106,8 @@ export class CircuitStructureProjectCommandRepository database: AppDatabase, projectId: string, source: ExecuteCircuitStructureCommandInput["source"], - snapshot: CircuitSnapshot + snapshot: CircuitSnapshot, + externalObjects: ExternalObjectLinkTransition[] ) { this.assertCircuitLocation(database, projectId, snapshot); if (source === "user") { @@ -172,17 +195,23 @@ export class CircuitStructureProjectCommandRepository .run(); } - return createCircuitDeleteProjectCommand( - snapshot.id, - snapshot.circuitListId - ); + if (externalObjects.length > 0) { + loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Wiederherstellen geändert."); + applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Wiederherstellen geändert."); + } + + return { + forward: createCircuitInsertProjectCommand(snapshot, externalObjects), + inverse: createCircuitDeleteProjectCommand(snapshot.id, snapshot.circuitListId, invertExternalObjectLinkTransitions(externalObjects)), + }; } private delete( database: AppDatabase, projectId: string, circuitId: string, - expectedCircuitListId: string + expectedCircuitListId: string, + suppliedExternalObjects?: ExternalObjectLinkTransition[] ) { const circuit = database .select() @@ -220,7 +249,12 @@ export class CircuitStructureProjectCommandRepository .from(circuitProtectionDevices) .where(eq(circuitProtectionDevices.circuitId, circuit.id)) .get(); - const inverse = createCircuitInsertProjectCommand({ + const externalObjects = suppliedExternalObjects ?? captureExternalObjectDetachTransitions( + database, projectId, rows.map((row) => row.id) + ); + loadExpectedExternalObjectTransitions(database, projectId, externalObjects, "Revit-Objekt wurde vor dem Löschen geändert."); + applyExternalObjectLinks(database, externalObjects, "Revit-Objekt wurde beim Löschen geändert."); + const circuitSnapshot: CircuitSnapshot = { id: circuit.id, circuitListId: circuit.circuitListId, sectionId: circuit.sectionId, @@ -239,7 +273,7 @@ export class CircuitStructureProjectCommandRepository remark: circuit.remark, deviceRows: rows.map(toCircuitDeviceRowSnapshot), protectionDevice: protectionDevice ?? null, - }); + }; assertCircuitsAreNotReferencedAsFeeders(database, projectId, [circuit.id]); @@ -255,7 +289,10 @@ export class CircuitStructureProjectCommandRepository if (result.changes !== 1) { throw new Error("Circuit could not be deleted."); } - return inverse; + return { + forward: createCircuitDeleteProjectCommand(circuitId, expectedCircuitListId, externalObjects), + inverse: createCircuitInsertProjectCommand(circuitSnapshot, invertExternalObjectLinkTransitions(externalObjects)), + }; } private assertCircuitLocation( diff --git a/src/db/repositories/distribution-board-structure-project-command.repository.ts b/src/db/repositories/distribution-board-structure-project-command.repository.ts index 115705b..f7003c5 100644 --- a/src/db/repositories/distribution-board-structure-project-command.repository.ts +++ b/src/db/repositories/distribution-board-structure-project-command.repository.ts @@ -46,6 +46,7 @@ import { floors } from "../schema/floors.js"; import { projects } from "../schema/projects.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js"; +import { assertDistributionBoardHasNoExternalObjects } from "./external-object-deletion-guard.persistence.js"; import { CircuitStructureProjectCommandRepository } from "./circuit-structure-project-command.repository.js"; export class DistributionBoardStructureProjectCommandRepository @@ -290,6 +291,7 @@ export class DistributionBoardStructureProjectCommandRepository } assertBoardsAreNotReferencedAsFeeders(database, projectId, [board.id]); + assertDistributionBoardHasNoExternalObjects(database, projectId, board.id); const deleted = database .delete(distributionBoards) diff --git a/src/db/repositories/distribution-board-subtree-project-command.repository.ts b/src/db/repositories/distribution-board-subtree-project-command.repository.ts index 969c287..b32fee5 100644 --- a/src/db/repositories/distribution-board-subtree-project-command.repository.ts +++ b/src/db/repositories/distribution-board-subtree-project-command.repository.ts @@ -33,6 +33,7 @@ import { rooms } from "../schema/rooms.js"; import { toCircuitDeviceRowInsertValues } from "./circuit-device-row-structure.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { assertBoardsAreNotReferencedAsFeeders } from "./distribution-board-feeding-deletion-guard.persistence.js"; +import { assertDistributionBoardHasNoExternalObjects } from "./external-object-deletion-guard.persistence.js"; export class DistributionBoardSubtreeProjectCommandRepository implements DistributionBoardSubtreeProjectCommandStore @@ -86,6 +87,7 @@ export class DistributionBoardSubtreeProjectCommandRepository assertBoardsAreNotReferencedAsFeeders(database, projectId, [ current.distributionBoard.id, ]); + assertDistributionBoardHasNoExternalObjects(database, projectId, current.distributionBoard.id); const deleted = database .delete(distributionBoards) .where( diff --git a/src/db/repositories/external-object-assignment.persistence.ts b/src/db/repositories/external-object-assignment.persistence.ts index 1111730..505481d 100644 --- a/src/db/repositories/external-object-assignment.persistence.ts +++ b/src/db/repositories/external-object-assignment.persistence.ts @@ -11,6 +11,27 @@ export interface ExternalObjectLinkTransition { target: ExternalModelObjectSnapshot; } +export function captureExternalObjectDetachTransitions( + database: AppDatabase, + projectId: string, + rowIds: string[] +): ExternalObjectLinkTransition[] { + if (rowIds.length === 0) return []; + return database.select().from(externalModelObjects) + .where(and( + eq(externalModelObjects.projectId, projectId), + inArray(externalModelObjects.circuitDeviceRowId, rowIds) + )).all() + .map(toExternalObjectSnapshot) + .map((expected) => ({ expected, target: { ...expected, circuitDeviceRowId: null } })); +} + +export function invertExternalObjectLinkTransitions( + transitions: ExternalObjectLinkTransition[] +): ExternalObjectLinkTransition[] { + return transitions.map(({ expected, target }) => ({ expected: target, target: expected })); +} + export function loadExpectedExternalObjectTransitions( database: AppDatabase, projectId: string, @@ -86,7 +107,13 @@ export function applyExternalObjectLinks( ) { for (const { expected, target } of transitions) { const updated = database.update(externalModelObjects) - .set({ circuitDeviceRowId: target.circuitDeviceRowId }) + .set({ + circuitDeviceRowId: target.circuitDeviceRowId, + distributionBoardId: target.distributionBoardId, + linkedProjectDeviceId: target.linkedProjectDeviceId, + planningValues: target.planningValues, + overriddenFields: target.overriddenFields, + }) .where(and( eq(externalModelObjects.id, expected.id), expected.circuitDeviceRowId === null diff --git a/src/db/repositories/external-object-deletion-guard.persistence.ts b/src/db/repositories/external-object-deletion-guard.persistence.ts new file mode 100644 index 0000000..45fdac7 --- /dev/null +++ b/src/db/repositories/external-object-deletion-guard.persistence.ts @@ -0,0 +1,37 @@ +import { and, eq } from "drizzle-orm"; +import type { AppDatabase } from "../database-context.js"; +import { externalModelObjects } from "../schema/external-model-objects.js"; +import { externalRoomMappings } from "../schema/external-room-mappings.js"; + +export function assertDistributionBoardHasNoExternalObjects( + database: AppDatabase, + projectId: string, + distributionBoardId: string +) { + const reference = database.select({ id: externalModelObjects.id }) + .from(externalModelObjects) + .where(and( + eq(externalModelObjects.projectId, projectId), + eq(externalModelObjects.distributionBoardId, distributionBoardId) + )).limit(1).get(); + if (reference) { + throw new Error("Der Verteiler kann nicht gelöscht werden, solange Revit-Objekte auf ihn verweisen."); + } +} + +export function assertRoomHasNoExternalObjects( + database: AppDatabase, + projectId: string, + roomId: string +) { + const reference = database.select({ id: externalModelObjects.id }) + .from(externalModelObjects) + .innerJoin(externalRoomMappings, eq(externalRoomMappings.id, externalModelObjects.externalRoomMappingId)) + .where(and( + eq(externalModelObjects.projectId, projectId), + eq(externalRoomMappings.roomId, roomId) + )).limit(1).get(); + if (reference) { + throw new Error("Der Raum kann nicht gelöscht werden, solange Revit-Objekte auf ihn verweisen."); + } +} diff --git a/src/db/repositories/project-location-structure-project-command.repository.ts b/src/db/repositories/project-location-structure-project-command.repository.ts index 51edb3b..40a1743 100644 --- a/src/db/repositories/project-location-structure-project-command.repository.ts +++ b/src/db/repositories/project-location-structure-project-command.repository.ts @@ -46,6 +46,7 @@ import { distributionBoards } from "../schema/distribution-boards.js"; import { projects } from "../schema/projects.js"; import { rooms } from "../schema/rooms.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; +import { assertRoomHasNoExternalObjects } from "./external-object-deletion-guard.persistence.js"; export class ProjectLocationStructureProjectCommandRepository implements ProjectLocationStructureProjectCommandStore @@ -439,6 +440,7 @@ export class ProjectLocationStructureProjectCommandRepository "Der Raum kann nicht gelöscht werden, solange er in einem Stromkreis verwendet wird." ); } + assertRoomHasNoExternalObjects(database, projectId, expected.id); const deleted = database .delete(rooms) .where( diff --git a/src/domain/models/circuit-device-row-structure-project-command.model.ts b/src/domain/models/circuit-device-row-structure-project-command.model.ts index 2464a27..ebba5ac 100644 --- a/src/domain/models/circuit-device-row-structure-project-command.model.ts +++ b/src/domain/models/circuit-device-row-structure-project-command.model.ts @@ -1,4 +1,11 @@ import type { SerializedProjectCommand } from "./project-command.model.js"; +import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js"; +import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js"; + +export interface ExternalObjectLinkSnapshotTransition { + expected: ExternalModelObjectSnapshot; + target: ExternalModelObjectSnapshot; +} export const circuitDeviceRowInsertCommandType = "circuit-device-row.insert" as const; @@ -32,11 +39,13 @@ export interface CircuitDeviceRowSnapshot { export interface CircuitDeviceRowInsertCommandPayload { row: CircuitDeviceRowSnapshot; + externalObjects?: ExternalObjectLinkSnapshotTransition[]; } export interface CircuitDeviceRowDeleteCommandPayload { rowId: string; expectedCircuitId: string; + externalObjects?: ExternalObjectLinkSnapshotTransition[]; } export interface CircuitDeviceRowInsertProjectCommand @@ -56,7 +65,8 @@ export type CircuitDeviceRowStructureProjectCommand = | CircuitDeviceRowDeleteProjectCommand; export function createCircuitDeviceRowInsertProjectCommand( - row: CircuitDeviceRowSnapshot + row: CircuitDeviceRowSnapshot, + externalObjects?: ExternalObjectLinkSnapshotTransition[] ): CircuitDeviceRowInsertProjectCommand { const normalizedRow = { ...row, @@ -65,7 +75,7 @@ export function createCircuitDeviceRowInsertProjectCommand( const command: CircuitDeviceRowInsertProjectCommand = { schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, type: circuitDeviceRowInsertCommandType, - payload: { row: normalizedRow }, + payload: { row: normalizedRow, ...(externalObjects?.length ? { externalObjects } : {}) }, }; assertCircuitDeviceRowInsertProjectCommand(command); return command; @@ -73,12 +83,13 @@ export function createCircuitDeviceRowInsertProjectCommand( export function createCircuitDeviceRowDeleteProjectCommand( rowId: string, - expectedCircuitId: string + expectedCircuitId: string, + externalObjects?: ExternalObjectLinkSnapshotTransition[] ): CircuitDeviceRowDeleteProjectCommand { const command: CircuitDeviceRowDeleteProjectCommand = { schemaVersion: circuitDeviceRowStructureCommandSchemaVersion, type: circuitDeviceRowDeleteCommandType, - payload: { rowId, expectedCircuitId }, + payload: { rowId, expectedCircuitId, ...(externalObjects?.length ? { externalObjects } : {}) }, }; assertCircuitDeviceRowDeleteProjectCommand(command); return command; @@ -143,6 +154,7 @@ export function assertCircuitDeviceRowInsertProjectCommand( throw new Error("row.cosPhi must be positive or null."); } } + assertExternalObjectLinkTransitions(command.payload.externalObjects); } export function assertCircuitDeviceRowDeleteProjectCommand( @@ -163,6 +175,41 @@ export function assertCircuitDeviceRowDeleteProjectCommand( command.payload.expectedCircuitId, "expectedCircuitId" ); + assertExternalObjectLinkTransitions(command.payload.externalObjects); +} + +export function assertExternalObjectLinkTransitions(value: unknown) { + if (value === undefined) return; + if (!Array.isArray(value)) throw new Error("externalObjects must be an array."); + const ids = new Set(); + for (const transition of value) { + if (!isPlainObject(transition) || !isPlainObject(transition.expected) || !isPlainObject(transition.target)) { + throw new Error("Invalid external object link transition."); + } + const expectedId = transition.expected.id; + const targetId = transition.target.id; + if (typeof expectedId !== "string" || expectedId !== targetId || ids.has(expectedId)) { + throw new Error("External object link transition has mismatched or duplicate ids."); + } + ids.add(expectedId); + const expectedRowId = transition.expected.circuitDeviceRowId; + const targetRowId = transition.target.circuitDeviceRowId; + if ((expectedRowId !== null && typeof expectedRowId !== "string") || (targetRowId !== null && typeof targetRowId !== "string")) { + throw new Error("Invalid external object row link."); + } + const parsed = parseExternalModelStateSnapshot({ source: null, importBatches: [], roomMappings: [], objects: [transition.expected, transition.target] }).objects; + const expectedRest = { ...parsed[0], circuitDeviceRowId: null }; + const targetRest = { ...parsed[1], circuitDeviceRowId: null }; + if (canonicalJson(expectedRest) !== canonicalJson(targetRest) || expectedRowId === targetRowId) { + throw new Error("External object transition may only change the row link."); + } + } +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (isPlainObject(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`; + return JSON.stringify(value) ?? "null"; } function assertNonEmptyString(value: unknown, field: string) { diff --git a/src/domain/models/circuit-group-subtree-project-command.model.ts b/src/domain/models/circuit-group-subtree-project-command.model.ts index a3e53b3..4470cd2 100644 --- a/src/domain/models/circuit-group-subtree-project-command.model.ts +++ b/src/domain/models/circuit-group-subtree-project-command.model.ts @@ -3,6 +3,7 @@ import { type CircuitGroupSubtreeSnapshot, } from "./circuit-group-subtree-snapshot.model.js"; import type { SerializedProjectCommand } from "./project-command.model.js"; +import { assertExternalObjectLinkTransitions, type ExternalObjectLinkSnapshotTransition } from "./circuit-device-row-structure-project-command.model.js"; export const circuitGroupDeleteSubtreeCommandType = "circuit-group.delete-subtree" as const; @@ -12,6 +13,7 @@ export const circuitGroupSubtreeCommandSchemaVersion = 1 as const; interface Payload { snapshot: CircuitGroupSubtreeSnapshot; + externalObjects?: ExternalObjectLinkSnapshotTransition[]; } export interface CircuitGroupDeleteSubtreeProjectCommand @@ -31,24 +33,26 @@ export type CircuitGroupSubtreeProjectCommand = | CircuitGroupRestoreSubtreeProjectCommand; export function createCircuitGroupDeleteSubtreeProjectCommand( - snapshot: CircuitGroupSubtreeSnapshot + snapshot: CircuitGroupSubtreeSnapshot, + externalObjects?: ExternalObjectLinkSnapshotTransition[] ): CircuitGroupDeleteSubtreeProjectCommand { const command: CircuitGroupDeleteSubtreeProjectCommand = { schemaVersion: circuitGroupSubtreeCommandSchemaVersion, type: circuitGroupDeleteSubtreeCommandType, - payload: { snapshot }, + payload: { snapshot, ...(externalObjects?.length ? { externalObjects } : {}) }, }; assertCircuitGroupDeleteSubtreeProjectCommand(command); return command; } export function createCircuitGroupRestoreSubtreeProjectCommand( - snapshot: CircuitGroupSubtreeSnapshot + snapshot: CircuitGroupSubtreeSnapshot, + externalObjects?: ExternalObjectLinkSnapshotTransition[] ): CircuitGroupRestoreSubtreeProjectCommand { const command: CircuitGroupRestoreSubtreeProjectCommand = { schemaVersion: circuitGroupSubtreeCommandSchemaVersion, type: circuitGroupRestoreSubtreeCommandType, - payload: { snapshot }, + payload: { snapshot, ...(externalObjects?.length ? { externalObjects } : {}) }, }; assertCircuitGroupRestoreSubtreeProjectCommand(command); return command; @@ -76,11 +80,12 @@ function assertCommand( command.schemaVersion !== circuitGroupSubtreeCommandSchemaVersion || command.type !== type || !isPlainObject(command.payload) || - Object.keys(command.payload).length !== 1 + Object.keys(command.payload).some((key) => key !== "snapshot" && key !== "externalObjects") ) { throw new Error("Unsupported circuit-group subtree command."); } assertCircuitGroupSubtreeSnapshot(command.payload.snapshot); + assertExternalObjectLinkTransitions(command.payload.externalObjects); } function isPlainObject(value: unknown): value is Record { diff --git a/src/domain/models/circuit-structure-project-command.model.ts b/src/domain/models/circuit-structure-project-command.model.ts index 5260c4b..292c776 100644 --- a/src/domain/models/circuit-structure-project-command.model.ts +++ b/src/domain/models/circuit-structure-project-command.model.ts @@ -3,6 +3,8 @@ import { circuitDeviceRowInsertCommandType, circuitDeviceRowStructureCommandSchemaVersion, type CircuitDeviceRowSnapshot, + type ExternalObjectLinkSnapshotTransition, + assertExternalObjectLinkTransitions, } from "./circuit-device-row-structure-project-command.model.js"; import type { SerializedProjectCommand } from "./project-command.model.js"; import { @@ -37,11 +39,13 @@ export interface CircuitSnapshot { export interface CircuitInsertCommandPayload { circuit: CircuitSnapshot; + externalObjects?: ExternalObjectLinkSnapshotTransition[]; } export interface CircuitDeleteCommandPayload { circuitId: string; expectedCircuitListId: string; + externalObjects?: ExternalObjectLinkSnapshotTransition[]; } export interface CircuitInsertProjectCommand @@ -61,12 +65,13 @@ export type CircuitStructureProjectCommand = | CircuitDeleteProjectCommand; export function createCircuitInsertProjectCommand( - circuit: CircuitSnapshot + circuit: CircuitSnapshot, + externalObjects?: ExternalObjectLinkSnapshotTransition[] ): CircuitInsertProjectCommand { const command: CircuitInsertProjectCommand = { schemaVersion: circuitStructureCommandSchemaVersion, type: circuitInsertCommandType, - payload: { circuit }, + payload: { circuit, ...(externalObjects?.length ? { externalObjects } : {}) }, }; assertCircuitInsertProjectCommand(command); return command; @@ -74,12 +79,13 @@ export function createCircuitInsertProjectCommand( export function createCircuitDeleteProjectCommand( circuitId: string, - expectedCircuitListId: string + expectedCircuitListId: string, + externalObjects?: ExternalObjectLinkSnapshotTransition[] ): CircuitDeleteProjectCommand { const command: CircuitDeleteProjectCommand = { schemaVersion: circuitStructureCommandSchemaVersion, type: circuitDeleteCommandType, - payload: { circuitId, expectedCircuitListId }, + payload: { circuitId, expectedCircuitListId, ...(externalObjects?.length ? { externalObjects } : {}) }, }; assertCircuitDeleteProjectCommand(command); return command; @@ -168,6 +174,7 @@ export function assertCircuitInsertProjectCommand( } rowIds.add(row.id); } + assertExternalObjectLinkTransitions(command.payload.externalObjects); } export function assertCircuitDeleteProjectCommand( @@ -187,6 +194,7 @@ export function assertCircuitDeleteProjectCommand( command.payload.expectedCircuitListId, "expectedCircuitListId" ); + assertExternalObjectLinkTransitions(command.payload.externalObjects); } function assertNonEmptyString(value: unknown, field: string) { diff --git a/src/domain/models/circuit-tree.model.ts b/src/domain/models/circuit-tree.model.ts index 00a0c2d..fd1bd2c 100644 --- a/src/domain/models/circuit-tree.model.ts +++ b/src/domain/models/circuit-tree.model.ts @@ -20,6 +20,7 @@ export interface CircuitTreeDeviceRow { remark?: string; overriddenFields?: string; rowTotalPower: number; + externalObjectCount?: number; } export interface CircuitTreeCircuit { diff --git a/src/domain/models/external-object-new-circuit-project-command.model.ts b/src/domain/models/external-object-new-circuit-project-command.model.ts index b34a449..e7a3e23 100644 --- a/src/domain/models/external-object-new-circuit-project-command.model.ts +++ b/src/domain/models/external-object-new-circuit-project-command.model.ts @@ -146,8 +146,8 @@ function assertOnlyAssignmentChanged( expected: ExternalModelObjectSnapshot, target: ExternalModelObjectSnapshot ) { - const expectedRest = { ...expected, circuitDeviceRowId: null }; - const targetRest = { ...target, circuitDeviceRowId: null }; + const expectedRest = { ...expected, circuitDeviceRowId: null, distributionBoardId: null }; + const targetRest = { ...target, circuitDeviceRowId: null, distributionBoardId: null }; if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) { throw new Error("New-circuit assignment may only change the external object row link."); } diff --git a/src/domain/models/external-object-new-row-project-command.model.ts b/src/domain/models/external-object-new-row-project-command.model.ts index d0416dd..6bae039 100644 --- a/src/domain/models/external-object-new-row-project-command.model.ts +++ b/src/domain/models/external-object-new-row-project-command.model.ts @@ -141,8 +141,8 @@ function assertOnlyAssignmentChanged( expected: ExternalModelObjectSnapshot, target: ExternalModelObjectSnapshot ) { - const expectedRest = { ...expected, circuitDeviceRowId: null }; - const targetRest = { ...target, circuitDeviceRowId: null }; + const expectedRest = { ...expected, circuitDeviceRowId: null, distributionBoardId: null }; + const targetRest = { ...target, circuitDeviceRowId: null, distributionBoardId: null }; if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) { throw new Error("New-row assignment may only change the external object row link."); } diff --git a/src/domain/models/external-object-row-assignment-project-command.model.ts b/src/domain/models/external-object-row-assignment-project-command.model.ts index a45fe31..cb4ae97 100644 --- a/src/domain/models/external-object-row-assignment-project-command.model.ts +++ b/src/domain/models/external-object-row-assignment-project-command.model.ts @@ -104,10 +104,10 @@ export function assertExternalObjectRowAssignmentProjectCommand( if (expected.circuitDeviceRowId === target.circuitDeviceRowId) { throw new Error("External object row assignment must change its row link."); } - assertOnlyFieldChanged( + assertOnlyFieldsChanged( expected as unknown as Record, target as unknown as Record, - "circuitDeviceRowId", + new Set(["circuitDeviceRowId", "distributionBoardId", "linkedProjectDeviceId", "planningValues", "overriddenFields"]), "external object" ); if (expected.circuitDeviceRowId) affectedRowIds.add(expected.circuitDeviceRowId); @@ -161,11 +161,22 @@ function assertOnlyFieldChanged( target: Record, allowedField: string, label: string +) { + assertOnlyFieldsChanged(expected, target, new Set([allowedField]), label); +} + +function assertOnlyFieldsChanged( + expected: Record, + target: Record, + allowedFields: ReadonlySet, + label: string ) { const expectedRest = { ...expected }; const targetRest = { ...target }; - delete expectedRest[allowedField]; - delete targetRest[allowedField]; + for (const field of allowedFields) { + delete expectedRest[field]; + delete targetRest[field]; + } if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) { throw new Error(`Row assignment may only change the ${label} assignment state.`); } diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index f75b64e..5418922 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -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(null); const [projectDevices, setProjectDevices] = useState([]); + const [externalObjects, setExternalObjects] = useState(null); + const [isRevitDrawerOpen, setIsRevitDrawerOpen] = useState(false); + const [draggingExternalObjectIds, setDraggingExternalObjectIds] = useState([]); 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(); + 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 /> + sum + group.objects.length, 0)})`} + onClick={() => setIsRevitDrawerOpen((current) => !current)} + showLabel + /> Wird gespeichert …
: null}
+ {isRevitDrawerOpen ? ( + + ) : null} {isProjectDeviceDrawerOpen ? (