diff --git a/AGENTS.md b/AGENTS.md index 894024e..39a9be6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,6 +186,22 @@ Keyboard behavior: Support Ctrl+Plus and Ctrl+Shift+Plus for insertion. +Ctrl+C/Ctrl+V copy and paste selected cells without opening an editor +(document `copy`/`paste` events, no secure-context clipboard API). Protection +and cable summaries copy the complete circuit record. With a Ctrl/Shift +multi-selection, typing, pasting and the protection dialog apply to every +selected row that can take the value; everything is one +`circuit-list.bulk-update` command and one undo step. A right-click context +menu (also context-menu key / Shift+F10) offers copy, paste, edit, protection, +cable sizing, insert circuit below, add device and delete. The protection +summary cell is selected by click and opened by double click/Enter/F2. + +The circuit editor page fills the viewport; only the grid scrolls so the +column headers stay pinned. The compact title bar holds the back link, board +number/name, toolbar and power summary. Inside a project the sidebar shows only +the active project with its sections, an expandable board list (current board +highlighted), Revit and Seitenansicht; `/projects` keeps the project list. + Editor actions use `IconButton` (`src/frontend/components/icon-button.tsx`, `lucide-react` icons). The German action text is always the accessible name and tooltip; the toolbar shows icon plus text, group headers and rows show icons diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 396ba88..18646ed 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -453,6 +453,24 @@ Gerätezeilen atomar. Delete/Undo erfasst denselben Datensatz vollständig. Im Editor sind die Schutzspalten deshalb eine schreibgeschützte Projektion der 1:1-Daten; Änderungen erfolgen über ein geräteabhängiges Schutzgeräte-Modal und `circuit-protection.update`. +Mehrfachänderungen und Einfügen im Editor laufen über +`circuit-list.bulk-update`: Der Befehl enthält vollständige Einzelbefehle +`circuit.update`, `circuit-device-row.update` und `circuit-protection.update` +(je Ziel höchstens einer, maximal 2000) und wendet sie mit denselben Regeln wie +die Einzelbefehle in einer Transaktion an +(`circuit-list-bulk-update-project-command.repository.ts`). Die Inverse ist die +umgekehrte Liste der angewendeten Inversen, sodass Undo/Redo nichts neu +plant. Die reine Planung im Client liegt in +`src/frontend/utils/circuit-grid-clipboard.ts`: Strg+C/Strg+V über die +`copy`/`paste`-Ereignisse (funktioniert auch über HTTP im LAN), Schutz- und +Kabelzusammenfassung werden als vollständiger Datensatz des Stromkreises +kopiert, Tabulator-Blöcke aus Excel laufen über sichtbare Zeilen und Spalten. +Bei einer Mehrfachauswahl (Strg/Umschalt) gelten Eingabe, Einfügen und das +Schutzgeräte-Modal für alle passenden ausgewählten Zeilen; nicht passende +Zellen werden übersprungen und gemeldet. Ein Rechtsklick-Kontextmenü +(`grid-context-menu.tsx`, auch Kontextmenütaste/Umschalt+F10) bündelt +Kopieren, Einfügen, Bearbeiten, Schutzgerät, Leitungsberechnung, Stromkreis +unterhalb einfügen, Gerät hinzufügen und Löschen. Gruppen lassen sich im Editor schrittweise nur gegenüber einer benachbarten Gruppe derselben Kategorie verschieben. Der Client sendet dabei immer die vollständige erwartete und gewünschte Sortierreihenfolge über diff --git a/src/app/globals.css b/src/app/globals.css index 20d83b5..411b1c3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -316,6 +316,80 @@ a { box-shadow: inset 3px 0 0 var(--color-signal); } +/* Section link with an optional expand toggle (Verteilungen). */ +.sidebar-link-row { + display: flex; + align-items: center; + gap: 2px; +} + +.sidebar-link-row > .sidebar-link { + flex: 1 1 auto; + min-width: 0; +} + +.sidebar-subtoggle { + flex: 0 0 auto; + width: 2rem; + align-self: stretch; + border: 0; + border-radius: 8px; + background: transparent; + color: #c7d6e0; + cursor: pointer; +} + +.sidebar-subtoggle:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; +} + +.sidebar-project-name { + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-board-list { + display: flex; + flex-direction: column; + gap: 1px; + margin: 2px 0 6px 1.6rem; + border-left: 1px solid rgba(255, 255, 255, 0.14); + max-height: 45vh; + overflow-y: auto; +} + +.sidebar-board-link { + display: block; + padding: 5px 10px; + border-radius: 6px; + color: #c7d6e0; + font-size: 0.82rem; + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-board-link:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; + text-decoration: none; +} + +.sidebar-board-link.active { + background: rgba(255, 255, 255, 0.14); + color: #fff; + box-shadow: inset 3px 0 0 var(--color-signal); +} + +.sidebar-board-link.disabled { + opacity: 0.55; + cursor: default; +} + .sidebar-icon { width: 1.1em; display: inline-flex; @@ -1677,7 +1751,7 @@ a.kpi:hover { .tree-grid .cell-protection-trigger { color: var(--color-primary); - cursor: pointer; + cursor: cell; font-weight: 600; transition: background-color 0.12s ease, @@ -1714,6 +1788,95 @@ a.kpi:hover { outline-offset: -2px; } +/* Result of copy/paste or a multi-row edit, shown above the grid. */ +.grid-status-line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem 0.75rem; + padding: 0.3rem 0.6rem; + border: 1px solid var(--notice-info-border); + border-radius: 4px; + background: var(--notice-info-bg); + font-size: 0.82rem; +} + +.grid-status-selection { + font-weight: 600; +} + +.grid-status-line button { + margin-left: auto; + border: 0; + background: transparent; + color: var(--text-muted); + font-size: 0.78rem; + text-decoration: underline; +} + +/* Right-click menu of the circuit grid. */ +.grid-context-menu { + position: fixed; + z-index: 1060; + display: flex; + flex-direction: column; + min-width: 15rem; + max-width: min(24rem, calc(100vw - 1rem)); + padding: 0.3rem; + border: 1px solid var(--panel-border); + border-radius: 6px; + background: var(--panel-bg); + box-shadow: 0 6px 20px var(--shadow-menu); +} + +.grid-context-menu-item { + display: flex; + align-items: center; + gap: 0.55rem; + width: 100%; + padding: 0.38rem 0.55rem; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--text-strong); + font-size: 0.85rem; + text-align: left; +} + +.grid-context-menu-item:not(:disabled):hover, +.grid-context-menu-item:focus-visible { + background: var(--panel-bg-subtle); + outline: none; +} + +.grid-context-menu-item:disabled { + opacity: 0.45; + cursor: default; +} + +.grid-context-menu-item.danger { + color: var(--color-danger); +} + +.grid-context-menu-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.grid-context-menu-shortcut { + color: var(--text-subtle); + font-size: 0.75rem; +} + +.grid-context-menu-separator { + height: 1px; + margin: 0.25rem 0.2rem; + background: var(--panel-border); +} + /* Picker trigger on the selected GZF cell. Positioned over the cell so the column width does not change while the selection moves. */ .tree-grid td:has(> .cell-picker-trigger) { diff --git a/src/db/repositories/circuit-device-row-project-command.repository.ts b/src/db/repositories/circuit-device-row-project-command.repository.ts index 6ffb46c..8276a2d 100644 --- a/src/db/repositories/circuit-device-row-project-command.repository.ts +++ b/src/db/repositories/circuit-device-row-project-command.repository.ts @@ -37,6 +37,11 @@ import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js"; type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect; +export type CircuitDeviceRowUpdateApplyInput = Pick< + ExecuteCircuitDeviceRowUpdateCommandInput, + "projectId" | "source" | "command" +>; + export class CircuitDeviceRowProjectCommandRepository implements CircuitDeviceRowProjectCommandStore { @@ -51,208 +56,216 @@ export class CircuitDeviceRowProjectCommandRepository return executeProjectCommandTransactionWithAppliedForward( this.database, input, - (tx) => this.applyCommand(tx, input) + (tx) => applyCircuitDeviceRowUpdateCommand(tx, input) ); } +} - private applyCommand( - tx: AppDatabase, - input: ExecuteCircuitDeviceRowUpdateCommandInput - ) { - const current = tx - .select() - .from(circuitDeviceRows) - .where(eq(circuitDeviceRows.id, input.command.payload.rowId)) - .get(); - if (!current) { - throw new Error("Invalid device row id."); - } +// Applies one circuit-device-row.update inside an open transaction and +// returns the applied forward command plus its exact inverse, including +// single-device circuit name transitions. Shared with +// circuit-list.bulk-update. +export function applyCircuitDeviceRowUpdateCommand( + tx: AppDatabase, + input: CircuitDeviceRowUpdateApplyInput +) { + assertCircuitDeviceRowUpdateProjectCommand(input.command); + assertCircuitDisplayNameTransitions( + input.command.payload.circuitDisplayNames + ); + const current = tx + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, input.command.payload.rowId)) + .get(); + if (!current) { + throw new Error("Invalid device row id."); + } - const circuit = tx - .select({ circuitListId: circuits.circuitListId }) - .from(circuits) - .where(eq(circuits.id, current.circuitId)) - .get(); - const owningList = circuit - ? tx - .select({ id: circuitLists.id }) - .from(circuitLists) - .where( - and( - eq(circuitLists.id, circuit.circuitListId), - eq(circuitLists.projectId, input.projectId) - ) + const circuit = tx + .select({ circuitListId: circuits.circuitListId }) + .from(circuits) + .where(eq(circuits.id, current.circuitId)) + .get(); + const owningList = circuit + ? tx + .select({ id: circuitLists.id }) + .from(circuitLists) + .where( + and( + eq(circuitLists.id, circuit.circuitListId), + eq(circuitLists.projectId, input.projectId) ) - .get() - : null; - if (!owningList) { - throw new Error("Circuit device row does not belong to project."); - } - - const patch = Object.fromEntries( - input.command.payload.changes.map((change) => [ - change.field, - change.value, - ]) - ) as CircuitDeviceRowPatchInput; - if (patch.quantity !== undefined || patch.manualQuantity !== undefined) { - this.applyQuantityPatch(tx, current, patch); - } - if ( - input.source === "user" && - patch.phaseType !== undefined && - !isElectricalPhaseType(patch.phaseType) - ) { - throw new Error("Device-row phase type is invalid."); - } - this.assertLinkedProjectDevice(tx, input.projectId, patch); - this.assertRoom(tx, input.projectId, patch); - - const overriddenFields = deriveOverriddenFieldsForLocalEdit( - current, - patch - ); - if ( - overriddenFields !== undefined && - patch.overriddenFields === undefined - ) { - patch.overriddenFields = overriddenFields; - } - - const appliedPatch = createCircuitDeviceRowUpdateProjectCommand( - current.id, - patch as CircuitDeviceRowUpdatePatch - ); - const inversePatch = Object.fromEntries( - appliedPatch.payload.changes.map((change) => [ - change.field, - getCircuitDeviceRowFieldValue(current, change.field), - ]) - ) as CircuitDeviceRowUpdatePatch; - - const update = tx - .update(circuitDeviceRows) - .set(toCircuitDeviceRowPatchValues(patch)) - .where(eq(circuitDeviceRows.id, current.id)) - .run(); - if (update.changes !== 1) { - throw new Error("Circuit device row changed before command execution."); - } - if (patch.phaseType !== undefined) { - updateDerivedCircuitVoltage( - tx, - input.projectId, - current.circuitId - ); - } - const circuitDisplayNames = syncSingleDeviceCircuitDisplayNames( - tx, - input.source, - patch.displayName === undefined ? [] : [current.circuitId], - input.command.payload.circuitDisplayNames - ); - - return { - forward: createCircuitDeviceRowUpdateProjectCommand( - current.id, - patch as CircuitDeviceRowUpdatePatch, - circuitDisplayNames - ), - inverse: createCircuitDeviceRowUpdateProjectCommand( - current.id, - inversePatch, - invertCircuitDisplayNameTransitions(circuitDisplayNames) - ), - }; - } - - // The row quantity is always the manual share plus every linked Revit - // object. A manual-share edit derives the total; a total edit is only - // valid while no Revit object contributes to the row. - private applyQuantityPatch( - database: AppDatabase, - current: CircuitDeviceRow, - patch: CircuitDeviceRowPatchInput - ) { - const externalObjects = database - .select({ planningValues: externalModelObjects.planningValues }) - .from(externalModelObjects) - .where(eq(externalModelObjects.circuitDeviceRowId, current.id)) - .all() - .map((object) => ({ - effectiveQuantity: object.planningValues.effectiveQuantity, - })); - const manualQuantity = patch.manualQuantity ?? current.manualQuantity; - if (patch.quantity === undefined) { - patch.quantity = calculateCircuitDeviceRowQuantity( - manualQuantity, - externalObjects - ); - return; - } - if ( - externalObjects.length > 0 && - patch.quantity !== - calculateCircuitDeviceRowQuantity(manualQuantity, externalObjects) - ) { - throw new Error( - "Die Anzahl dieser Gerätezeile ist mit Revit-Objekten verknüpft. Bitte die manuelle Anzahl bearbeiten." - ); - } - assertCircuitDeviceRowQuantity({ - quantity: patch.quantity, - manualQuantity, - externalObjects, - }); - } - - private assertLinkedProjectDevice( - database: AppDatabase, - projectId: string, - patch: CircuitDeviceRowPatchInput - ) { - if ( - !Object.prototype.hasOwnProperty.call(patch, "linkedProjectDeviceId") || - patch.linkedProjectDeviceId === null || - patch.linkedProjectDeviceId === undefined - ) { - return; - } - const device = database - .select({ id: projectDevices.id }) - .from(projectDevices) - .where( - and( - eq(projectDevices.id, patch.linkedProjectDeviceId), - eq(projectDevices.projectId, projectId) ) - ) - .get(); - if (!device) { - throw new Error("Invalid linked project device id."); - } + .get() + : null; + if (!owningList) { + throw new Error("Circuit device row does not belong to project."); } - private assertRoom( - database: AppDatabase, - projectId: string, - patch: CircuitDeviceRowPatchInput + const patch = Object.fromEntries( + input.command.payload.changes.map((change) => [ + change.field, + change.value, + ]) + ) as CircuitDeviceRowPatchInput; + if (patch.quantity !== undefined || patch.manualQuantity !== undefined) { + applyQuantityPatch(tx, current, patch); + } + if ( + input.source === "user" && + patch.phaseType !== undefined && + !isElectricalPhaseType(patch.phaseType) ) { - if ( - !Object.prototype.hasOwnProperty.call(patch, "roomId") || - patch.roomId === null || - patch.roomId === undefined - ) { - return; - } - const room = database - .select({ id: rooms.id }) - .from(rooms) - .where(and(eq(rooms.id, patch.roomId), eq(rooms.projectId, projectId))) - .get(); - if (!room) { - throw new Error("Invalid room id."); - } + throw new Error("Device-row phase type is invalid."); + } + assertLinkedProjectDevice(tx, input.projectId, patch); + assertRoom(tx, input.projectId, patch); + + const overriddenFields = deriveOverriddenFieldsForLocalEdit( + current, + patch + ); + if ( + overriddenFields !== undefined && + patch.overriddenFields === undefined + ) { + patch.overriddenFields = overriddenFields; + } + + const appliedPatch = createCircuitDeviceRowUpdateProjectCommand( + current.id, + patch as CircuitDeviceRowUpdatePatch + ); + const inversePatch = Object.fromEntries( + appliedPatch.payload.changes.map((change) => [ + change.field, + getCircuitDeviceRowFieldValue(current, change.field), + ]) + ) as CircuitDeviceRowUpdatePatch; + + const update = tx + .update(circuitDeviceRows) + .set(toCircuitDeviceRowPatchValues(patch)) + .where(eq(circuitDeviceRows.id, current.id)) + .run(); + if (update.changes !== 1) { + throw new Error("Circuit device row changed before command execution."); + } + if (patch.phaseType !== undefined) { + updateDerivedCircuitVoltage( + tx, + input.projectId, + current.circuitId + ); + } + const circuitDisplayNames = syncSingleDeviceCircuitDisplayNames( + tx, + input.source, + patch.displayName === undefined ? [] : [current.circuitId], + input.command.payload.circuitDisplayNames + ); + + return { + forward: createCircuitDeviceRowUpdateProjectCommand( + current.id, + patch as CircuitDeviceRowUpdatePatch, + circuitDisplayNames + ), + inverse: createCircuitDeviceRowUpdateProjectCommand( + current.id, + inversePatch, + invertCircuitDisplayNameTransitions(circuitDisplayNames) + ), + }; +} + +// The row quantity is always the manual share plus every linked Revit +// object. A manual-share edit derives the total; a total edit is only +// valid while no Revit object contributes to the row. +function applyQuantityPatch( + database: AppDatabase, + current: CircuitDeviceRow, + patch: CircuitDeviceRowPatchInput +) { + const externalObjects = database + .select({ planningValues: externalModelObjects.planningValues }) + .from(externalModelObjects) + .where(eq(externalModelObjects.circuitDeviceRowId, current.id)) + .all() + .map((object) => ({ + effectiveQuantity: object.planningValues.effectiveQuantity, + })); + const manualQuantity = patch.manualQuantity ?? current.manualQuantity; + if (patch.quantity === undefined) { + patch.quantity = calculateCircuitDeviceRowQuantity( + manualQuantity, + externalObjects + ); + return; + } + if ( + externalObjects.length > 0 && + patch.quantity !== + calculateCircuitDeviceRowQuantity(manualQuantity, externalObjects) + ) { + throw new Error( + "Die Anzahl dieser Gerätezeile ist mit Revit-Objekten verknüpft. Bitte die manuelle Anzahl bearbeiten." + ); + } + assertCircuitDeviceRowQuantity({ + quantity: patch.quantity, + manualQuantity, + externalObjects, + }); +} + +function assertLinkedProjectDevice( + database: AppDatabase, + projectId: string, + patch: CircuitDeviceRowPatchInput +) { + if ( + !Object.prototype.hasOwnProperty.call(patch, "linkedProjectDeviceId") || + patch.linkedProjectDeviceId === null || + patch.linkedProjectDeviceId === undefined + ) { + return; + } + const device = database + .select({ id: projectDevices.id }) + .from(projectDevices) + .where( + and( + eq(projectDevices.id, patch.linkedProjectDeviceId), + eq(projectDevices.projectId, projectId) + ) + ) + .get(); + if (!device) { + throw new Error("Invalid linked project device id."); + } +} + +function assertRoom( + database: AppDatabase, + projectId: string, + patch: CircuitDeviceRowPatchInput +) { + if ( + !Object.prototype.hasOwnProperty.call(patch, "roomId") || + patch.roomId === null || + patch.roomId === undefined + ) { + return; + } + const room = database + .select({ id: rooms.id }) + .from(rooms) + .where(and(eq(rooms.id, patch.roomId), eq(rooms.projectId, projectId))) + .get(); + if (!room) { + throw new Error("Invalid room id."); } } diff --git a/src/db/repositories/circuit-list-bulk-update-project-command.repository.ts b/src/db/repositories/circuit-list-bulk-update-project-command.repository.ts new file mode 100644 index 0000000..e1e6b11 --- /dev/null +++ b/src/db/repositories/circuit-list-bulk-update-project-command.repository.ts @@ -0,0 +1,69 @@ +import { circuitDeviceRowUpdateCommandType } from "../../domain/models/circuit-device-row-project-command.model.js"; +import { + assertCircuitListBulkUpdateProjectCommand, + createCircuitListBulkUpdateProjectCommand, + type CircuitListBulkUpdateEntry, +} from "../../domain/models/circuit-list-bulk-update-project-command.model.js"; +import { circuitUpdateCommandType } from "../../domain/models/circuit-project-command.model.js"; +import type { + CircuitListBulkUpdateProjectCommandStore, + ExecuteCircuitListBulkUpdateCommandInput, +} from "../../domain/ports/circuit-list-bulk-update-project-command.store.js"; +import type { AppDatabase } from "../database-context.js"; +import { applyCircuitDeviceRowUpdateCommand } from "./circuit-device-row-project-command.repository.js"; +import { applyCircuitUpdateCommand } from "./circuit-project-command.repository.js"; +import { applyCircuitProtectionUpdateCommand } from "./circuit-protection-project-command.repository.js"; +import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js"; + +export class CircuitListBulkUpdateProjectCommandRepository + implements CircuitListBulkUpdateProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + // Applies every update with the same rules as its single command inside + // one transaction. Any rejected update rolls the whole edit back. + execute(input: ExecuteCircuitListBulkUpdateCommandInput) { + assertCircuitListBulkUpdateProjectCommand(input.command); + return executeProjectCommandTransactionWithAppliedForward( + this.database, + input, + (tx) => { + const forward: CircuitListBulkUpdateEntry[] = []; + const inverse: CircuitListBulkUpdateEntry[] = []; + for (const update of input.command.payload.updates) { + const applied = applyBulkUpdateEntry(tx, input, update); + forward.push(applied.forward); + inverse.unshift(applied.inverse); + } + return { + forward: createCircuitListBulkUpdateProjectCommand(forward), + inverse: createCircuitListBulkUpdateProjectCommand(inverse), + }; + } + ); + } +} + +function applyBulkUpdateEntry( + tx: AppDatabase, + input: ExecuteCircuitListBulkUpdateCommandInput, + update: CircuitListBulkUpdateEntry +): { forward: CircuitListBulkUpdateEntry; inverse: CircuitListBulkUpdateEntry } { + const context = { projectId: input.projectId, source: input.source }; + if (update.type === circuitUpdateCommandType) { + return applyCircuitUpdateCommand(tx, { ...context, command: update }); + } + if (update.type === circuitDeviceRowUpdateCommandType) { + return applyCircuitDeviceRowUpdateCommand(tx, { + ...context, + command: update, + }); + } + return { + forward: update, + inverse: applyCircuitProtectionUpdateCommand(tx, { + ...context, + command: update, + }), + }; +} diff --git a/src/db/repositories/circuit-project-command.repository.ts b/src/db/repositories/circuit-project-command.repository.ts index bbd6543..f0414f4 100644 --- a/src/db/repositories/circuit-project-command.repository.ts +++ b/src/db/repositories/circuit-project-command.repository.ts @@ -25,6 +25,11 @@ import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqu type CircuitRow = typeof circuits.$inferSelect; +export type CircuitUpdateApplyInput = Pick< + ExecuteCircuitUpdateCommandInput, + "projectId" | "source" | "command" +>; + export class CircuitProjectCommandRepository implements CircuitProjectCommandStore { @@ -36,144 +41,148 @@ export class CircuitProjectCommandRepository return executeProjectCommandTransactionWithAppliedForward( this.database, input, - (tx) => this.applyCommand(tx, input) + (tx) => applyCircuitUpdateCommand(tx, input) ); } +} - private applyCommand( - tx: AppDatabase, - input: ExecuteCircuitUpdateCommandInput - ) { - const current = tx - .select() - .from(circuits) - .where(eq(circuits.id, input.command.payload.circuitId)) - .get(); - if (!current) { - throw new Error("Invalid circuit id."); - } +// Applies one circuit.update inside an open transaction and returns the +// applied forward command (with derived voltage) plus its exact inverse. +// Shared with circuit-list.bulk-update. +export function applyCircuitUpdateCommand( + tx: AppDatabase, + input: CircuitUpdateApplyInput +) { + assertCircuitUpdateProjectCommand(input.command); + const current = tx + .select() + .from(circuits) + .where(eq(circuits.id, input.command.payload.circuitId)) + .get(); + if (!current) { + throw new Error("Invalid circuit id."); + } - const owningList = tx - .select({ id: circuitLists.id }) - .from(circuitLists) - .where( - and( - eq(circuitLists.id, current.circuitListId), - eq(circuitLists.projectId, input.projectId) - ) + const owningList = tx + .select({ id: circuitLists.id }) + .from(circuitLists) + .where( + and( + eq(circuitLists.id, current.circuitListId), + eq(circuitLists.projectId, input.projectId) ) - .get(); - if (!owningList) { - throw new Error("Circuit does not belong to project."); - } - - const patch = Object.fromEntries( - input.command.payload.changes.map((change) => [ - change.field, - change.value, - ]) - ) as CircuitPatchPersistenceInput; - this.assertSectionInCircuitList(tx, current, patch.sectionId); - if (input.source === "user") { - if ( - input.command.payload.changes.every( - (change) => change.field === "voltage" - ) - ) { - throw new Error("Circuit voltage is derived and cannot be edited."); - } - const devicePhaseTypes = tx - .select({ phaseType: circuitDeviceRows.phaseType }) - .from(circuitDeviceRows) - .where(eq(circuitDeviceRows.circuitId, current.id)) - .all() - .map((row) => row.phaseType); - const derivedVoltage = resolveCircuitVoltage( - tx, - input.projectId, - patch.sectionId ?? current.sectionId, - devicePhaseTypes - ); - if (derivedVoltage === current.voltage) { - delete patch.voltage; - } else { - patch.voltage = derivedVoltage; - } - } - this.assertUniqueEquipmentIdentifier( - tx, - current, - patch.equipmentIdentifier - ); - - const appliedForward = createCircuitUpdateProjectCommand( - current.id, - patch as CircuitUpdatePatch - ); - const inversePatch = Object.fromEntries( - appliedForward.payload.changes.map((change) => [ - change.field, - getCircuitFieldValue(current, change.field), - ]) - ) as CircuitUpdatePatch; - const inverse = createCircuitUpdateProjectCommand( - current.id, - inversePatch - ); - - const update = tx - .update(circuits) - .set(toCircuitPatchValues(patch)) - .where(eq(circuits.id, current.id)) - .run(); - if (update.changes !== 1) { - throw new Error("Circuit changed before command execution."); - } - - return { forward: appliedForward, inverse }; + ) + .get(); + if (!owningList) { + throw new Error("Circuit does not belong to project."); } - private assertSectionInCircuitList( - database: AppDatabase, - circuit: CircuitRow, - sectionId: string | undefined - ) { - if (sectionId === undefined || sectionId === circuit.sectionId) { - return; - } - const section = database - .select({ id: circuitSections.id }) - .from(circuitSections) - .where( - and( - eq(circuitSections.id, sectionId), - eq(circuitSections.circuitListId, circuit.circuitListId) - ) - ) - .get(); - if (!section) { - throw new Error("Section does not belong to circuit list."); - } - } - - private assertUniqueEquipmentIdentifier( - database: AppDatabase, - circuit: CircuitRow, - equipmentIdentifier: string | undefined - ) { + const patch = Object.fromEntries( + input.command.payload.changes.map((change) => [ + change.field, + change.value, + ]) + ) as CircuitPatchPersistenceInput; + assertSectionInCircuitList(tx, current, patch.sectionId); + if (input.source === "user") { if ( - equipmentIdentifier === undefined || - equipmentIdentifier === circuit.equipmentIdentifier + input.command.payload.changes.every( + (change) => change.field === "voltage" + ) ) { - return; + throw new Error("Circuit voltage is derived and cannot be edited."); } - assertEquipmentIdentifierAvailable( - database, - circuit.circuitListId, - equipmentIdentifier, - circuit.id + const devicePhaseTypes = tx + .select({ phaseType: circuitDeviceRows.phaseType }) + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.circuitId, current.id)) + .all() + .map((row) => row.phaseType); + const derivedVoltage = resolveCircuitVoltage( + tx, + input.projectId, + patch.sectionId ?? current.sectionId, + devicePhaseTypes ); + if (derivedVoltage === current.voltage) { + delete patch.voltage; + } else { + patch.voltage = derivedVoltage; + } } + assertUniqueEquipmentIdentifier( + tx, + current, + patch.equipmentIdentifier + ); + + const appliedForward = createCircuitUpdateProjectCommand( + current.id, + patch as CircuitUpdatePatch + ); + const inversePatch = Object.fromEntries( + appliedForward.payload.changes.map((change) => [ + change.field, + getCircuitFieldValue(current, change.field), + ]) + ) as CircuitUpdatePatch; + const inverse = createCircuitUpdateProjectCommand( + current.id, + inversePatch + ); + + const update = tx + .update(circuits) + .set(toCircuitPatchValues(patch)) + .where(eq(circuits.id, current.id)) + .run(); + if (update.changes !== 1) { + throw new Error("Circuit changed before command execution."); + } + + return { forward: appliedForward, inverse }; +} + +function assertSectionInCircuitList( + database: AppDatabase, + circuit: CircuitRow, + sectionId: string | undefined +) { + if (sectionId === undefined || sectionId === circuit.sectionId) { + return; + } + const section = database + .select({ id: circuitSections.id }) + .from(circuitSections) + .where( + and( + eq(circuitSections.id, sectionId), + eq(circuitSections.circuitListId, circuit.circuitListId) + ) + ) + .get(); + if (!section) { + throw new Error("Section does not belong to circuit list."); + } +} + +function assertUniqueEquipmentIdentifier( + database: AppDatabase, + circuit: CircuitRow, + equipmentIdentifier: string | undefined +) { + if ( + equipmentIdentifier === undefined || + equipmentIdentifier === circuit.equipmentIdentifier + ) { + return; + } + assertEquipmentIdentifierAvailable( + database, + circuit.circuitListId, + equipmentIdentifier, + circuit.id + ); } function getCircuitFieldValue( diff --git a/src/db/repositories/circuit-protection-project-command.repository.ts b/src/db/repositories/circuit-protection-project-command.repository.ts index bbc38dd..9d6cdd5 100644 --- a/src/db/repositories/circuit-protection-project-command.repository.ts +++ b/src/db/repositories/circuit-protection-project-command.repository.ts @@ -14,69 +14,83 @@ import { circuitProtectionDevices } from "../schema/circuit-protection-devices.j import { circuits } from "../schema/circuits.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; +export type CircuitProtectionUpdateApplyInput = Pick< + ExecuteCircuitProtectionCommandInput, + "projectId" | "source" | "command" +>; + export class CircuitProtectionProjectCommandRepository implements CircuitProtectionProjectCommandStore { constructor(private readonly database: AppDatabase) {} execute(input: ExecuteCircuitProtectionCommandInput) { - return executeProjectCommandTransaction(this.database, input, (tx) => { - assertCircuitProtectionUpdateProjectCommand(input.command); - const { circuitId, expected, target } = input.command.payload; - if (input.source === "user" && target === null) { - throw new Error("User commands cannot remove circuit protection."); - } - this.assertOwnership(tx, input.projectId, circuitId); - const actual = tx - .select() - .from(circuitProtectionDevices) - .where(eq(circuitProtectionDevices.circuitId, circuitId)) - .get(); - if (!sameNullableSnapshot(expected, actual)) { - throw new Error("Circuit protection changed before update."); - } - if (target === null) { - tx.delete(circuitProtectionDevices) - .where(eq(circuitProtectionDevices.circuitId, circuitId)) - .run(); - } else if (actual) { - tx.update(circuitProtectionDevices) - .set(target) - .where(eq(circuitProtectionDevices.circuitId, circuitId)) - .run(); - } else { - tx.insert(circuitProtectionDevices).values(target).run(); - } - return createCircuitProtectionUpdateProjectCommand( - circuitId, - target, - expected - ); - }); + return executeProjectCommandTransaction(this.database, input, (tx) => + applyCircuitProtectionUpdateCommand(tx, input) + ); } +} - private assertOwnership( - database: AppDatabase, - projectId: string, - circuitId: string - ) { - const circuit = database - .select({ projectId: circuitLists.projectId }) - .from(circuits) - .innerJoin( - circuitLists, - eq(circuitLists.id, circuits.circuitListId) +// Applies one circuit-protection.update inside an open transaction and +// returns its exact inverse. Shared with circuit-list.bulk-update. +export function applyCircuitProtectionUpdateCommand( + tx: AppDatabase, + input: CircuitProtectionUpdateApplyInput +) { + assertCircuitProtectionUpdateProjectCommand(input.command); + const { circuitId, expected, target } = input.command.payload; + if (input.source === "user" && target === null) { + throw new Error("User commands cannot remove circuit protection."); + } + assertOwnership(tx, input.projectId, circuitId); + const actual = tx + .select() + .from(circuitProtectionDevices) + .where(eq(circuitProtectionDevices.circuitId, circuitId)) + .get(); + if (!sameNullableSnapshot(expected, actual)) { + throw new Error("Circuit protection changed before update."); + } + if (target === null) { + tx.delete(circuitProtectionDevices) + .where(eq(circuitProtectionDevices.circuitId, circuitId)) + .run(); + } else if (actual) { + tx.update(circuitProtectionDevices) + .set(target) + .where(eq(circuitProtectionDevices.circuitId, circuitId)) + .run(); + } else { + tx.insert(circuitProtectionDevices).values(target).run(); + } + return createCircuitProtectionUpdateProjectCommand( + circuitId, + target, + expected + ); +} + +function assertOwnership( + database: AppDatabase, + projectId: string, + circuitId: string +) { + const circuit = database + .select({ projectId: circuitLists.projectId }) + .from(circuits) + .innerJoin( + circuitLists, + eq(circuitLists.id, circuits.circuitListId) + ) + .where( + and( + eq(circuits.id, circuitId), + eq(circuitLists.projectId, projectId) ) - .where( - and( - eq(circuits.id, circuitId), - eq(circuitLists.projectId, projectId) - ) - ) - .get(); - if (!circuit) { - throw new Error("Circuit protection does not belong to project."); - } + ) + .get(); + if (!circuit) { + throw new Error("Circuit protection does not belong to project."); } } diff --git a/src/domain/models/circuit-list-bulk-update-project-command.model.ts b/src/domain/models/circuit-list-bulk-update-project-command.model.ts new file mode 100644 index 0000000..266062d --- /dev/null +++ b/src/domain/models/circuit-list-bulk-update-project-command.model.ts @@ -0,0 +1,111 @@ +import { + assertCircuitDeviceRowUpdateProjectCommand, + circuitDeviceRowUpdateCommandType, + type CircuitDeviceRowUpdateProjectCommand, +} from "./circuit-device-row-project-command.model.js"; +import { + assertCircuitUpdateProjectCommand, + circuitUpdateCommandType, + type CircuitUpdateProjectCommand, +} from "./circuit-project-command.model.js"; +import { + assertCircuitProtectionUpdateProjectCommand, + circuitProtectionUpdateCommandType, + type CircuitProtectionUpdateProjectCommand, +} from "./circuit-protection-project-command.model.js"; +import type { SerializedProjectCommand } from "./project-command.model.js"; + +// Several cell edits of the circuit list (multi-selection edit or paste) +// as one atomic command and one undo step. Each update is a complete +// single-entity command; its inverse is the reversed list of the applied +// inverses, so Undo/Redo never re-plans the edit. +export const circuitListBulkUpdateCommandType = + "circuit-list.bulk-update" as const; +export const circuitListBulkUpdateCommandSchemaVersion = 1 as const; +export const circuitListBulkUpdateMaxUpdates = 2000; + +export type CircuitListBulkUpdateEntry = + | CircuitUpdateProjectCommand + | CircuitDeviceRowUpdateProjectCommand + | CircuitProtectionUpdateProjectCommand; + +export interface CircuitListBulkUpdateCommandPayload { + updates: CircuitListBulkUpdateEntry[]; +} + +export interface CircuitListBulkUpdateProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof circuitListBulkUpdateCommandSchemaVersion; + type: typeof circuitListBulkUpdateCommandType; +} + +export function createCircuitListBulkUpdateProjectCommand( + updates: CircuitListBulkUpdateEntry[] +): CircuitListBulkUpdateProjectCommand { + const command: CircuitListBulkUpdateProjectCommand = { + schemaVersion: circuitListBulkUpdateCommandSchemaVersion, + type: circuitListBulkUpdateCommandType, + payload: { updates }, + }; + assertCircuitListBulkUpdateProjectCommand(command); + return command; +} + +export function assertCircuitListBulkUpdateProjectCommand( + command: SerializedProjectCommand +): asserts command is CircuitListBulkUpdateProjectCommand { + if ( + command.schemaVersion !== circuitListBulkUpdateCommandSchemaVersion || + command.type !== circuitListBulkUpdateCommandType || + !isPlainObject(command.payload) || + Object.keys(command.payload).length !== 1 + ) { + throw new Error("Unsupported circuit-list bulk update command."); + } + const updates = command.payload.updates; + if ( + !Array.isArray(updates) || + updates.length === 0 || + updates.length > circuitListBulkUpdateMaxUpdates + ) { + throw new Error( + `Circuit-list bulk update requires 1 to ${circuitListBulkUpdateMaxUpdates} updates.` + ); + } + const seenTargets = new Set(); + for (const update of updates) { + if (!isPlainObject(update)) { + throw new Error("Circuit-list bulk update contains an invalid update."); + } + const target = getBulkUpdateTargetKey( + update as unknown as SerializedProjectCommand + ); + if (seenTargets.has(target)) { + throw new Error("Circuit-list bulk update changes one target twice."); + } + seenTargets.add(target); + } +} + +// Validates one nested update and returns its unique type/entity key. +function getBulkUpdateTargetKey( + update: SerializedProjectCommand +): string { + if (update.type === circuitUpdateCommandType) { + assertCircuitUpdateProjectCommand(update); + return `${update.type}:${update.payload.circuitId}`; + } + if (update.type === circuitDeviceRowUpdateCommandType) { + assertCircuitDeviceRowUpdateProjectCommand(update); + return `${update.type}:${update.payload.rowId}`; + } + if (update.type === circuitProtectionUpdateCommandType) { + assertCircuitProtectionUpdateProjectCommand(update); + return `${update.type}:${update.payload.circuitId}`; + } + throw new Error("Circuit-list bulk update contains an unsupported update."); +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/ports/circuit-list-bulk-update-project-command.store.ts b/src/domain/ports/circuit-list-bulk-update-project-command.store.ts new file mode 100644 index 0000000..67eff64 --- /dev/null +++ b/src/domain/ports/circuit-list-bulk-update-project-command.store.ts @@ -0,0 +1,22 @@ +import type { CircuitListBulkUpdateProjectCommand } from "../models/circuit-list-bulk-update-project-command.model.js"; +import type { + AppendedProjectRevision, + ProjectRevisionSource, +} from "./project-revision.store.js"; + +export interface ExecuteCircuitListBulkUpdateCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: CircuitListBulkUpdateProjectCommand; +} + +export interface CircuitListBulkUpdateProjectCommandStore { + execute(input: ExecuteCircuitListBulkUpdateCommandInput): { + revision: AppendedProjectRevision; + inverse: CircuitListBulkUpdateProjectCommand; + }; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index 960ee64..25fc776 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -214,6 +214,11 @@ import { estimateBalanceUpdateCommandType, } from "../../estimate-balance/domain/estimate-balance-command.model.js"; import type { EstimateBalanceProjectCommandStore } from "../../estimate-balance/domain/estimate-balance-project-command.store.js"; +import { + assertCircuitListBulkUpdateProjectCommand, + circuitListBulkUpdateCommandType, +} from "../models/circuit-list-bulk-update-project-command.model.js"; +import type { CircuitListBulkUpdateProjectCommandStore } from "../ports/circuit-list-bulk-update-project-command.store.js"; interface DispatchProjectCommandInput { projectId: string; @@ -257,7 +262,8 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore, private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore, private readonly externalFollowUpImportStore?: ExternalFollowUpImportProjectCommandStore, - private readonly externalObjectDeleteMissingStore?: ExternalObjectDeleteMissingProjectCommandStore + private readonly externalObjectDeleteMissingStore?: ExternalObjectDeleteMissingProjectCommandStore, + private readonly circuitListBulkUpdateStore?: CircuitListBulkUpdateProjectCommandStore ) {} executeUser( @@ -532,6 +538,16 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case circuitListBulkUpdateCommandType: { + assertCircuitListBulkUpdateProjectCommand(input.command); + if (!this.circuitListBulkUpdateStore) { + throw new Error("Circuit-list bulk update store is not available."); + } + return this.circuitListBulkUpdateStore.execute({ + ...input, + command: input.command, + }).revision; + } case projectBuildingInsertCommandType: { assertProjectBuildingInsertProjectCommand(input.command); return this.projectLocationStructureStore.execute({ ...input, command: input.command }).revision; diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index fedf3f9..76a746b 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -7,8 +7,11 @@ import { ArrowDown01, ArrowLeft, ArrowUp, + BetweenHorizontalStart, Calculator, Check, + ClipboardCopy, + ClipboardPaste, Columns3, FilterX, FolderPlus, @@ -22,6 +25,7 @@ import { Pencil, Plus, Redo2, + Shield, ShieldPlus, TableProperties, Trash2, @@ -148,9 +152,22 @@ import { updateCircuitDeviceRowById, updateCircuitProtectionCommand, updateCircuitGroupCommand, + updateCircuitListCellsCommand, updateDistributionBoardComponentCommand, undoProjectCommand, } from "../utils/api"; +import { + buildCellClipboard, + formatCellEditText, + gridDataRowTypes, + isCircuitRecordCell, + isCircuitRow, + planCellWrites, + resolveClipboardCells, + resolvePasteWrites, + type CellClipboard, + type CellWrite, +} from "../utils/circuit-grid-clipboard"; import type { CircuitTreeCircuitDto, CircuitTreeComponentDto, @@ -173,6 +190,7 @@ import { DistributionBoardComponentModal } from "./distribution-board-component- import { GzfPickerModal } from "./gzf-picker-modal"; import { CircuitGroupModal } from "./circuit-group-modal"; import { CircuitProtectionModal } from "./circuit-protection-modal"; +import { GridContextMenu, type GridContextMenuEntry } from "./grid-context-menu"; import { CableSizingModal } from "./cable-sizing-modal"; import { circuitGroupCategoryLabels, @@ -321,6 +339,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str // editingCell is mounted input session state (draft + mode + focus token). // It should only exist for currently editable grid cells. const [editingCell, setEditingCell] = useState(null); + const [contextMenu, setContextMenu] = useState< + { x: number; y: number; rowKey: string; cellKey: CellKey } | null + >(null); + // Last cells copied in this editor. Pasting the same text again reuses the + // structured values (complete protection/cable records). + const cellClipboardRef = useRef(null); + const [hasCellClipboard, setHasCellClipboard] = useState(false); + const [gridNotice, setGridNotice] = useState(null); const [activeSectionId, setActiveSectionId] = useState(null); const [isSaving, setIsSaving] = useState(false); // Synchronous re-entry guard: isSaving is React state and only reflects @@ -904,7 +930,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str () => visibleRows.flatMap((row) => row.cells - .filter((cell) => cell.editable) + .filter((cell) => cell.editable || isCircuitRecordCell(row, cell.cellKey)) .map((cell) => ({ rowKey: row.rowKey, cellKey: cell.cellKey as CellKey })) ), [visibleRows] @@ -917,7 +943,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const map = new Map(); for (const row of visibleRows) { const keys = row.cells - .filter((cell) => cell.editable && visibleKeySet.has(cell.cellKey)) + .filter( + (cell) => + (cell.editable || isCircuitRecordCell(row, cell.cellKey)) && + visibleKeySet.has(cell.cellKey) + ) .map((cell) => cell.cellKey as CellKey); if (keys.length) { map.set(row.rowKey, keys); @@ -1520,6 +1550,16 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }); } + // Circuit rows of a multi-selection that contains the edited circuit. + function getProtectionMultiSelectionRows(circuitId: string) { + const circuitRowKey = visibleRows.find( + (row) => row.circuit?.id === circuitId && isCircuitRow(row) + )?.rowKey; + const rows = circuitRowKey ? getMultiSelectionRows(circuitRowKey) : null; + const circuitRows = rows?.filter((row) => isCircuitRow(row)) ?? []; + return circuitRows.length > 1 ? circuitRows : null; + } + async function handleSaveCircuitProtection( protection: CircuitTreeProtectionDeviceDto ) { @@ -1527,6 +1567,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str if (!circuit) { return; } + const multiSelectionRows = getProtectionMultiSelectionRows(circuit.id); + if (multiSelectionRows) { + const { circuitId: _circuitId, ...protectionValue } = + toCircuitProtectionSnapshot(circuit.id, protection); + setProtectionEditorCircuit(null); + await applyCellWrites( + multiSelectionRows.map((row) => ({ + row, + cellKey: "protectionSummary", + value: { kind: "protection", text: "", protection: protectionValue }, + })), + "Stromkreisschutz für mehrere Stromkreise bearbeiten" + ); + return; + } await runCommand({ label: "Stromkreisschutz bearbeiten", redo: async () => { @@ -1858,15 +1913,27 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str // Opens edit session for selected cell. // Enter/F2/double-click use selectExisting; type-to-edit uses replaceWithTypedChar. function startEdit(cell: SelectedCell, mode: StartEditMode, typedChar?: string) { + const row = findRow(cell.rowKey); const visibleCell = findCell(cell.rowKey, cell.cellKey); + // The protection summary is edited in its dialog (Enter/F2/double click). + if ( + row?.circuit && + cell.cellKey === "protectionSummary" && + isCircuitRecordCell(row, cell.cellKey) && + mode !== "replaceWithTypedChar" + ) { + setProtectionEditorCircuit(row.circuit); + return; + } if (!visibleCell || !visibleCell.editable) { return; } // Spreadsheet behavior: typing on a selected cell starts overwrite mode. + // Existing numbers use the decimal comma the edit parser expects. const currentDisplay = mode === "replaceWithTypedChar" && cell.cellKey !== "phaseType" ? typedChar ?? "" - : String(visibleCell.editValue ?? visibleCell.value ?? ""); + : formatCellEditText(visibleCell.editValue ?? visibleCell.value); focusTokenRef.current += 1; setEditingCell({ ...cell, @@ -2048,6 +2115,24 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return buildSelectionIntent(selected); }; + // With several selected rows the value goes to the same column of every + // selected row that can take it, as one command and one undo step. + const multiSelectionRows = getMultiSelectionRows(row.rowKey); + if (multiSelectionRows) { + setEditingCell(null); + await applyCellWrites( + multiSelectionRows.map((targetRow) => ({ + row: targetRow, + cellKey: session.cellKey, + value: { kind: "text", text: draft }, + })), + "Mehrere Zeilen bearbeiten", + { allowEquipmentIdentifier: false }, + nextSelectionIntent + ); + return; + } + // Placeholder rows are not persisted entities; editing them materializes a real circuit first. if (row.rowType === "placeholder") { const command: HistoryCommand = { @@ -2179,6 +2264,373 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str focusGridWithoutScroll(); } + // Selected data rows in grid order while a multi-selection contains the + // given row; null for a single-row selection. + function getMultiSelectionRows(rowKey: string): VisibleGridRow[] | null { + if (selectedRowKeys.length < 2 || !selectedRowKeys.includes(rowKey)) { + return null; + } + const selected = new Set(selectedRowKeys); + return visibleRows.filter( + (row) => selected.has(row.rowKey) && gridDataRowTypes.has(row.rowType) + ); + } + + // Plans cell writes and stores them as one circuit-list.bulk-update. + async function applyCellWrites( + writes: CellWrite[], + label: string, + options: { allowEquipmentIdentifier?: boolean } = {}, + selectionIntent: () => SelectionIntent | null = () => null, + extraSkippedCellCount = 0 + ) { + let plan: ReturnType; + try { + plan = planCellWrites(writes, options); + } catch (err) { + setError(normalizeUiError(err)); + return; + } + const skipped = plan.skippedCellCount + extraSkippedCellCount; + const skippedText = + skipped > 0 + ? `${skipped} Zelle(n) übersprungen (nicht bearbeitbar oder nicht passend).` + : ""; + if (plan.updates.length === 0) { + setGridNotice( + skippedText || "Keine Änderung – die Werte sind bereits gesetzt." + ); + return; + } + setGridNotice( + `${plan.changedCellCount} Zelle(n) geändert.${skippedText ? ` ${skippedText}` : ""}` + ); + await runCommand({ + label, + redo: async () => { + const result = await updateCircuitListCellsCommand( + projectId, + getExpectedProjectRevision(), + plan.updates, + label + ); + applyProjectCommandResult(result); + return selectionIntent(); + }, + }); + } + + // Cells to copy: the selected cell, or its column for every selected row. + function buildSelectionClipboard(): CellClipboard | null { + if (!selectedCell) { + return null; + } + const row = findRow(selectedCell.rowKey); + if (!row) { + return null; + } + return buildCellClipboard( + getMultiSelectionRows(row.rowKey) ?? [row], + [selectedCell.cellKey] + ); + } + + function copySelectionToClipboard(clipboardData: DataTransfer | null) { + const clipboard = buildSelectionClipboard(); + if (!clipboard) { + return false; + } + cellClipboardRef.current = clipboard; + setHasCellClipboard(true); + clipboardData?.setData("text/plain", clipboard.text); + const count = clipboard.cells.length; + setGridNotice( + count > 1 ? `${count} Zellen kopiert.` : "Zelle kopiert." + ); + return true; + } + + async function pasteClipboardText(text: string) { + if (!selectedCell || isSaving || historyBusy) { + return; + } + const cells = resolveClipboardCells(text, cellClipboardRef.current); + if (cells.length === 0) { + return; + } + const { writes, overflowCellCount } = resolvePasteWrites({ + cells, + anchor: selectedCell, + selectedRowKeys, + rows: visibleRows, + columnKeys: visibleColumns.map((column) => column.key), + }); + await applyCellWrites( + writes, + "Zellen einfügen", + {}, + () => buildSelectionIntent(selectedCell), + overflowCellCount + ); + } + + // Menu paste has no clipboard event (browsers block reading it there), so + // it reuses the cells last copied in this editor. + function pasteFromCellClipboard() { + const clipboard = cellClipboardRef.current; + if (clipboard) { + void pasteClipboardText(clipboard.text); + } + } + + // execCommand("copy") also works over plain HTTP and runs the grid's copy + // listener, which writes the system clipboard. + function copyFromMenu() { + focusGridWithoutScroll(); + let copied = false; + try { + copied = document.execCommand("copy"); + } catch { + copied = false; + } + if (!copied) { + copySelectionToClipboard(null); + } + } + + function openContextMenu(row: VisibleGridRow, cellKey: CellKey, x: number, y: number) { + if (editingCell) { + setEditingCell(null); + } + if (selectedRowKeys.length > 1 && selectedRowKeys.includes(row.rowKey)) { + setSelectedCell({ rowKey: row.rowKey, cellKey }); + } else { + handleRowSelectionClick(row, cellKey); + } + setContextMenu({ x, y, rowKey: row.rowKey, cellKey }); + } + + // Keyboard access (context-menu key or Shift+F10) opens the menu at the cell. + function openContextMenuForSelectedCell() { + if (!selectedCell) { + return; + } + const row = findRow(selectedCell.rowKey); + const element = containerRef.current?.querySelector( + `td[data-row-key="${CSS.escape(selectedCell.rowKey)}"][data-cell-key="${CSS.escape(selectedCell.cellKey)}"]` + ); + if (!row || !element) { + return; + } + const rect = element.getBoundingClientRect(); + openContextMenu(row, selectedCell.cellKey, rect.left + 8, rect.bottom); + } + + function closeContextMenu(restoreFocus: boolean) { + setContextMenu(null); + if (restoreFocus) { + focusGridWithoutScroll(); + } + } + + // Multi-selection hint and the result of the last copy/paste/bulk edit. + function renderGridStatus() { + const selectionRows = selectedCell + ? getMultiSelectionRows(selectedCell.rowKey) + : null; + if (!selectionRows && !gridNotice) { + return null; + } + return ( +
+ {selectionRows ? ( + + {selectionRows.length} Zeilen ausgewählt – Eingaben, Einfügen und + Schutzgerät gelten für alle passenden Zeilen. + + ) : null} + {gridNotice ? {gridNotice} : null} + {gridNotice ? ( + + ) : null} +
+ ); + } + + function buildContextMenuEntries(): GridContextMenuEntry[] { + const row = contextMenu ? findRow(contextMenu.rowKey) : null; + if (!contextMenu || !row) { + return []; + } + const busy = isSaving || historyBusy; + const { circuit, device } = row; + const cell = row.cells.find((entry) => entry.cellKey === contextMenu.cellKey); + const multiSelectionRows = getMultiSelectionRows(row.rowKey); + const protectionRows = circuit + ? getProtectionMultiSelectionRows(circuit.id) + : null; + const feedsDownstreamBoard = (circuit?.downstreamBoardLoads?.length ?? 0) > 0; + const entries: GridContextMenuEntry[] = [ + { + kind: "action", + label: multiSelectionRows + ? `${multiSelectionRows.length} Zellen kopieren` + : "Kopieren", + icon: ClipboardCopy, + shortcut: "Strg+C", + onSelect: copyFromMenu, + }, + { + kind: "action", + label: multiSelectionRows + ? `In ${multiSelectionRows.length} Zeilen einfügen` + : "Einfügen", + icon: ClipboardPaste, + shortcut: "Strg+V", + disabled: !hasCellClipboard || busy, + title: hasCellClipboard + ? undefined + : "Zuerst Zellen dieser Stromkreisliste kopieren. Werte aus Excel mit Strg+V einfügen.", + onSelect: pasteFromCellClipboard, + }, + { kind: "separator" }, + ]; + if (cell?.editable) { + entries.push({ + kind: "action", + label: multiSelectionRows + ? `Bearbeiten (${multiSelectionRows.length} Zeilen)` + : "Bearbeiten", + icon: Pencil, + shortcut: "Enter", + disabled: busy, + onSelect: () => + startEdit({ rowKey: row.rowKey, cellKey: contextMenu.cellKey }, "selectExisting"), + }); + } + if (circuit && isCircuitRow(row)) { + entries.push( + { + kind: "action", + label: protectionRows + ? `Schutzgerät für ${protectionRows.length} Stromkreise bearbeiten` + : "Schutzgerät bearbeiten", + icon: Shield, + disabled: busy, + onSelect: () => setProtectionEditorCircuit(circuit), + }, + { + kind: "action", + label: "Leitungsberechnung", + icon: Calculator, + disabled: busy, + onSelect: () => setCableSizingEditorCircuit(circuit), + } + ); + } + if (circuit) { + entries.push( + { kind: "separator" }, + { + kind: "action", + label: "Stromkreis unterhalb einfügen", + icon: BetweenHorizontalStart, + disabled: busy, + onSelect: () => void handleAddReserveCircuit(row.sectionId, circuit.id), + }, + { + kind: "action", + label: "Manuelles Gerät hinzufügen", + icon: ListPlus, + disabled: busy, + onSelect: () => + void handleAddManualDevice(circuit, row.sectionId, device?.id), + } + ); + } + if (device || (circuit && isCircuitRow(row))) { + entries.push({ kind: "separator" }); + } + if (device) { + entries.push({ + kind: "action", + label: `Gerät „${device.displayName || device.name}“ löschen`, + icon: ListX, + variant: "danger", + disabled: busy, + onSelect: () => void handleDeleteDevice(device.id), + }); + } + if (circuit && isCircuitRow(row)) { + entries.push({ + kind: "action", + label: `Stromkreis ${circuit.equipmentIdentifier} löschen`, + icon: Trash2, + variant: "danger", + disabled: busy || feedsDownstreamBoard, + title: feedsDownstreamBoard + ? "Speist eine Unterverteilung – zuerst deren Einspeisung ändern" + : undefined, + onSelect: () => void handleDeleteCircuit(circuit.id), + }); + } + return entries; + } + + // Latest handlers for the document-level clipboard listeners below. + const gridClipboardHandlersRef = useRef<{ + copy: (event: ClipboardEvent) => void; + paste: (event: ClipboardEvent) => void; + } | null>(null); + gridClipboardHandlersRef.current = { + copy: (event) => { + if (!isGridClipboardTarget()) { + return; + } + if (copySelectionToClipboard(event.clipboardData)) { + event.preventDefault(); + } + }, + paste: (event) => { + if (!isGridClipboardTarget()) { + return; + } + event.preventDefault(); + void pasteClipboardText(event.clipboardData?.getData("text/plain") ?? ""); + }, + }; + + // Copy/paste act on grid cells only while the grid itself has focus, no + // inline editor or dialog is open and no page text is selected. + function isGridClipboardTarget() { + const active = document.activeElement; + return Boolean( + selectedCell && + !editingCell && + !gzfPickerCell && + !protectionEditorCircuit && + active && + containerRef.current?.contains(active) && + !isGridEditorControlTarget(active) && + !window.getSelection()?.toString() + ); + } + + useEffect(() => { + const handleCopy = (event: ClipboardEvent) => + gridClipboardHandlersRef.current?.copy(event); + const handlePaste = (event: ClipboardEvent) => + gridClipboardHandlersRef.current?.paste(event); + document.addEventListener("copy", handleCopy); + document.addEventListener("paste", handlePaste); + return () => { + document.removeEventListener("copy", handleCopy); + document.removeEventListener("paste", handlePaste); + }; + }, []); + async function handleAddReserveCircuit(sectionId: string, afterCircuitId?: string) { const section = data?.sections.find((entry) => entry.id === sectionId); if (!section) { @@ -3191,6 +3643,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str if (eventTarget.closest("button, input, select, textarea")) { return; } + if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) { + event.preventDefault(); + openContextMenuForSelectedCell(); + return; + } if (event.key === "Escape") { if (selectedRowKeys.length > 0) { event.preventDefault(); @@ -3579,6 +4036,16 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str ) : null} {isSaving ?
Wird gespeichert …
: null} + {renderGridStatus()} + {contextMenu ? ( + + ) : null}
{isRevitDrawerOpen ? (