Add cell copy/paste, multi-row edits and a context menu to the circuit grid

Ctrl+C/Ctrl+V copy and paste grid cells without opening an editor, using
the document copy/paste events so it also works over plain HTTP in the
LAN. Protection and cable summaries copy the complete circuit record;
tab-separated blocks from Excel spread over visible rows and columns.
With a Ctrl/Shift multi-selection, typing, pasting and the protection
dialog apply to every selected row that can take the value, including
device power and GZF; other cells are skipped and reported.

All of these writes use the new circuit-list.bulk-update command. It
carries complete circuit.update, circuit-device-row.update and
circuit-protection.update commands, applies them with the single-command
rules in one transaction and stores the reversed inverses, so each edit
is one undo step. The single-command apply logic is extracted for reuse.

A right-click menu (also context-menu key / Shift+F10) offers copy,
paste, edit, protection, cable sizing, insert circuit below, add device
and delete. The protection summary is now selected by click and opened
by double click, Enter or F2. Existing numbers open in the inline editor
with a decimal comma, so confirming an unchanged decimal no longer fails.

Refs #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-10-30 07:38:28 +01:00
parent 07bc52a134
commit 17a3945813
18 changed files with 2548 additions and 391 deletions

View file

@ -186,6 +186,22 @@ Keyboard behavior:
Support Ctrl+Plus and Ctrl+Shift+Plus for insertion. 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`, Editor actions use `IconButton` (`src/frontend/components/icon-button.tsx`,
`lucide-react` icons). The German action text is always the accessible name and `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 tooltip; the toolbar shows icon plus text, group headers and rows show icons

View file

@ -453,6 +453,24 @@ Gerätezeilen atomar. Delete/Undo erfasst denselben Datensatz vollständig.
Im Editor sind die Schutzspalten deshalb eine Im Editor sind die Schutzspalten deshalb eine
schreibgeschützte Projektion der 1:1-Daten; Änderungen erfolgen über ein schreibgeschützte Projektion der 1:1-Daten; Änderungen erfolgen über ein
geräteabhängiges Schutzgeräte-Modal und `circuit-protection.update`. 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 Gruppen lassen sich im Editor schrittweise nur gegenüber einer benachbarten
Gruppe derselben Kategorie verschieben. Der Client sendet dabei immer die Gruppe derselben Kategorie verschieben. Der Client sendet dabei immer die
vollständige erwartete und gewünschte Sortierreihenfolge über vollständige erwartete und gewünschte Sortierreihenfolge über

View file

@ -316,6 +316,80 @@ a {
box-shadow: inset 3px 0 0 var(--color-signal); 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 { .sidebar-icon {
width: 1.1em; width: 1.1em;
display: inline-flex; display: inline-flex;
@ -1677,7 +1751,7 @@ a.kpi:hover {
.tree-grid .cell-protection-trigger { .tree-grid .cell-protection-trigger {
color: var(--color-primary); color: var(--color-primary);
cursor: pointer; cursor: cell;
font-weight: 600; font-weight: 600;
transition: transition:
background-color 0.12s ease, background-color 0.12s ease,
@ -1714,6 +1788,95 @@ a.kpi:hover {
outline-offset: -2px; 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 /* Picker trigger on the selected GZF cell. Positioned over the cell so the
column width does not change while the selection moves. */ column width does not change while the selection moves. */
.tree-grid td:has(> .cell-picker-trigger) { .tree-grid td:has(> .cell-picker-trigger) {

View file

@ -37,6 +37,11 @@ import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect; type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
export type CircuitDeviceRowUpdateApplyInput = Pick<
ExecuteCircuitDeviceRowUpdateCommandInput,
"projectId" | "source" | "command"
>;
export class CircuitDeviceRowProjectCommandRepository export class CircuitDeviceRowProjectCommandRepository
implements CircuitDeviceRowProjectCommandStore implements CircuitDeviceRowProjectCommandStore
{ {
@ -51,208 +56,216 @@ export class CircuitDeviceRowProjectCommandRepository
return executeProjectCommandTransactionWithAppliedForward( return executeProjectCommandTransactionWithAppliedForward(
this.database, this.database,
input, input,
(tx) => this.applyCommand(tx, input) (tx) => applyCircuitDeviceRowUpdateCommand(tx, input)
); );
} }
}
private applyCommand( // Applies one circuit-device-row.update inside an open transaction and
tx: AppDatabase, // returns the applied forward command plus its exact inverse, including
input: ExecuteCircuitDeviceRowUpdateCommandInput // single-device circuit name transitions. Shared with
) { // circuit-list.bulk-update.
const current = tx export function applyCircuitDeviceRowUpdateCommand(
.select() tx: AppDatabase,
.from(circuitDeviceRows) input: CircuitDeviceRowUpdateApplyInput
.where(eq(circuitDeviceRows.id, input.command.payload.rowId)) ) {
.get(); assertCircuitDeviceRowUpdateProjectCommand(input.command);
if (!current) { assertCircuitDisplayNameTransitions(
throw new Error("Invalid device row id."); 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 const circuit = tx
.select({ circuitListId: circuits.circuitListId }) .select({ circuitListId: circuits.circuitListId })
.from(circuits) .from(circuits)
.where(eq(circuits.id, current.circuitId)) .where(eq(circuits.id, current.circuitId))
.get(); .get();
const owningList = circuit const owningList = circuit
? tx ? tx
.select({ id: circuitLists.id }) .select({ id: circuitLists.id })
.from(circuitLists) .from(circuitLists)
.where( .where(
and( and(
eq(circuitLists.id, circuit.circuitListId), eq(circuitLists.id, circuit.circuitListId),
eq(circuitLists.projectId, input.projectId) 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()
.get(); : null;
if (!device) { if (!owningList) {
throw new Error("Invalid linked project device id."); throw new Error("Circuit device row does not belong to project.");
}
} }
private assertRoom( const patch = Object.fromEntries(
database: AppDatabase, input.command.payload.changes.map((change) => [
projectId: string, change.field,
patch: CircuitDeviceRowPatchInput 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 ( throw new Error("Device-row phase type is invalid.");
!Object.prototype.hasOwnProperty.call(patch, "roomId") || }
patch.roomId === null || assertLinkedProjectDevice(tx, input.projectId, patch);
patch.roomId === undefined assertRoom(tx, input.projectId, patch);
) {
return; const overriddenFields = deriveOverriddenFieldsForLocalEdit(
} current,
const room = database patch
.select({ id: rooms.id }) );
.from(rooms) if (
.where(and(eq(rooms.id, patch.roomId), eq(rooms.projectId, projectId))) overriddenFields !== undefined &&
.get(); patch.overriddenFields === undefined
if (!room) { ) {
throw new Error("Invalid room id."); 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.");
} }
} }

View file

@ -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,
}),
};
}

View file

@ -25,6 +25,11 @@ import { assertEquipmentIdentifierAvailable } from "./equipment-identifier-uniqu
type CircuitRow = typeof circuits.$inferSelect; type CircuitRow = typeof circuits.$inferSelect;
export type CircuitUpdateApplyInput = Pick<
ExecuteCircuitUpdateCommandInput,
"projectId" | "source" | "command"
>;
export class CircuitProjectCommandRepository export class CircuitProjectCommandRepository
implements CircuitProjectCommandStore implements CircuitProjectCommandStore
{ {
@ -36,144 +41,148 @@ export class CircuitProjectCommandRepository
return executeProjectCommandTransactionWithAppliedForward( return executeProjectCommandTransactionWithAppliedForward(
this.database, this.database,
input, input,
(tx) => this.applyCommand(tx, input) (tx) => applyCircuitUpdateCommand(tx, input)
); );
} }
}
private applyCommand( // Applies one circuit.update inside an open transaction and returns the
tx: AppDatabase, // applied forward command (with derived voltage) plus its exact inverse.
input: ExecuteCircuitUpdateCommandInput // Shared with circuit-list.bulk-update.
) { export function applyCircuitUpdateCommand(
const current = tx tx: AppDatabase,
.select() input: CircuitUpdateApplyInput
.from(circuits) ) {
.where(eq(circuits.id, input.command.payload.circuitId)) assertCircuitUpdateProjectCommand(input.command);
.get(); const current = tx
if (!current) { .select()
throw new Error("Invalid circuit id."); .from(circuits)
} .where(eq(circuits.id, input.command.payload.circuitId))
.get();
if (!current) {
throw new Error("Invalid circuit id.");
}
const owningList = tx const owningList = tx
.select({ id: circuitLists.id }) .select({ id: circuitLists.id })
.from(circuitLists) .from(circuitLists)
.where( .where(
and( and(
eq(circuitLists.id, current.circuitListId), eq(circuitLists.id, current.circuitListId),
eq(circuitLists.projectId, input.projectId) eq(circuitLists.projectId, input.projectId)
)
) )
.get(); )
if (!owningList) { .get();
throw new Error("Circuit does not belong to project."); 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 };
} }
private assertSectionInCircuitList( const patch = Object.fromEntries(
database: AppDatabase, input.command.payload.changes.map((change) => [
circuit: CircuitRow, change.field,
sectionId: string | undefined change.value,
) { ])
if (sectionId === undefined || sectionId === circuit.sectionId) { ) as CircuitPatchPersistenceInput;
return; assertSectionInCircuitList(tx, current, patch.sectionId);
} if (input.source === "user") {
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
) {
if ( if (
equipmentIdentifier === undefined || input.command.payload.changes.every(
equipmentIdentifier === circuit.equipmentIdentifier (change) => change.field === "voltage"
)
) { ) {
return; throw new Error("Circuit voltage is derived and cannot be edited.");
} }
assertEquipmentIdentifierAvailable( const devicePhaseTypes = tx
database, .select({ phaseType: circuitDeviceRows.phaseType })
circuit.circuitListId, .from(circuitDeviceRows)
equipmentIdentifier, .where(eq(circuitDeviceRows.circuitId, current.id))
circuit.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<TField extends CircuitUpdateField>( function getCircuitFieldValue<TField extends CircuitUpdateField>(

View file

@ -14,69 +14,83 @@ import { circuitProtectionDevices } from "../schema/circuit-protection-devices.j
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export type CircuitProtectionUpdateApplyInput = Pick<
ExecuteCircuitProtectionCommandInput,
"projectId" | "source" | "command"
>;
export class CircuitProtectionProjectCommandRepository export class CircuitProtectionProjectCommandRepository
implements CircuitProtectionProjectCommandStore implements CircuitProtectionProjectCommandStore
{ {
constructor(private readonly database: AppDatabase) {} constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitProtectionCommandInput) { execute(input: ExecuteCircuitProtectionCommandInput) {
return executeProjectCommandTransaction(this.database, input, (tx) => { return executeProjectCommandTransaction(this.database, input, (tx) =>
assertCircuitProtectionUpdateProjectCommand(input.command); applyCircuitProtectionUpdateCommand(tx, input)
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
);
});
} }
}
private assertOwnership( // Applies one circuit-protection.update inside an open transaction and
database: AppDatabase, // returns its exact inverse. Shared with circuit-list.bulk-update.
projectId: string, export function applyCircuitProtectionUpdateCommand(
circuitId: string tx: AppDatabase,
) { input: CircuitProtectionUpdateApplyInput
const circuit = database ) {
.select({ projectId: circuitLists.projectId }) assertCircuitProtectionUpdateProjectCommand(input.command);
.from(circuits) const { circuitId, expected, target } = input.command.payload;
.innerJoin( if (input.source === "user" && target === null) {
circuitLists, throw new Error("User commands cannot remove circuit protection.");
eq(circuitLists.id, circuits.circuitListId) }
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( .get();
eq(circuits.id, circuitId), if (!circuit) {
eq(circuitLists.projectId, projectId) throw new Error("Circuit protection does not belong to project.");
)
)
.get();
if (!circuit) {
throw new Error("Circuit protection does not belong to project.");
}
} }
} }

View file

@ -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<CircuitListBulkUpdateCommandPayload> {
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<unknown>
): 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<string>();
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<unknown>
);
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<unknown>
): 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<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

View file

@ -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;
};
}

View file

@ -214,6 +214,11 @@ import {
estimateBalanceUpdateCommandType, estimateBalanceUpdateCommandType,
} from "../../estimate-balance/domain/estimate-balance-command.model.js"; } from "../../estimate-balance/domain/estimate-balance-command.model.js";
import type { EstimateBalanceProjectCommandStore } from "../../estimate-balance/domain/estimate-balance-project-command.store.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 { interface DispatchProjectCommandInput {
projectId: string; projectId: string;
@ -257,7 +262,8 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore, private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore, private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore,
private readonly externalFollowUpImportStore?: ExternalFollowUpImportProjectCommandStore, private readonly externalFollowUpImportStore?: ExternalFollowUpImportProjectCommandStore,
private readonly externalObjectDeleteMissingStore?: ExternalObjectDeleteMissingProjectCommandStore private readonly externalObjectDeleteMissingStore?: ExternalObjectDeleteMissingProjectCommandStore,
private readonly circuitListBulkUpdateStore?: CircuitListBulkUpdateProjectCommandStore
) {} ) {}
executeUser( executeUser(
@ -532,6 +538,16 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command, command: input.command,
}).revision; }).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: { case projectBuildingInsertCommandType: {
assertProjectBuildingInsertProjectCommand(input.command); assertProjectBuildingInsertProjectCommand(input.command);
return this.projectLocationStructureStore.execute({ ...input, command: input.command }).revision; return this.projectLocationStructureStore.execute({ ...input, command: input.command }).revision;

View file

@ -7,8 +7,11 @@ import {
ArrowDown01, ArrowDown01,
ArrowLeft, ArrowLeft,
ArrowUp, ArrowUp,
BetweenHorizontalStart,
Calculator, Calculator,
Check, Check,
ClipboardCopy,
ClipboardPaste,
Columns3, Columns3,
FilterX, FilterX,
FolderPlus, FolderPlus,
@ -22,6 +25,7 @@ import {
Pencil, Pencil,
Plus, Plus,
Redo2, Redo2,
Shield,
ShieldPlus, ShieldPlus,
TableProperties, TableProperties,
Trash2, Trash2,
@ -148,9 +152,22 @@ import {
updateCircuitDeviceRowById, updateCircuitDeviceRowById,
updateCircuitProtectionCommand, updateCircuitProtectionCommand,
updateCircuitGroupCommand, updateCircuitGroupCommand,
updateCircuitListCellsCommand,
updateDistributionBoardComponentCommand, updateDistributionBoardComponentCommand,
undoProjectCommand, undoProjectCommand,
} from "../utils/api"; } from "../utils/api";
import {
buildCellClipboard,
formatCellEditText,
gridDataRowTypes,
isCircuitRecordCell,
isCircuitRow,
planCellWrites,
resolveClipboardCells,
resolvePasteWrites,
type CellClipboard,
type CellWrite,
} from "../utils/circuit-grid-clipboard";
import type { import type {
CircuitTreeCircuitDto, CircuitTreeCircuitDto,
CircuitTreeComponentDto, CircuitTreeComponentDto,
@ -173,6 +190,7 @@ import { DistributionBoardComponentModal } from "./distribution-board-component-
import { GzfPickerModal } from "./gzf-picker-modal"; import { GzfPickerModal } from "./gzf-picker-modal";
import { CircuitGroupModal } from "./circuit-group-modal"; import { CircuitGroupModal } from "./circuit-group-modal";
import { CircuitProtectionModal } from "./circuit-protection-modal"; import { CircuitProtectionModal } from "./circuit-protection-modal";
import { GridContextMenu, type GridContextMenuEntry } from "./grid-context-menu";
import { CableSizingModal } from "./cable-sizing-modal"; import { CableSizingModal } from "./cable-sizing-modal";
import { import {
circuitGroupCategoryLabels, circuitGroupCategoryLabels,
@ -321,6 +339,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
// editingCell is mounted input session state (draft + mode + focus token). // editingCell is mounted input session state (draft + mode + focus token).
// It should only exist for currently editable grid cells. // It should only exist for currently editable grid cells.
const [editingCell, setEditingCell] = useState<EditingCell | null>(null); const [editingCell, setEditingCell] = useState<EditingCell | null>(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<CellClipboard | null>(null);
const [hasCellClipboard, setHasCellClipboard] = useState(false);
const [gridNotice, setGridNotice] = useState<string | null>(null);
const [activeSectionId, setActiveSectionId] = useState<string | null>(null); const [activeSectionId, setActiveSectionId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
// Synchronous re-entry guard: isSaving is React state and only reflects // 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) => visibleRows.flatMap((row) =>
row.cells row.cells
.filter((cell) => cell.editable) .filter((cell) => cell.editable || isCircuitRecordCell(row, cell.cellKey))
.map((cell) => ({ rowKey: row.rowKey, cellKey: cell.cellKey as CellKey })) .map((cell) => ({ rowKey: row.rowKey, cellKey: cell.cellKey as CellKey }))
), ),
[visibleRows] [visibleRows]
@ -917,7 +943,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const map = new Map<string, CellKey[]>(); const map = new Map<string, CellKey[]>();
for (const row of visibleRows) { for (const row of visibleRows) {
const keys = row.cells 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); .map((cell) => cell.cellKey as CellKey);
if (keys.length) { if (keys.length) {
map.set(row.rowKey, keys); 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( async function handleSaveCircuitProtection(
protection: CircuitTreeProtectionDeviceDto protection: CircuitTreeProtectionDeviceDto
) { ) {
@ -1527,6 +1567,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (!circuit) { if (!circuit) {
return; 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({ await runCommand({
label: "Stromkreisschutz bearbeiten", label: "Stromkreisschutz bearbeiten",
redo: async () => { redo: async () => {
@ -1858,15 +1913,27 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
// Opens edit session for selected cell. // Opens edit session for selected cell.
// Enter/F2/double-click use selectExisting; type-to-edit uses replaceWithTypedChar. // Enter/F2/double-click use selectExisting; type-to-edit uses replaceWithTypedChar.
function startEdit(cell: SelectedCell, mode: StartEditMode, typedChar?: string) { function startEdit(cell: SelectedCell, mode: StartEditMode, typedChar?: string) {
const row = findRow(cell.rowKey);
const visibleCell = findCell(cell.rowKey, cell.cellKey); 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) { if (!visibleCell || !visibleCell.editable) {
return; return;
} }
// Spreadsheet behavior: typing on a selected cell starts overwrite mode. // Spreadsheet behavior: typing on a selected cell starts overwrite mode.
// Existing numbers use the decimal comma the edit parser expects.
const currentDisplay = const currentDisplay =
mode === "replaceWithTypedChar" && cell.cellKey !== "phaseType" mode === "replaceWithTypedChar" && cell.cellKey !== "phaseType"
? typedChar ?? "" ? typedChar ?? ""
: String(visibleCell.editValue ?? visibleCell.value ?? ""); : formatCellEditText(visibleCell.editValue ?? visibleCell.value);
focusTokenRef.current += 1; focusTokenRef.current += 1;
setEditingCell({ setEditingCell({
...cell, ...cell,
@ -2048,6 +2115,24 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return buildSelectionIntent(selected); 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. // Placeholder rows are not persisted entities; editing them materializes a real circuit first.
if (row.rowType === "placeholder") { if (row.rowType === "placeholder") {
const command: HistoryCommand = { const command: HistoryCommand = {
@ -2179,6 +2264,373 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
focusGridWithoutScroll(); 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<typeof planCellWrites>;
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 (
<div className="grid-status-line" role="status">
{selectionRows ? (
<span className="grid-status-selection">
{selectionRows.length} Zeilen ausgewählt – Eingaben, Einfügen und
Schutzgerät gelten für alle passenden Zeilen.
</span>
) : null}
{gridNotice ? <span>{gridNotice}</span> : null}
{gridNotice ? (
<button type="button" onClick={() => setGridNotice(null)}>
Ausblenden
</button>
) : null}
</div>
);
}
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) { async function handleAddReserveCircuit(sectionId: string, afterCircuitId?: string) {
const section = data?.sections.find((entry) => entry.id === sectionId); const section = data?.sections.find((entry) => entry.id === sectionId);
if (!section) { if (!section) {
@ -3191,6 +3643,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (eventTarget.closest("button, input, select, textarea")) { if (eventTarget.closest("button, input, select, textarea")) {
return; return;
} }
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
event.preventDefault();
openContextMenuForSelectedCell();
return;
}
if (event.key === "Escape") { if (event.key === "Escape") {
if (selectedRowKeys.length > 0) { if (selectedRowKeys.length > 0) {
event.preventDefault(); event.preventDefault();
@ -3579,6 +4036,16 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</div> </div>
) : null} ) : null}
{isSaving ? <div className="notice info">Wird gespeichert …</div> : null} {isSaving ? <div className="notice info">Wird gespeichert …</div> : null}
{renderGridStatus()}
{contextMenu ? (
<GridContextMenu
ariaLabel="Zellenaktionen"
entries={buildContextMenuEntries()}
onClose={closeContextMenu}
x={contextMenu.x}
y={contextMenu.y}
/>
) : null}
<div className="tree-editor-layout"> <div className="tree-editor-layout">
{isRevitDrawerOpen ? ( {isRevitDrawerOpen ? (
<aside className="project-device-sidebar project-device-drawer" id="revit-object-drawer"> <aside className="project-device-sidebar project-device-drawer" id="revit-object-drawer">
@ -3772,6 +4239,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
tabIndex={0} tabIndex={0}
onFocus={handleContainerFocus} onFocus={handleContainerFocus}
onKeyDown={handleContainerKeyDown} onKeyDown={handleContainerKeyDown}
onContextMenu={(event) => {
if (event.target === event.currentTarget) {
event.preventDefault();
openContextMenuForSelectedCell();
}
}}
onMouseDown={(event) => { onMouseDown={(event) => {
if (event.target === event.currentTarget) { if (event.target === event.currentTarget) {
setSelectedRowKeys([]); setSelectedRowKeys([]);
@ -4665,14 +5138,16 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
row.circuit && row.circuit &&
(duplicateEquipmentIdentifierCircuitIds.has(row.circuit.id) || hasDraftIdentifierConflict) (duplicateEquipmentIdentifierCircuitIds.has(row.circuit.id) || hasDraftIdentifierConflict)
); );
const isProtectionTrigger = Boolean( const isProtectionTrigger =
column.key === "protectionSummary" && column.key === "protectionSummary" &&
row.circuit && isCircuitRecordCell(row, column.key);
row.rowType !== "deviceRow" const isSelectableCell =
); cell.editable || isCircuitRecordCell(row, column.key);
return ( return (
<td <td
key={column.key} key={column.key}
data-cell-key={column.key}
data-row-key={row.rowKey}
className={`${column.numeric ? "num" : ""} ${cell.editable ? "cell-editable" : ""} ${isProtectionTrigger ? "cell-protection-trigger" : ""} ${isSelected ? "cell-selected" : ""} ${hasIdentifierConflict ? "cell-invalid" : ""} ${ className={`${column.numeric ? "num" : ""} ${cell.editable ? "cell-editable" : ""} ${isProtectionTrigger ? "cell-protection-trigger" : ""} ${isSelected ? "cell-selected" : ""} ${hasIdentifierConflict ? "cell-invalid" : ""} ${
Boolean(row.device) && column.key === "displayName" && (row.rowType === "deviceRow" || row.rowType === "circuitCompact") Boolean(row.device) && column.key === "displayName" && (row.rowType === "deviceRow" || row.rowType === "circuitCompact")
? "device-drag-handle" ? "device-drag-handle"
@ -4694,7 +5169,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
cell.lockedReason cell.lockedReason
? cell.lockedReason ? cell.lockedReason
: isProtectionTrigger : isProtectionTrigger
? "Schutzgerät bearbeiten" ? "Doppelklick oder Enter: Schutzgerät bearbeiten"
: column.key === "equipmentIdentifier" && : column.key === "equipmentIdentifier" &&
row.circuit && row.circuit &&
(row.rowType === "circuitCompact" || (row.rowType === "circuitCompact" ||
@ -4770,11 +5245,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (isGridEditorControlTarget(event.target)) { if (isGridEditorControlTarget(event.target)) {
return; return;
} }
if (isProtectionTrigger) { if (isSelectableCell) {
setProtectionEditorCircuit(row.circuit!);
return;
}
if (cell.editable) {
handleRowSelectionClick(row, column.key, { handleRowSelectionClick(row, column.key, {
ctrlKey: event.ctrlKey, ctrlKey: event.ctrlKey,
metaKey: event.metaKey, metaKey: event.metaKey,
@ -4786,10 +5257,17 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (isGridEditorControlTarget(event.target)) { if (isGridEditorControlTarget(event.target)) {
return; return;
} }
if (cell.editable) { if (isSelectableCell) {
startEdit({ rowKey: row.rowKey, cellKey: column.key }, "selectExisting"); startEdit({ rowKey: row.rowKey, cellKey: column.key }, "selectExisting");
} }
}} }}
onContextMenu={(event) => {
if (isGridEditorControlTarget(event.target) || !isSelectableCell) {
return;
}
event.preventDefault();
openContextMenu(row, column.key, event.clientX, event.clientY);
}}
> >
{isEditing && column.key === "phaseType" ? ( {isEditing && column.key === "phaseType" ? (
<select <select
@ -5054,7 +5532,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</div> </div>
</div> </div>
<p className="todo-hint"> <p className="todo-hint">
Strg+Plus fügt unterhalb des ausgewählten Stromkreises oder Geräts ein. Mit „Rückgängig“ lässt sich das Einfügen zurücknehmen. Strg+Plus fügt unterhalb des ausgewählten Stromkreises oder Geräts ein. Rechtsklick öffnet die Zellenaktionen, Strg+C/Strg+V kopiert und fügt Zellen ein; mit Strg/Umschalt ausgewählte Zeilen werden gemeinsam bearbeitet.
</p> </p>
</div> </div>
); );

View file

@ -0,0 +1,154 @@
"use client";
import type { LucideIcon } from "lucide-react";
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
export type GridContextMenuEntry =
| {
kind: "action";
label: string;
icon: LucideIcon;
onSelect: () => void;
shortcut?: string;
disabled?: boolean;
title?: string;
variant?: "default" | "danger";
}
| { kind: "separator" };
/**
* Right-click menu of the circuit grid at fixed viewport coordinates. It
* stays inside the viewport, closes on outside click, Escape, scroll or
* resize, and supports arrow-key navigation between enabled actions.
*/
export function GridContextMenu({
x,
y,
entries,
onClose,
ariaLabel,
}: {
x: number;
y: number;
entries: GridContextMenuEntry[];
/** `restoreFocus` is true when the menu was dismissed by keyboard. */
onClose: (restoreFocus: boolean) => void;
ariaLabel: string;
}) {
const menuRef = useRef<HTMLDivElement | null>(null);
// Optional groups may leave leading, trailing or doubled separators.
const visibleEntries = entries.filter(
(entry, index) =>
entry.kind !== "separator" ||
(index > 0 &&
index < entries.length - 1 &&
entries[index - 1].kind !== "separator")
);
const [position, setPosition] = useState({ left: x, top: y });
useLayoutEffect(() => {
const menu = menuRef.current;
if (!menu) {
return;
}
const { width, height } = menu.getBoundingClientRect();
const margin = 8;
setPosition({
left: Math.max(margin, Math.min(x, window.innerWidth - width - margin)),
top: Math.max(margin, Math.min(y, window.innerHeight - height - margin)),
});
menu
.querySelector<HTMLButtonElement>("button:not(:disabled)")
?.focus({ preventScroll: true });
}, [x, y]);
useEffect(() => {
function handlePointerDown(event: PointerEvent) {
if (!menuRef.current?.contains(event.target as Node)) {
onClose(false);
}
}
function handleViewportChange() {
onClose(true);
}
document.addEventListener("pointerdown", handlePointerDown, true);
window.addEventListener("resize", handleViewportChange);
window.addEventListener("scroll", handleViewportChange, true);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
window.removeEventListener("resize", handleViewportChange);
window.removeEventListener("scroll", handleViewportChange, true);
};
}, [onClose]);
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape" || event.key === "Tab") {
event.preventDefault();
onClose(true);
return;
}
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") {
return;
}
event.preventDefault();
const buttons = [
...(menuRef.current?.querySelectorAll<HTMLButtonElement>(
"button:not(:disabled)"
) ?? []),
];
if (buttons.length === 0) {
return;
}
const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
const next =
event.key === "ArrowDown"
? (index + 1) % buttons.length
: (index - 1 + buttons.length) % buttons.length;
buttons[next].focus();
}
return (
<div
aria-label={ariaLabel}
className="grid-context-menu"
onContextMenu={(event) => event.preventDefault()}
onKeyDown={handleKeyDown}
ref={menuRef}
role="menu"
style={{ left: position.left, top: position.top }}
>
{visibleEntries.map((entry, index) =>
entry.kind === "separator" ? (
<div
aria-hidden="true"
className="grid-context-menu-separator"
key={`separator-${index}`}
/>
) : (
<button
className={
entry.variant === "danger"
? "grid-context-menu-item danger"
: "grid-context-menu-item"
}
disabled={entry.disabled}
key={entry.label}
onClick={() => {
onClose(false);
entry.onSelect();
}}
role="menuitem"
title={entry.title}
type="button"
>
<entry.icon aria-hidden="true" size={15} strokeWidth={2} />
<span className="grid-context-menu-label">{entry.label}</span>
{entry.shortcut ? (
<span className="grid-context-menu-shortcut">{entry.shortcut}</span>
) : null}
</button>
)
)}
</div>
);
}

View file

@ -61,6 +61,10 @@ import {
createCircuitUpdateProjectCommand, createCircuitUpdateProjectCommand,
type CircuitUpdatePatch, type CircuitUpdatePatch,
} from "../../domain/models/circuit-project-command.model"; } from "../../domain/models/circuit-project-command.model";
import type {
CircuitListBulkUpdateEntry,
CircuitListBulkUpdateProjectCommand,
} from "../../domain/models/circuit-list-bulk-update-project-command.model";
import { import {
createCircuitDeviceRowUpdateProjectCommand, createCircuitDeviceRowUpdateProjectCommand,
type CircuitDeviceRowUpdatePatch, type CircuitDeviceRowUpdatePatch,
@ -850,6 +854,27 @@ export function updateCircuitProtectionCommand(
); );
} }
export function updateCircuitListCellsCommand(
projectId: string,
expectedRevision: number,
updates: CircuitListBulkUpdateEntry[],
description: string
) {
// Built as a literal: the model's validators use server-side imports;
// the server validates the command.
const command: CircuitListBulkUpdateProjectCommand = {
schemaVersion: 1,
type: "circuit-list.bulk-update",
payload: { updates },
};
return executeProjectCommand(
projectId,
expectedRevision,
command,
description
);
}
export function updateCircuitById( export function updateCircuitById(
projectId: string, projectId: string,
expectedRevision: number, expectedRevision: number,

View file

@ -0,0 +1,422 @@
import { createCircuitDeviceRowUpdateProjectCommand } from "../../domain/models/circuit-device-row-project-command.model";
import type { CircuitDeviceRowUpdatePatch } from "../../domain/models/circuit-device-row-project-command.model";
import type { CircuitListBulkUpdateEntry } from "../../domain/models/circuit-list-bulk-update-project-command.model";
import {
createCircuitUpdateProjectCommand,
type CircuitUpdatePatch,
} from "../../domain/models/circuit-project-command.model";
import type {
CircuitProtectionSnapshot,
CircuitProtectionUpdateProjectCommand,
} from "../../domain/models/circuit-protection-project-command.model";
import type { CircuitTreeCircuitDto } from "../types";
import {
buildCircuitEditPatch,
buildDeviceRowEditPatch,
formatPhaseTypeLabel,
formatValue,
getCircuitValue,
type CellKey,
type GridValue,
type RowType,
} from "./circuit-grid-model";
import type { VisibleGridRow } from "./circuit-grid-projection";
import { toCircuitProtectionSnapshot } from "./circuit-protection-editing";
// Spreadsheet copy/paste and multi-row edits of the circuit grid. Every
// write is planned against the visible rows and turned into one atomic
// circuit-list.bulk-update; cells a target row cannot take are skipped.
export type ProtectionClipboardValue = Omit<CircuitProtectionSnapshot, "circuitId">;
export interface CableClipboardValue {
cableType: string | null;
cableCrossSection: string | null;
cableLength: number | null;
}
/**
* One copied cell. Protection and cable summaries carry the complete
* circuit record so they can be pasted onto other circuits; `text` is what
* leaves the application through the system clipboard.
*/
export type CellClipboardValue =
| { kind: "text"; text: string }
| { kind: "protection"; text: string; protection: ProtectionClipboardValue }
| { kind: "cable"; text: string; cable: CableClipboardValue };
export interface CellClipboard {
cells: CellClipboardValue[][];
text: string;
}
export interface CellWrite {
row: VisibleGridRow;
cellKey: CellKey;
value: CellClipboardValue;
}
export interface CellWritePlan {
updates: CircuitListBulkUpdateEntry[];
changedCellCount: number;
skippedCellCount: number;
}
const circuitRowTypes = new Set<RowType>([
"circuitCompact",
"circuitSummary",
"reserveCircuit",
]);
export const gridDataRowTypes = new Set<RowType>([
"circuitCompact",
"circuitSummary",
"deviceRow",
"reserveCircuit",
]);
/** Protection and cable summaries are read-only records of one circuit. */
export function isCircuitRecordCell(
row: Pick<VisibleGridRow, "rowType" | "circuit">,
cellKey: CellKey
): boolean {
return (
(cellKey === "protectionSummary" || cellKey === "cableSummary") &&
Boolean(row.circuit) &&
circuitRowTypes.has(row.rowType)
);
}
export function isCircuitRow(row: Pick<VisibleGridRow, "rowType" | "circuit">) {
return Boolean(row.circuit) && circuitRowTypes.has(row.rowType);
}
/**
* Text for an inline editor or the clipboard that the edit parsers accept:
* numbers with a decimal comma and without grouping.
*/
export function formatCellEditText(value: GridValue): string {
if (value === undefined || value === null) {
return "";
}
if (typeof value === "number") {
return value.toLocaleString("de-DE", {
maximumFractionDigits: 10,
useGrouping: false,
});
}
if (typeof value === "boolean") {
return value ? "ja" : "nein";
}
return String(value);
}
export function copyGridCell(
row: VisibleGridRow,
cellKey: CellKey
): CellClipboardValue {
const circuit = row.circuit;
if (circuit && isCircuitRecordCell(row, cellKey)) {
const text = formatSummaryText(getCircuitValue(circuit, cellKey));
if (cellKey === "protectionSummary") {
if (!circuit.protectionDevice) {
return { kind: "text", text };
}
const snapshot = toCircuitProtectionSnapshot(
circuit.id,
circuit.protectionDevice
);
return {
kind: "protection",
text,
protection: {
type: snapshot.type,
ratedCurrentA: snapshot.ratedCurrentA,
fuseUtilizationCategory: snapshot.fuseUtilizationCategory,
tripCharacteristic: snapshot.tripCharacteristic,
rcdType: snapshot.rcdType,
ratedResidualCurrentMa: snapshot.ratedResidualCurrentMa,
},
};
}
return {
kind: "cable",
text,
cable: {
cableType: circuit.cableType ?? null,
cableCrossSection: circuit.cableCrossSection ?? null,
cableLength: circuit.cableLength ?? null,
},
};
}
const cell = row.cells.find((entry) => entry.cellKey === cellKey);
const value = cell?.editValue ?? cell?.value;
return {
kind: "text",
text:
cellKey === "phaseType" && typeof value === "string"
? formatPhaseTypeLabel(value)
: formatCellEditText(value),
};
}
export function buildCellClipboard(
rows: readonly VisibleGridRow[],
cellKeys: readonly CellKey[]
): CellClipboard {
const cells = rows.map((row) =>
cellKeys.map((cellKey) => copyGridCell(row, cellKey))
);
return {
cells,
text: cells
.map((line) => line.map((cell) => toClipboardText(cell.text)).join("\t"))
.join("\n"),
};
}
/**
* Uses the structured internal clipboard while the system clipboard still
* holds its text; anything else (e.g. cells copied in Excel) is plain text.
*/
export function resolveClipboardCells(
text: string,
internal: CellClipboard | null
): CellClipboardValue[][] {
const normalized = normalizeLineBreaks(text);
if (internal && normalizeLineBreaks(internal.text) === normalized) {
return internal.cells;
}
const lines = normalized.split("\n");
if (lines.length > 1 && lines[lines.length - 1] === "") {
lines.pop();
}
return lines.map((line) =>
line.split("\t").map((cellText) => ({ kind: "text", text: cellText }))
);
}
/**
* Places copied cells on the grid. A single value fills every selected row;
* one copied column fills an equally large selection in order; otherwise
* the block starts at the anchor cell and runs down the visible data rows
* and across the visible columns.
*/
export function resolvePasteWrites(input: {
cells: CellClipboardValue[][];
anchor: { rowKey: string; cellKey: CellKey };
selectedRowKeys: readonly string[];
rows: readonly VisibleGridRow[];
columnKeys: readonly CellKey[];
}): { writes: CellWrite[]; overflowCellCount: number } {
const { cells, anchor } = input;
const dataRows = input.rows.filter((row) => gridDataRowTypes.has(row.rowType));
const selectedRows = dataRows.filter((row) =>
input.selectedRowKeys.includes(row.rowKey)
);
const isSingleColumn = cells.every((line) => line.length === 1);
const useSelection =
selectedRows.length > 1 &&
selectedRows.some((row) => row.rowKey === anchor.rowKey) &&
isSingleColumn &&
(cells.length === 1 || cells.length === selectedRows.length);
if (useSelection) {
return {
writes: selectedRows.map((row, index) => ({
row,
cellKey: anchor.cellKey,
value: cells.length === 1 ? cells[0][0] : cells[index][0],
})),
overflowCellCount: 0,
};
}
const startRow = dataRows.findIndex((row) => row.rowKey === anchor.rowKey);
const startColumn = input.columnKeys.indexOf(anchor.cellKey);
const writes: CellWrite[] = [];
let overflowCellCount = 0;
if (startRow < 0 || startColumn < 0) {
return {
writes,
overflowCellCount: cells.reduce((sum, line) => sum + line.length, 0),
};
}
cells.forEach((line, rowOffset) => {
line.forEach((value, columnOffset) => {
const row = dataRows[startRow + rowOffset];
const cellKey = input.columnKeys[startColumn + columnOffset];
if (!row || !cellKey) {
overflowCellCount += 1;
return;
}
writes.push({ row, cellKey, value });
});
});
return { writes, overflowCellCount };
}
/**
* Turns cell writes into single-entity updates. Several cells of one
* circuit or device row become one update; unchanged values are dropped.
* Throws the regular edit-parser error for invalid input.
*/
export function planCellWrites(
writes: readonly CellWrite[],
options: { allowEquipmentIdentifier?: boolean } = {}
): CellWritePlan {
const allowEquipmentIdentifier = options.allowEquipmentIdentifier ?? true;
const circuitPatches = new Map<
string,
{ circuit: CircuitTreeCircuitDto; patch: CircuitUpdatePatch }
>();
const devicePatches = new Map<
string,
{ current: Record<string, unknown>; patch: CircuitDeviceRowUpdatePatch }
>();
const protections = new Map<
string,
{ expected: CircuitProtectionSnapshot | null; target: CircuitProtectionSnapshot }
>();
let changedCellCount = 0;
let skippedCellCount = 0;
for (const { row, cellKey, value } of writes) {
const circuit = row.circuit;
if (circuit && isCircuitRecordCell(row, cellKey)) {
if (cellKey === "protectionSummary" && value.kind === "protection") {
const expected = circuit.protectionDevice
? toCircuitProtectionSnapshot(circuit.id, circuit.protectionDevice)
: null;
const target = { circuitId: circuit.id, ...value.protection };
if (!sameProtection(expected, target)) {
protections.set(circuit.id, { expected, target });
changedCellCount += 1;
}
} else if (cellKey === "cableSummary" && value.kind === "cable") {
if (mergeCircuitPatch(circuitPatches, circuit, { ...value.cable })) {
changedCellCount += 1;
}
} else {
skippedCellCount += 1;
}
continue;
}
const cell = row.cells.find((entry) => entry.cellKey === cellKey);
const isBlockedIdentifier =
cellKey === "equipmentIdentifier" &&
(!allowEquipmentIdentifier || !value.text.trim());
if (!cell?.editable || isBlockedIdentifier) {
skippedCellCount += 1;
continue;
}
if (cell.kind === "circuitField" && circuit && row.rowType !== "placeholder") {
if (mergeCircuitPatch(circuitPatches, circuit, buildCircuitEditPatch(cellKey, value.text))) {
changedCellCount += 1;
}
continue;
}
if (cell.kind === "deviceField" && row.device) {
const patch = buildDeviceRowEditPatch(
cellKey,
cellKey === "phaseType" ? normalizePhaseTypeText(value.text) : value.text
);
const entry = devicePatches.get(row.device.id) ?? {
current: row.device as unknown as Record<string, unknown>,
patch: {},
};
const changed = keepChangedFields(entry.current, patch);
if (Object.keys(changed).length > 0) {
entry.patch = { ...entry.patch, ...changed };
devicePatches.set(row.device.id, entry);
changedCellCount += 1;
}
continue;
}
skippedCellCount += 1;
}
const updates: CircuitListBulkUpdateEntry[] = [
...[...circuitPatches.entries()].map(([circuitId, { patch }]) =>
createCircuitUpdateProjectCommand(circuitId, patch)
),
...[...devicePatches.entries()].map(([rowId, { patch }]) =>
createCircuitDeviceRowUpdateProjectCommand(rowId, patch)
),
// A literal like api.ts: the protection model's validator imports
// server-side schemas; the server validates the command.
...[...protections.entries()].map(
([circuitId, { expected, target }]): CircuitProtectionUpdateProjectCommand => ({
schemaVersion: 1,
type: "circuit-protection.update",
payload: { circuitId, expected, target },
})
),
];
return { updates, changedCellCount, skippedCellCount };
}
function mergeCircuitPatch(
patches: Map<string, { circuit: CircuitTreeCircuitDto; patch: CircuitUpdatePatch }>,
circuit: CircuitTreeCircuitDto,
patch: CircuitUpdatePatch
): boolean {
const changed = keepChangedFields(
circuit as unknown as Record<string, unknown>,
patch
);
if (Object.keys(changed).length === 0) {
return false;
}
const entry = patches.get(circuit.id) ?? { circuit, patch: {} };
entry.patch = { ...entry.patch, ...changed };
patches.set(circuit.id, entry);
return true;
}
function keepChangedFields<TPatch extends object>(
current: Record<string, unknown>,
patch: TPatch
): Partial<TPatch> {
return Object.fromEntries(
Object.entries(patch).filter(
([field, value]) => (current[field] ?? null) !== (value ?? null)
)
) as Partial<TPatch>;
}
function sameProtection(
left: CircuitProtectionSnapshot | null,
right: CircuitProtectionSnapshot
) {
return (
left !== null &&
(Object.keys(right) as Array<keyof CircuitProtectionSnapshot>).every(
(key) => left[key] === right[key]
)
);
}
function normalizePhaseTypeText(text: string) {
const normalized = text.trim().toLowerCase();
if (normalized === "1-phasig") {
return "single_phase";
}
if (normalized === "3-phasig") {
return "three_phase";
}
return text;
}
function formatSummaryText(value: GridValue) {
const text = formatValue(value);
return text === "-" ? "" : text;
}
function toClipboardText(text: string) {
return text.replace(/[\t\r\n]+/g, " ");
}
function normalizeLineBreaks(text: string) {
return text.replace(/\r\n?/g, "\n");
}

View file

@ -61,6 +61,7 @@ const commandTypeLabels: Record<string, string> = {
"project-room.delete": "Raum entfernt", "project-room.delete": "Raum entfernt",
"project.restore-state": "Projektstand wiederhergestellt", "project.restore-state": "Projektstand wiederhergestellt",
"circuit-protection.update": "Stromkreisschutz bearbeitet", "circuit-protection.update": "Stromkreisschutz bearbeitet",
"circuit-list.bulk-update": "Mehrere Zellen bearbeitet",
"external-csv-configuration.update": "Revit-CSV-Konfiguration bearbeitet", "external-csv-configuration.update": "Revit-CSV-Konfiguration bearbeitet",
"external-model-configuration.update": "Revit-XLSX-Einstellungen bearbeitet", "external-model-configuration.update": "Revit-XLSX-Einstellungen bearbeitet",
"external-import.apply-initial": "Revit-Erstimport übernommen", "external-import.apply-initial": "Revit-Erstimport übernommen",

View file

@ -7,6 +7,7 @@ import { CircuitSectionReorderProjectCommandRepository } from "../../db/reposito
import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js"; import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js";
import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js"; import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
import { CircuitProtectionProjectCommandRepository } from "../../db/repositories/circuit-protection-project-command.repository.js"; import { CircuitProtectionProjectCommandRepository } from "../../db/repositories/circuit-protection-project-command.repository.js";
import { CircuitListBulkUpdateProjectCommandRepository } from "../../db/repositories/circuit-list-bulk-update-project-command.repository.js";
import { CircuitGroupStructureProjectCommandRepository } from "../../db/repositories/circuit-group-structure-project-command.repository.js"; import { CircuitGroupStructureProjectCommandRepository } from "../../db/repositories/circuit-group-structure-project-command.repository.js";
import { CircuitGroupRenumberProjectCommandRepository } from "../../db/repositories/circuit-group-renumber-project-command.repository.js"; import { CircuitGroupRenumberProjectCommandRepository } from "../../db/repositories/circuit-group-renumber-project-command.repository.js";
import { CircuitGroupMoveProjectCommandRepository } from "../../db/repositories/circuit-group-move-project-command.repository.js"; import { CircuitGroupMoveProjectCommandRepository } from "../../db/repositories/circuit-group-move-project-command.repository.js";
@ -43,6 +44,8 @@ export const circuitStructureProjectCommandStore =
new CircuitStructureProjectCommandRepository(db); new CircuitStructureProjectCommandRepository(db);
export const circuitProtectionProjectCommandStore = export const circuitProtectionProjectCommandStore =
new CircuitProtectionProjectCommandRepository(db); new CircuitProtectionProjectCommandRepository(db);
export const circuitListBulkUpdateProjectCommandStore =
new CircuitListBulkUpdateProjectCommandRepository(db);
export const circuitGroupStructureProjectCommandStore = export const circuitGroupStructureProjectCommandStore =
new CircuitGroupStructureProjectCommandRepository(db); new CircuitGroupStructureProjectCommandRepository(db);
export const circuitGroupRenumberProjectCommandStore = export const circuitGroupRenumberProjectCommandStore =
@ -123,5 +126,6 @@ export const projectCommandService = new ProjectCommandService(
externalInitialImportProjectCommandStore, externalInitialImportProjectCommandStore,
externalObjectPlanningProjectCommandStore, externalObjectPlanningProjectCommandStore,
externalFollowUpImportProjectCommandStore, externalFollowUpImportProjectCommandStore,
externalObjectDeleteMissingProjectCommandStore externalObjectDeleteMissingProjectCommandStore,
circuitListBulkUpdateProjectCommandStore
); );

View file

@ -0,0 +1,274 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildCellClipboard,
formatCellEditText,
planCellWrites,
resolveClipboardCells,
resolvePasteWrites,
} from "../src/frontend/utils/circuit-grid-clipboard.js";
import { buildVisibleGridRowsWithStructure } from "../src/frontend/utils/circuit-grid-projection.js";
import type {
CircuitTreeCircuitDto,
CircuitTreeDeviceRowDto,
CircuitTreeSectionDto,
} from "../src/frontend/types.js";
function makeDevice(id: string, powerPerUnit: number): CircuitTreeDeviceRowDto {
return {
id,
sortOrder: 10,
name: `Gerät ${id}`,
displayName: `Gerät ${id}`,
phaseType: "single_phase",
quantity: 1,
manualQuantity: 1,
powerPerUnit,
simultaneityFactor: 1,
rowTotalPower: powerPerUnit,
};
}
function makeCircuit(
id: string,
deviceRows: CircuitTreeDeviceRowDto[],
extra: Partial<CircuitTreeCircuitDto> = {}
): CircuitTreeCircuitDto {
return {
id,
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: `-2F${id.slice(-1)}`,
displayName: id,
sortOrder: 10,
isReserve: deviceRows.length === 0,
circuitTotalPower: 0,
circuitApparentPower: 0,
circuitCosPhi: 1,
deviceRows,
downstreamBoardLoads: [],
...extra,
};
}
const source = makeCircuit("circuit-1", [makeDevice("row-1", 0.25)], {
cableType: "NYM-J",
cableCrossSection: "3x2,5",
cableLength: 18,
protectionDevice: { type: "LS", ratedCurrentA: 16, tripCharacteristic: "B" },
});
const target = makeCircuit("circuit-2", [makeDevice("row-2", 0.1)], {
protectionDevice: { type: "LS", ratedCurrentA: 10, tripCharacteristic: "B" },
});
const multi = makeCircuit("circuit-3", [
makeDevice("row-3a", 0.1),
makeDevice("row-3b", 0.2),
]);
const reserve = makeCircuit("circuit-4", []);
const sections: CircuitTreeSectionDto[] = [
{
id: "section-1",
key: "single_phase",
displayName: "Einphasig",
prefix: "-2F",
sortOrder: 10,
sectionTotalPower: 0,
components: [],
circuits: [source, target, multi, reserve],
},
];
const rows = buildVisibleGridRowsWithStructure(sections, {
headerComponents: [],
footerComponents: [],
});
const rowByKey = (rowKey: string) => {
const row = rows.find((entry) => entry.rowKey === rowKey);
assert.ok(row, rowKey);
return row;
};
describe("circuit grid clipboard", () => {
it("formats numbers with a decimal comma for editing and copying", () => {
assert.equal(formatCellEditText(0.25), "0,25");
assert.equal(formatCellEditText(1500), "1500");
assert.equal(formatCellEditText(true), "ja");
assert.equal(formatCellEditText(undefined), "");
});
it("copies protection and cable summaries as complete circuit records", () => {
const clipboard = buildCellClipboard(
[rowByKey("circuitCompact:circuit-1")],
["protectionSummary"]
);
assert.equal(clipboard.cells[0][0].kind, "protection");
const plan = planCellWrites([
{
row: rowByKey("circuitCompact:circuit-2"),
cellKey: "protectionSummary",
value: clipboard.cells[0][0],
},
{
row: rowByKey("circuitCompact:circuit-2"),
cellKey: "cableSummary",
value: buildCellClipboard(
[rowByKey("circuitCompact:circuit-1")],
["cableSummary"]
).cells[0][0],
},
]);
assert.equal(plan.changedCellCount, 2);
assert.deepEqual(
plan.updates.map((update) => [update.type, update.payload]),
[
[
"circuit.update",
{
circuitId: "circuit-2",
changes: [
{ field: "cableType", value: "NYM-J" },
{ field: "cableCrossSection", value: "3x2,5" },
{ field: "cableLength", value: 18 },
],
},
],
[
"circuit-protection.update",
{
circuitId: "circuit-2",
expected: {
circuitId: "circuit-2",
type: "LS",
ratedCurrentA: 10,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: null,
ratedResidualCurrentMa: null,
},
target: {
circuitId: "circuit-2",
type: "LS",
ratedCurrentA: 16,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: null,
ratedResidualCurrentMa: null,
},
},
],
]
);
});
it("fills one value into every selected row and skips rows that cannot take it", () => {
const selectedRowKeys = [
"circuitCompact:circuit-1",
"circuitCompact:circuit-2",
"circuitSummary:circuit-3",
"device:row-3a",
];
const { writes } = resolvePasteWrites({
cells: resolveClipboardCells("0,5", null),
anchor: { rowKey: "circuitCompact:circuit-1", cellKey: "powerPerUnit" },
selectedRowKeys,
rows,
columnKeys: ["equipmentIdentifier", "displayName", "powerPerUnit"],
});
assert.equal(writes.length, 4);
const plan = planCellWrites(writes);
// The summary row of a multi-device circuit has no device power.
assert.equal(plan.skippedCellCount, 1);
assert.deepEqual(
plan.updates.map((update) => [update.type, update.payload]),
["row-1", "row-2", "row-3a"].map((rowId) => [
"circuit-device-row.update",
{ rowId, changes: [{ field: "powerPerUnit", value: 0.5 }] },
])
);
});
it("pastes an Excel block down the visible rows and across visible columns", () => {
const cells = resolveClipboardCells("0,3\t0,8\r\n0,4\t0,9\r\n", null);
const { writes, overflowCellCount } = resolvePasteWrites({
cells,
anchor: { rowKey: "circuitCompact:circuit-1", cellKey: "powerPerUnit" },
selectedRowKeys: [],
rows,
columnKeys: ["displayName", "powerPerUnit", "simultaneityFactor"],
});
assert.equal(overflowCellCount, 0);
const plan = planCellWrites(writes);
assert.deepEqual(
plan.updates.map((update) => update.payload),
[
{
rowId: "row-1",
changes: [
{ field: "powerPerUnit", value: 0.3 },
{ field: "simultaneityFactor", value: 0.8 },
],
},
{
rowId: "row-2",
changes: [
{ field: "powerPerUnit", value: 0.4 },
{ field: "simultaneityFactor", value: 0.9 },
],
},
]
);
});
it("reuses structured cells only while the clipboard text is unchanged", () => {
const clipboard = buildCellClipboard(
[rowByKey("circuitCompact:circuit-1")],
["protectionSummary"]
);
assert.equal(
resolveClipboardCells(clipboard.text, clipboard)[0][0].kind,
"protection"
);
assert.equal(
resolveClipboardCells("LS 16 A", clipboard)[0][0].kind,
"text"
);
});
it("drops unchanged values, blocks shared identifiers and rejects invalid numbers", () => {
const unchanged = planCellWrites([
{
row: rowByKey("circuitCompact:circuit-1"),
cellKey: "powerPerUnit",
value: { kind: "text", text: "0,25" },
},
]);
assert.deepEqual(unchanged, {
updates: [],
changedCellCount: 0,
skippedCellCount: 0,
});
const identifiers = planCellWrites(
[
{
row: rowByKey("circuitCompact:circuit-2"),
cellKey: "equipmentIdentifier",
value: { kind: "text", text: "-2F9" },
},
],
{ allowEquipmentIdentifier: false }
);
assert.equal(identifiers.skippedCellCount, 1);
assert.equal(identifiers.updates.length, 0);
assert.throws(
() =>
planCellWrites([
{
row: rowByKey("circuitCompact:circuit-1"),
cellKey: "powerPerUnit",
value: { kind: "text", text: "1.5" },
},
]),
/Komma/
);
});
});

View file

@ -0,0 +1,348 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { CircuitListBulkUpdateProjectCommandRepository } from "../src/db/repositories/circuit-list-bulk-update-project-command.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js";
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { projects } from "../src/db/schema/projects.js";
import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js";
import {
assertCircuitListBulkUpdateProjectCommand,
createCircuitListBulkUpdateProjectCommand,
} from "../src/domain/models/circuit-list-bulk-update-project-command.model.js";
import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js";
import {
createCircuitProtectionUpdateProjectCommand,
type CircuitProtectionSnapshot,
} from "../src/domain/models/circuit-protection-project-command.model.js";
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db
.insert(projects)
.values([
{ id: "project-1", name: "Projekt" },
{ id: "project-2", name: "Fremdprojekt" },
])
.run();
const fixtures = new DistributionBoardFixtureRepository(context.db);
fixtures.createWithCircuitListAndDefaultSections("project-1", "UV-01");
fixtures.createWithCircuitListAndDefaultSections("project-2", "UV-02");
const [list, foreignList] = ["project-1", "project-2"].map((projectId) => {
const circuitList = context.db
.select()
.from(circuitLists)
.where(eq(circuitLists.projectId, projectId))
.get();
assert.ok(circuitList);
return circuitList;
});
const sectionOf = (circuitListId: string) => {
const section = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, circuitListId))
.get();
assert.ok(section);
return section.id;
};
context.db
.insert(circuits)
.values([
{
id: "circuit-1",
circuitListId: list.id,
sectionId: sectionOf(list.id),
equipmentIdentifier: "-1F1",
displayName: "Leuchte",
sortOrder: 10,
},
{
id: "circuit-2",
circuitListId: list.id,
sectionId: sectionOf(list.id),
equipmentIdentifier: "-1F2",
displayName: "Leuchte",
sortOrder: 20,
},
{
id: "foreign-circuit",
circuitListId: foreignList.id,
sectionId: sectionOf(foreignList.id),
equipmentIdentifier: "-1F1",
sortOrder: 10,
},
])
.run();
context.db
.insert(circuitDeviceRows)
.values(
["circuit-1", "circuit-2"].map((circuitId, index) => ({
id: `row-${index + 1}`,
circuitId,
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
quantity: 2,
manualQuantity: 2,
powerPerUnit: 0.1,
simultaneityFactor: 1,
}))
)
.run();
context.db
.insert(circuitProtectionDevices)
.values(protectionFor("circuit-1", 10))
.run();
return context;
}
function protectionFor(
circuitId: string,
ratedCurrentA: number
): CircuitProtectionSnapshot {
return {
circuitId,
type: "LS",
ratedCurrentA,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: null,
ratedResidualCurrentMa: null,
};
}
function readState(context: DatabaseContext) {
return {
circuits: context.db
.select({
id: circuits.id,
displayName: circuits.displayName,
cableType: circuits.cableType,
cableCrossSection: circuits.cableCrossSection,
})
.from(circuits)
.all(),
rows: context.db
.select({
id: circuitDeviceRows.id,
displayName: circuitDeviceRows.displayName,
powerPerUnit: circuitDeviceRows.powerPerUnit,
simultaneityFactor: circuitDeviceRows.simultaneityFactor,
})
.from(circuitDeviceRows)
.all(),
protections: context.db.select().from(circuitProtectionDevices).all(),
};
}
describe("circuit-list bulk update project command", () => {
it("applies circuit, device-row and protection edits as one undoable revision", () => {
const context = createTestDatabase();
try {
const repository = new CircuitListBulkUpdateProjectCommandRepository(
context.db
);
const history = new ProjectHistoryRepository(context.db);
const before = readState(context);
const command = createCircuitListBulkUpdateProjectCommand([
createCircuitUpdateProjectCommand("circuit-1", {
cableType: "NYM-J",
cableCrossSection: "3x1,5",
}),
createCircuitUpdateProjectCommand("circuit-2", {
cableType: "NYM-J",
cableCrossSection: "3x1,5",
}),
createCircuitDeviceRowUpdateProjectCommand("row-1", {
powerPerUnit: 0.25,
simultaneityFactor: 0.8,
}),
createCircuitDeviceRowUpdateProjectCommand("row-2", {
displayName: "Steckdose",
}),
createCircuitProtectionUpdateProjectCommand(
"circuit-1",
protectionFor("circuit-1", 10),
protectionFor("circuit-1", 16)
),
createCircuitProtectionUpdateProjectCommand(
"circuit-2",
null,
protectionFor("circuit-2", 16)
),
]);
const executed = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.equal(history.getState("project-1")?.currentRevision, 1);
assert.equal(history.getState("project-1")?.undoDepth, 1);
const after = readState(context);
assert.deepEqual(
after.circuits
.filter((circuit) => circuit.id !== "foreign-circuit")
.map((circuit) => [circuit.cableType, circuit.cableCrossSection]),
[
["NYM-J", "3x1,5"],
["NYM-J", "3x1,5"],
]
);
// The single-device circuit follows its renamed row.
assert.equal(
after.circuits.find((circuit) => circuit.id === "circuit-2")
?.displayName,
"Steckdose"
);
assert.deepEqual(
after.rows.find((row) => row.id === "row-1"),
{
id: "row-1",
displayName: "Leuchte",
powerPerUnit: 0.25,
simultaneityFactor: 0.8,
}
);
assert.deepEqual(
after.protections.map((protection) => protection.ratedCurrentA),
[16, 16]
);
// The inverse undoes the updates in reverse order.
assert.deepEqual(
executed.inverse.payload.updates.map((update) => update.type),
[
"circuit-protection.update",
"circuit-protection.update",
"circuit-device-row.update",
"circuit-device-row.update",
"circuit.update",
"circuit.update",
]
);
const undo = history.getNextCommand("project-1", "undo");
assert.ok(undo);
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: undo.changeSetId,
command: executed.inverse,
});
assert.deepEqual(readState(context), before);
const redo = history.getNextCommand("project-1", "redo");
assert.ok(redo);
repository.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId: redo.changeSetId,
command: (() => {
assertCircuitListBulkUpdateProjectCommand(redo.command);
return redo.command;
})(),
});
assert.deepEqual(readState(context), after);
} finally {
context.close();
}
});
it("rolls back every update when one update is rejected", () => {
const context = createTestDatabase();
try {
const repository = new CircuitListBulkUpdateProjectCommandRepository(
context.db
);
const history = new ProjectHistoryRepository(context.db);
const before = readState(context);
const valid = createCircuitUpdateProjectCommand("circuit-1", {
cableType: "NYM-J",
});
for (const rejected of [
createCircuitUpdateProjectCommand("foreign-circuit", {
cableType: "NYM-J",
}),
createCircuitProtectionUpdateProjectCommand(
"circuit-1",
protectionFor("circuit-1", 13),
protectionFor("circuit-1", 16)
),
]) {
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitListBulkUpdateProjectCommand([
valid,
rejected,
]),
}),
/does not belong to project|changed before update/
);
assert.deepEqual(readState(context), before);
assert.equal(history.getState("project-1")?.currentRevision, 0);
}
} finally {
context.close();
}
});
it("rejects empty, duplicate and unsupported nested updates", () => {
const update = createCircuitUpdateProjectCommand("circuit-1", {
cableType: "NYM-J",
});
assert.throws(
() => createCircuitListBulkUpdateProjectCommand([]),
/requires 1 to/
);
assert.throws(
() => createCircuitListBulkUpdateProjectCommand([update, update]),
/one target twice/
);
assert.throws(
() =>
assertCircuitListBulkUpdateProjectCommand({
schemaVersion: 1,
type: "circuit-list.bulk-update",
payload: {
updates: [
{ schemaVersion: 1, type: "circuit.delete", payload: {} },
],
},
}),
/unsupported update/
);
assert.throws(
() =>
assertCircuitListBulkUpdateProjectCommand({
schemaVersion: 1,
type: "circuit-list.bulk-update",
payload: { updates: [update], extra: true },
}),
/Unsupported circuit-list bulk update/
);
});
});