From 7580ad0adee67911e6dc4d9195bbedd8ac6b12d6 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Thu, 7 May 2026 22:55:15 +0200 Subject: [PATCH] Documentation --- README.md | 10 ++ docs/circuit-list-editor-api.md | 151 ++++++++++++++++++ docs/circuit-list-editor-architecture.md | 96 +++++++++++ docs/circuit-list-editor-interactions.md | 109 +++++++++++++ docs/circuit-list-editor-known-limitations.md | 12 ++ docs/circuit-list-editor-migration.md | 75 +++++++++ src/db/repositories/circuit.repository.ts | 6 + src/domain/services/circuit-write.service.ts | 16 ++ .../legacy-consumer-migration-planner.ts | 5 +- .../legacy-consumer-migration.service.ts | 10 ++ .../components/circuit-tree-editor.tsx | 95 +++++++++++ 11 files changed, 584 insertions(+), 1 deletion(-) create mode 100644 docs/circuit-list-editor-api.md create mode 100644 docs/circuit-list-editor-architecture.md create mode 100644 docs/circuit-list-editor-interactions.md create mode 100644 docs/circuit-list-editor-known-limitations.md create mode 100644 docs/circuit-list-editor-migration.md diff --git a/README.md b/README.md index 01df2b0..4dd0d37 100644 --- a/README.md +++ b/README.md @@ -185,3 +185,13 @@ Siehe auch: `docs/local-db-circuit-first-migration.md` - Anforderungen (Quelle): [docs/electrical-load-balance-requirements-context-dump.md](docs/electrical-load-balance-requirements-context-dump.md) - Abgleich „Anforderung vs. Implementierung“: [docs/anforderungs-abgleich.md](docs/anforderungs-abgleich.md) + +## Circuit-List Editor Dokumentation + +- Architektur: [docs/circuit-list-editor-architecture.md](docs/circuit-list-editor-architecture.md) +- Interaktionen: [docs/circuit-list-editor-interactions.md](docs/circuit-list-editor-interactions.md) +- API: [docs/circuit-list-editor-api.md](docs/circuit-list-editor-api.md) +- Migration: [docs/circuit-list-editor-migration.md](docs/circuit-list-editor-migration.md) +- Bekannte Limitierungen: [docs/circuit-list-editor-known-limitations.md](docs/circuit-list-editor-known-limitations.md) + +Diese Dokumente beschreiben den aktuellen circuit-first Editorstand und dienen als sichere Entwicklungsbasis fuer Folgeschritte. diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md new file mode 100644 index 0000000..4e729ac --- /dev/null +++ b/docs/circuit-list-editor-api.md @@ -0,0 +1,151 @@ +# Circuit List Editor API + +## Scope + +The circuit-first editor uses tree + circuit + row endpoints. Legacy consumer endpoints still exist for compatibility and migration, but should not be extended for new circuit-list behavior. + +## Circuit-First Endpoints + +### Tree Endpoint + +- `GET /projects/:projectId/circuit-lists/:circuitListId/tree` +- Purpose: returns section/circuit/device-row tree with calculated row and circuit totals. + +Response sketch: + +```json +{ + "circuitListId": "cl_1", + "sections": [ + { + "id": "sec_1", + "key": "lighting", + "prefix": "-1F", + "circuits": [ + { + "id": "cir_1", + "equipmentIdentifier": "-1F1", + "circuitTotalPower": 4.2, + "deviceRows": [ + { + "id": "row_1", + "displayName": "Office lights", + "rowTotalPower": 1.8 + } + ] + } + ] + } + ] +} +``` + +### Circuit CRUD + +- `POST /projects/:projectId/circuit-lists/:circuitListId/circuits` + - create circuit in list/section +- `PATCH /circuits/:circuitId` + - update circuit-level fields (BMK, protection, cable, reserve flag, etc.) +- `DELETE /circuits/:circuitId` + - delete circuit (and related rows via DB relations) +- `GET /circuit-sections/:sectionId/next-identifier` + - preview next identifier for section (`prefix + maxSuffix + 1`) + +Request sketch (`POST .../circuits`): + +```json +{ + "sectionId": "sec_1", + "equipmentIdentifier": "-2F14", + "displayName": "Sockets East", + "sortOrder": 140 +} +``` + +### Device Row CRUD + +- `POST /circuits/:circuitId/device-rows` + - create row inside a circuit +- `PATCH /circuit-device-rows/:rowId` + - update row values +- `DELETE /circuit-device-rows/:rowId` + - delete row + +### Move Device Row + +- `PATCH /circuit-device-rows/:rowId/move` +- Purpose: move one row to existing circuit or to newly created circuit in target section. + +Request sketch: + +```json +{ + "targetCircuitId": "cir_target" +} +``` + +or + +```json +{ + "targetSectionId": "sec_target", + "createNewCircuit": true +} +``` + +### Bulk Move Device Rows + +- `PATCH /circuit-device-rows/move-bulk` +- Purpose: move multiple rows in one command flow. + +Request sketch: + +```json +{ + "rowIds": ["row_1", "row_2"], + "targetCircuitId": "cir_target" +} +``` + +### Reorder Circuits + +- `PATCH /circuit-sections/:sectionId/circuits/reorder` +- Purpose: persist explicit circuit order within one section. + +Request sketch: + +```json +{ + "orderedCircuitIds": ["cir_2", "cir_1", "cir_3"] +} +``` + +### Renumber Section + +- `POST /circuit-sections/:sectionId/renumber` +- Purpose: explicit renumbering by section prefix; never implicit on move/sort. + +### Safe Equipment Identifier Update + +- `PATCH /circuit-sections/:sectionId/equipment-identifiers` +- Purpose: apply explicit per-circuit identifiers safely even with unique constraints. + +Request sketch: + +```json +{ + "identifiers": [ + { "circuitId": "cir_1", "equipmentIdentifier": "-2F1" }, + { "circuitId": "cir_2", "equipmentIdentifier": "-2F2" } + ] +} +``` + +## Legacy Endpoints (Temporary) + +- `GET /consumers/projects/:projectId` +- `POST /consumers` +- `PUT /consumers/:consumerId` +- `DELETE /consumers/:consumerId` + +These remain for migration/legacy views. New circuit-list interactions must use circuit-first endpoints above. diff --git a/docs/circuit-list-editor-architecture.md b/docs/circuit-list-editor-architecture.md new file mode 100644 index 0000000..7a090fc --- /dev/null +++ b/docs/circuit-list-editor-architecture.md @@ -0,0 +1,96 @@ +# Circuit List Editor Architecture + +## Purpose + +The circuit-list editor is a circuit-first planning workspace for distribution-board design. It is optimized for spreadsheet-like editing while preserving electrical domain boundaries: circuit-level data stays on circuits, and load rows stay inside circuits. + +## Domain Model Overview + +- `CircuitSection` + - Groups circuits by planning section (for example lighting, single-phase, three-phase). + - Owns section metadata (`key`, `displayName`, `prefix`, ordering). +- `Circuit` + - Core electrical unit in the list. + - Owns circuit-level identifiers and technical data: + - `equipmentIdentifier` (BMK) + - protection data + - cable data + - reserve state + - circuit-level remark/status +- `CircuitDeviceRow` + - Load/device line inside a circuit. + - Owns row-level load and context values: + - `quantity` + - `powerPerUnit` + - `simultaneityFactor` + - `cosPhi` + - room snapshots + - category/cost group and related row attributes +- `ProjectDevice` + - Reusable device template entity at project level. + - Can be linked to `CircuitDeviceRow` entries, with copied display values on insert. +- Legacy `Consumer` + - Old row-first model still present for compatibility/migration. + - Should not be extended for new circuit-list behavior. + +## Why A Circuit Is Not One Row + +A circuit can contain zero, one, or many device rows. Treating a circuit as a single row breaks: + +- BMK ownership (belongs to circuit, not each device row) +- circuit-level protection/cable fields +- grouped calculations and circuit move/reorder semantics + +The tree model keeps circuit identity stable while allowing row-level load composition. + +## Rendering Model: Single vs Multi Device + +- Single-device circuit: + - Rendered as compact combined row (`circuitCompact`) for fast editing. +- Multi-device circuit: + - Rendered as one circuit summary row (`circuitSummary`) plus indented `deviceRow` entries. + +This keeps visual density high without losing ownership boundaries. + +## Reserve / Empty Circuits + +Circuits with no device rows are rendered as reserve rows (`reserveCircuit`). +Section-level `-frei-` placeholder rows represent insertion targets for creating a new circuit in that section. + +## BMK Ownership (`equipmentIdentifier`) + +`equipmentIdentifier` / BMK is circuit-owned (`Circuit.equipmentIdentifier`). + +- Device rows do not have their own BMK. +- Existing identifiers must stay stable unless explicitly changed by user action. +- Renumbering is explicit, not implicit on move/sort/delete. + +## Data Ownership Split: Circuit vs Device Row + +Circuit-level fields on `Circuit`: + +- protection type/rating/characteristic +- cable type/cross-section/length +- RCD/terminal/status + +Device-level load fields on `CircuitDeviceRow`: + +- quantity, power per unit, simultaneity, cosPhi +- row naming/categorization and room snapshots + +This split matches execution-design workflows where many loads share one protective path. + +## Calculated Totals + +- `rowTotalPower` is calculated per `CircuitDeviceRow`. +- `circuitTotalPower` is calculated as sum of all row totals in one circuit. + +Totals are exposed by the tree response and shown in computed read-only cells. + +## Frontend Route + +Primary circuit-first editor route: + +- `/projects/:projectId/circuit-lists/:circuitListId/tree-edit` + +Legacy route remains separate (read-only/preview/migration transition path) and is intentionally not merged into the editable tree editor. diff --git a/docs/circuit-list-editor-interactions.md b/docs/circuit-list-editor-interactions.md new file mode 100644 index 0000000..e4b2cef --- /dev/null +++ b/docs/circuit-list-editor-interactions.md @@ -0,0 +1,109 @@ +# Circuit List Editor Interactions + +## Editing Model + +Inline cells are static text by default. A cell enters edit mode by: + +- double-click +- `Enter` +- `F2` +- typing a printable character (type-to-edit) + +`Enter` confirms changes. `Escape` cancels current edit draft. `Tab` and `Shift+Tab` confirm and move to next/previous editable cell. + +## `selectedCell` vs `editingCell` + +- `selectedCell` tracks spreadsheet navigation focus. +- `editingCell` tracks active input draft (`draft`, edit mode, focus token). + +The editor can have a selected cell without an active editor input. `editingCell` is only set while editing. + +## Normalized Visible Grid + +The UI works from a normalized `visibleRows` model built from filtered/sorted sections: + +- section header rows +- circuit rows (`circuitCompact`, `circuitSummary`, `reserveCircuit`) +- device rows (`deviceRow`) +- section placeholder rows (`placeholder`) + +Selection, keyboard movement, and editability checks run against this normalized grid, not directly against nested API JSON. + +## Keyboard Behavior + +- `Enter`: start edit (when not editing) or commit (when editing input) +- `F2`: start edit on selected cell +- `Escape`: cancel edit draft, or clear multi-row selection when not editing +- `Tab` / `Shift+Tab`: move between editable cells (commits active edit) +- arrow keys: move selected cell in grid when not editing +- type-to-edit: printable keys open edit mode and replace current display value with typed character + +## `-frei-` Placeholder Behavior + +Each section has a trailing placeholder row showing `-frei-` in BMK column: + +- serves as drop target for creating new circuits from project devices or moved rows +- editable placeholder cells can create a new circuit + first row through the same edit command flow + +## Add Circuit Behavior + +Add-circuit actions create reserve circuits in the active section using the section prefix and next numeric suffix (`max + 1`). +No automatic gap-filling or global renumbering is performed. + +## Drag-and-Drop Behavior + +Intent is separated by drag source type: + +- project device drag: + - drop to section/placeholder -> create new circuit with linked row + - drop to existing circuit row -> append row to that circuit +- device row drag: + - drop to existing circuit -> move row(s) into that circuit + - drop to placeholder -> create new target circuit and move row(s) +- circuit drag (BMK handle): + - reorder circuits inside same section only + - cross-section reorder is rejected +- bulk device row move: + - supported via multi-selection + drag +- multi-circuit move: + - supported for same-section selected circuit blocks + +## Filtering and Sorting + +- Per-column filtering works on normalized displayed values. +- Sorting is view-level first and treats circuits as blocks. +- Multi-device circuits are not split during sort. +- Sorting alone does not persist order. + +## Apply Sorted Order + +`Apply sorted order` persists current sorted block order to backend by section. + +- disabled while filters are active +- undoable via command history +- until applied, sort remains view-only + +## Column Configuration + +- Column visibility and order are configurable. +- BMK column (`equipmentIdentifier`) is locked as first column. +- Layout is saved in local storage (`circuitTreeEditor.columnLayout.v1`). + +## Undo/Redo + +Undo/redo wraps editor operations as command objects with async `redo`/`undo` and reloads tree after each command. + +Covered operations include: + +- insert/delete circuit +- insert/delete row +- edit cell values +- moves (single/bulk rows, circuit reorder) +- renumber and identifier update flows +- apply sorted order + +Current limitations: + +- session-local only +- no persisted history across browser reload +- some multi-step backend flows are not fully transaction-hardened end-to-end diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md new file mode 100644 index 0000000..0bb0478 --- /dev/null +++ b/docs/circuit-list-editor-known-limitations.md @@ -0,0 +1,12 @@ +# Circuit List Editor Known Limitations + +- Undo/redo history is session-local only. +- Undo/redo history is not persisted across reloads or between users. +- No linked project-device sync review dialog is implemented yet. +- No global cross-project device library workflow is implemented yet. +- Final electrical sizing logic is not implemented yet. +- No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet. +- Bulk device-row move flow is command-based but not fully transaction-hardened end-to-end across all affected circuits. +- Sorting is view-only until users explicitly apply sorted order. +- Cross-section circuit drag-reorder is intentionally blocked. +- Legacy consumer and circuit-first paths coexist; migration is transitional and still requires operational discipline. diff --git a/docs/circuit-list-editor-migration.md b/docs/circuit-list-editor-migration.md new file mode 100644 index 0000000..868e3b2 --- /dev/null +++ b/docs/circuit-list-editor-migration.md @@ -0,0 +1,75 @@ +# Circuit List Editor Migration + +## Goal + +Migrate legacy row-first consumers into the circuit-first model without deleting legacy data. + +## Legacy Mapping + +Legacy `Consumer` rows map into: + +- `Circuit` for shared circuit identity and circuit-level technical fields +- `CircuitDeviceRow` for per-device load rows + +Multiple legacy consumers can map into one circuit when they share normalized circuit identity. + +## Grouping Strategy (`circuitNumber`) + +Migration groups legacy rows by normalized `circuitNumber`: + +- valid/normalizable values become one target circuit per normalized value +- duplicates are grouped under that circuit (multiple `CircuitDeviceRow`s) +- missing/invalid values trigger generated identifiers and may fall back to `unassigned` section + +## Default Section Backfill + +Before migration, default sections are created/backfilled per circuit list. +This guarantees a valid target section space, including `unassigned` when no section can be inferred. + +## Migration Commands + +Run in this order for local database workflows: + +1. Backup: + - `npm run db:backup` +2. Migrate schema: + - `npm run db:migrate` +3. Verify circuit schema: + - `npm run db:verify:circuit-schema` +4. Backfill missing sections: + - `npm run db:backfill:sections` +5. Migrate legacy consumers: + - `npm run db:migrate:legacy-consumers` + +## Validation Checks + +After migration, verify: + +- tree endpoint returns sections/circuits/rows +- grouped duplicate circuit numbers are reported +- generated identifiers are reported where expected +- migrated rows include `legacyConsumerId` traceability +- no duplicate BMKs exist inside one circuit list + +## If Tree Endpoint Returns Empty Sections + +Likely causes: + +- circuit-first tables missing (migration not run) +- sections not backfilled yet +- migrated dataset genuinely empty for selected list + +Actions: + +1. Run schema migration and verification commands. +2. Run section backfill command. +3. Run legacy-consumer migration command. +4. Retry tree endpoint. + +## Legacy Data Retention + +Do not delete legacy consumers yet. + +- legacy endpoints/views may still depend on them +- migration trace tables reference old/new mapping +- keeping legacy rows allows comparison and rollback validation during transition diff --git a/src/db/repositories/circuit.repository.ts b/src/db/repositories/circuit.repository.ts index 06ae1a0..d8b3262 100644 --- a/src/db/repositories/circuit.repository.ts +++ b/src/db/repositories/circuit.repository.ts @@ -141,6 +141,8 @@ export class CircuitRepository { if (updates.length === 0) { return; } + // better-sqlite3 transactions are synchronous callbacks. Do not make this callback + // async or return a Promise, otherwise statements may run outside the transaction scope. db.transaction((tx) => { const ids = updates.map((entry) => entry.id); const existing = tx @@ -152,6 +154,10 @@ export class CircuitRepository { throw new Error("One or more circuit ids are invalid for circuit list."); } + // Direct identifier swaps can violate UNIQUE(circuit_list_id, equipment_identifier) + // mid-update (for example A->B while B->A). Two-phase strategy prevents that: + // 1) assign unique temporary identifiers for all affected circuits + // 2) assign final user-visible identifiers const stamp = Date.now(); for (let index = 0; index < updates.length; index += 1) { const entry = updates[index]; diff --git a/src/domain/services/circuit-write.service.ts b/src/domain/services/circuit-write.service.ts index 563fb4f..eb718eb 100644 --- a/src/domain/services/circuit-write.service.ts +++ b/src/domain/services/circuit-write.service.ts @@ -39,6 +39,7 @@ export class CircuitWriteService { this.numberingService = deps?.numberingService ?? new CircuitNumberingService(); } + // Ensures writes never connect a section to the wrong circuit list. private async assertSectionInList(sectionId: string, circuitListId: string) { const section = await this.circuitSectionRepository.findById(sectionId); if (!section) { @@ -50,6 +51,7 @@ export class CircuitWriteService { return section; } + // Enforces BMK uniqueness inside one circuit list. private async assertUniqueEquipmentIdentifier( circuitListId: string, equipmentIdentifier: string, @@ -65,6 +67,7 @@ export class CircuitWriteService { } } + // Validates linked project-device id against owning project of the circuit list. private async assertValidLinkedProjectDevice(circuitId: string, linkedProjectDeviceId?: string) { if (!linkedProjectDeviceId) { return; @@ -184,6 +187,7 @@ export class CircuitWriteService { overriddenFields: input.overriddenFields, }); + // Reserve circuits become active as soon as at least one device row exists. if (Boolean(circuit.isReserve)) { await this.circuitRepository.update(circuit.id, { sectionId: circuit.sectionId, @@ -247,6 +251,7 @@ export class CircuitWriteService { } await this.deviceRowRepository.delete(rowId); const remaining = await this.deviceRowRepository.countByCircuit(current.circuitId); + // When last row is removed, keep circuit and mark it reserve instead of deleting it. if (remaining === 0) { await this.circuitRepository.update(circuit.id, { sectionId: circuit.sectionId, @@ -287,6 +292,7 @@ export class CircuitWriteService { throw new Error("Invalid target circuit id."); } + // Placeholder-target move creates a new circuit explicitly; no implicit renumbering of others. if (!targetCircuit) { if (!input.targetSectionId || !input.createNewCircuit) { throw new Error("Invalid move target."); @@ -321,6 +327,7 @@ export class CircuitWriteService { await this.deviceRowRepository.moveToCircuit(rowId, targetCircuit.id, (targetCount + 1) * 10); const sourceRemaining = await this.deviceRowRepository.countByCircuit(sourceCircuit.id); + // Source circuit becomes reserve when all rows are moved away. if (sourceRemaining === 0) { await this.circuitRepository.update(sourceCircuit.id, { sectionId: sourceCircuit.sectionId, @@ -342,6 +349,7 @@ export class CircuitWriteService { }); } + // Target circuit is no longer reserve once it receives moved rows. if (Boolean(targetCircuit.isReserve)) { await this.circuitRepository.update(targetCircuit.id, { sectionId: targetCircuit.sectionId, @@ -367,6 +375,8 @@ export class CircuitWriteService { } async moveDeviceRowsBulk(input: MoveCircuitDeviceRowsBulkInput) { + // Bulk move keeps input order and resolves all source circuits first so undo can + // restore per-source assignment deterministically. const uniqueRowIds = [...new Set(input.rowIds)]; if (uniqueRowIds.length === 0) { throw new Error("No device rows provided."); @@ -400,6 +410,7 @@ export class CircuitWriteService { throw new Error("Invalid target circuit id."); } + // Bulk placeholder move creates exactly one new circuit as common target. if (!targetCircuit) { if (!input.targetSectionId || !input.createNewCircuit) { throw new Error("Invalid move target."); @@ -423,6 +434,7 @@ export class CircuitWriteService { } } + // Any source circuit emptied by bulk move is preserved as reserve circuit. for (const sourceCircuit of sourceCircuits.values()) { if (sourceCircuit.circuitListId !== targetCircuit.circuitListId) { throw new Error("All moved rows must belong to same circuit list as target."); @@ -493,6 +505,8 @@ export class CircuitWriteService { } async renumberSection(sectionId: string) { + // Explicit renumber operation for one section only. + // Never renumbers other sections and never runs implicitly during move/sort operations. const section = await this.circuitSectionRepository.findById(sectionId); if (!section) { throw new Error("Invalid section id."); @@ -514,6 +528,7 @@ export class CircuitWriteService { finalAssignments.push({ id: circuit.id, equipmentIdentifier: candidate }); index += 1; } + // Uses safe two-phase identifier update to avoid UNIQUE collisions during swaps. await this.circuitRepository.updateEquipmentIdentifiersSafely( section.circuitListId, finalAssignments, @@ -551,6 +566,7 @@ export class CircuitWriteService { } async reorderCircuitsInSection(sectionId: string, input: ReorderSectionCircuitsInput) { + // Reorder updates sortOrder only. BMKs remain unchanged; users may renumber explicitly later. const section = await this.circuitSectionRepository.findById(sectionId); if (!section) { throw new Error("Invalid section id."); diff --git a/src/domain/services/legacy-consumer-migration-planner.ts b/src/domain/services/legacy-consumer-migration-planner.ts index b60bc8a..fce7cbb 100644 --- a/src/domain/services/legacy-consumer-migration-planner.ts +++ b/src/domain/services/legacy-consumer-migration-planner.ts @@ -6,6 +6,7 @@ export interface LegacyConsumerForPlanning { phaseCount: number | null; } +// Accepts only normalized BMK-like legacy circuit numbers used for grouping. export function normalizeCircuitNumber(value: string | null): string | null { if (!value) { return null; @@ -20,6 +21,8 @@ export function normalizeCircuitNumber(value: string | null): string | null { return trimmed; } +// Best-effort fallback when no valid circuit number exists. Keeps migration deterministic +// by preferring explicit category/phase cues over random assignment. export function inferSectionKeyFromLegacyInput(consumer: LegacyConsumerForPlanning): string | null { const category = (consumer.category ?? "").toLowerCase(); if (category.includes("light") || category.includes("beleuchtung")) { @@ -42,6 +45,7 @@ export function inferSectionKeyFromLegacyInput(consumer: LegacyConsumerForPlanni return null; } +// Prefix-based section inference for already normalized equipment identifiers. export function inferSectionKeyFromEquipmentIdentifier(equipmentIdentifier: string): string | null { if (equipmentIdentifier.startsWith("-1F")) { return "lighting"; @@ -54,4 +58,3 @@ export function inferSectionKeyFromEquipmentIdentifier(equipmentIdentifier: stri } return null; } - diff --git a/src/domain/services/legacy-consumer-migration.service.ts b/src/domain/services/legacy-consumer-migration.service.ts index f98e203..09ef99d 100644 --- a/src/domain/services/legacy-consumer-migration.service.ts +++ b/src/domain/services/legacy-consumer-migration.service.ts @@ -40,6 +40,8 @@ export class LegacyConsumerMigrationService { private readonly roomRepository = new RoomRepository(); async migrateCircuitList(projectId: string, circuitListId: string): Promise { + // Migration is additive: legacy consumers are preserved, and circuit-first entities are created + // with mapping records so transition remains auditable and reversible. const list = await this.circuitListRepository.findById(projectId, circuitListId); if (!list) { throw new Error("Circuit list not found in project."); @@ -73,6 +75,7 @@ export class LegacyConsumerMigrationService { warnings: [], }; + // Idempotency guard: skip consumers already mapped in previous migration run. const migratedRows = await db .select({ consumerId: legacyConsumerCircuitMigrations.consumerId }) .from(legacyConsumerCircuitMigrations) @@ -80,6 +83,8 @@ export class LegacyConsumerMigrationService { const migratedConsumerIds = new Set(migratedRows.map((row) => row.consumerId)); const consumersToMigrate = legacyConsumers.filter((consumer) => !migratedConsumerIds.has(consumer.id)); + // Legacy rows are grouped by normalized circuit number so duplicates become + // multiple device rows within one circuit instead of duplicate circuits. const byNormalizedCircuitNumber = new Map(); const withoutNormalizedCircuitNumber: LegacyConsumerRow[] = []; for (const consumer of consumersToMigrate) { @@ -94,6 +99,8 @@ export class LegacyConsumerMigrationService { byNormalizedCircuitNumber.get(normalized)!.push(consumer); } + // Duplicate normalized circuit numbers are expected and represented as one circuit + // with multiple circuit_device_rows. report.groupedDuplicateCircuitNumbers = [...byNormalizedCircuitNumber.entries()] .filter(([, grouped]) => grouped.length > 1) .map(([normalizedCircuitNumber, grouped]) => ({ normalizedCircuitNumber, count: grouped.length })); @@ -105,6 +112,7 @@ export class LegacyConsumerMigrationService { isGeneratedIdentifier: boolean; }> = []; + // Stable normalized circuit numbers keep existing intent where available. for (const [normalizedCircuitNumber, grouped] of byNormalizedCircuitNumber.entries()) { groups.push({ equipmentIdentifier: normalizedCircuitNumber, @@ -114,6 +122,8 @@ export class LegacyConsumerMigrationService { }); } + // Missing/invalid circuit numbers are migrated as single-row groups with + // generated identifiers and best-effort section inference. for (const consumer of withoutNormalizedCircuitNumber) { groups.push({ equipmentIdentifier: null, diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index c9c5add..64137af 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -148,6 +148,9 @@ interface ColumnDef { locked?: boolean; } +// Centralized column metadata keeps render, filtering/sorting, keyboard traversal, +// and persisted layout in sync. BMK/equipmentIdentifier stays locked as first column +// because circuit identity and circuit drag handles depend on always-visible BMK context. const allColumns: ColumnDef[] = [ { key: "equipmentIdentifier", label: "Equipment identifier", defaultVisible: true, locked: true }, { key: "displayName", label: "Display name", defaultVisible: true }, @@ -463,13 +466,24 @@ function isPrintableKey(event: KeyboardEvent) { } export function CircuitTreeEditor(props: { projectId: string; circuitListId: string }) { + /* + Circuit-tree editor invariant overview: + - Data model is circuit-first (section -> circuit -> device rows). + - Rendered table rows are virtual projection rows, not direct DB rows. + - A circuit may appear as compact row, summary+device rows, reserve row, or placeholder row. + - Keyboard navigation and drag/drop targeting must use normalized visible rows, not raw tree nesting. + */ const { projectId, circuitListId } = props; const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + // selectedCell is spreadsheet-style keyboard focus target within normalized grid. + // It can exist without an active edit input. const [selectedCell, setSelectedCell] = useState(null); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [anchorRowKey, setAnchorRowKey] = useState(null); + // editingCell is mounted input session state (draft + mode + focus token). + // It should only exist for currently editable grid cells. const [editingCell, setEditingCell] = useState(null); const [activeSectionId, setActiveSectionId] = useState(null); const [isSaving, setIsSaving] = useState(false); @@ -478,6 +492,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const [selectedProjectDeviceId, setSelectedProjectDeviceId] = useState(null); const [targetSectionId, setTargetSectionId] = useState(null); const [targetCircuitId, setTargetCircuitId] = useState(null); + // Drag intent states stay separated by domain intent to avoid cross-type accidental operations. + // project-device drag => create/append linked rows, device-row drag => move rows, + // circuit drag => reorder circuit blocks, column drag => layout-only changes. const [draggingProjectDeviceId, setDraggingProjectDeviceId] = useState(null); const [dropIntent, setDropIntent] = useState(null); const [draggingDeviceRowId, setDraggingDeviceRowId] = useState(null); @@ -486,6 +503,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const [draggingCircuitId, setDraggingCircuitId] = useState(null); const [draggingCircuitIds, setDraggingCircuitIds] = useState([]); const [circuitReorderIntent, setCircuitReorderIntent] = useState(null); + // Undo/redo history is session-local UI state (not persisted server-side). const [undoStack, setUndoStack] = useState([]); const [redoStack, setRedoStack] = useState([]); const [historyBusy, setHistoryBusy] = useState(false); @@ -539,10 +557,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return ["equipmentIdentifier" as CellKey, ...merged.filter((key) => key !== "equipmentIdentifier")]; } + // Initial/identity-change tree load for current route context. useEffect(() => { void loadTree({ showLoading: true }); }, [projectId, circuitListId]); + // Loads project-device palette used for sidebar drag/drop and quick inserts. useEffect(() => { async function loadProjectDeviceList() { try { @@ -609,6 +629,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str ); const distinctValuesByColumn = useMemo(() => { + // Filter options are generated only for visible columns to match current header UI. + // Hidden columns still exist in row model, but are intentionally not filterable from header. const result = {} as Record; for (const column of visibleColumns) { const set = new Set(); @@ -626,6 +648,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }, [data, visibleColumns]); const filteredSortedSections = useMemo(() => { + // Filtering and sorting are frontend view state. Backend sort order remains unchanged + // until user explicitly applies sorted order. if (!data) { return [] as CircuitTreeResponseDto["sections"]; } @@ -683,6 +707,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str .filter(Boolean) as CircuitTreeCircuitDto[]; if (sortState) { + // Sort on circuit blocks so multi-device circuits keep row grouping integrity. circuits = [...circuits].sort((a, b) => { const cmp = compareSortValues(getBlockSortValue(a, sortState.key), getBlockSortValue(b, sortState.key)); return sortState.direction === "asc" ? cmp : -cmp; @@ -695,6 +720,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return sections; }, [data, columnFilters, hasActiveFilters, sortState]); + // visibleRows is the single normalized grid used by render + navigation + editability. + // This avoids selected/rendered/editable state drifting apart after filters/sorts/reloads. const visibleRows = useMemo(() => { if (!filteredSortedSections.length) { return [] as VisibleGridRow[]; @@ -705,6 +732,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str rowKey: `section:${section.id}`, rowType: "section", sectionId: section.id, + // Section headers are structural markers only: visible, but never editable/selectable. cells: allColumns.map((col) => ({ cellKey: col.key, editable: false, kind: "readonly", value: undefined })), }); for (const circuit of section.circuits) { @@ -721,6 +749,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str rows.push(makeRow("deviceRow", section.id, circuit, device)); } } + // Placeholder row is virtual (-frei-) but intentionally part of editable grid because + // editing/dropping here is the fast path to create a real circuit in this section. rows.push(makeRow("placeholder", section.id)); } return rows; @@ -756,6 +786,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str circuit?: CircuitTreeCircuitDto, device?: CircuitTreeDeviceRowDto ): VisibleGridRow { + // Row keys are UI identities, not persisted identities. They are rebuilt on each tree refresh. const rowKey = rowType === "placeholder" ? `placeholder:${sectionId}` @@ -764,6 +795,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str : `${rowType}:${circuit?.id ?? sectionId}`; const cells = allColumns.map((col) => { const kind = getCellKind(rowType, col.key); + // Hidden columns remain in the normalized row data; only visibility controls rendering. const editable = kind === "circuitField" || kind === "deviceField"; let value: string | number | boolean | undefined; if (rowType === "placeholder") { @@ -792,6 +824,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str [visibleRows] ); + // Map each visible row to editable cells currently on screen. + // This keeps Tab/arrow navigation aligned with hidden/reordered columns. const rowCellMap = useMemo(() => { const visibleKeySet = new Set(visibleColumnKeys); const map = new Map(); @@ -868,6 +902,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str setAnchorRowKey(anchor); } + // Applies cell/row selection semantics. Row selection is UI-only state and later interpreted + // as either device-row batch selection or circuit batch selection depending on drag context. function handleRowSelectionClick( row: VisibleGridRow, cellKey: CellKey, @@ -897,6 +933,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str requestAnimationFrame(() => containerRef.current?.focus()); } + // Captures semantic identity for post-reload focus restoration. function buildSelectionIntent(cell: SelectedCell): SelectionIntent | null { const row = findRow(cell.rowKey); if (!row) { @@ -912,6 +949,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }; } + // Resolves selection after writes with fallback chain: + // exact cell -> same device -> same circuit rowType -> same circuit -> same section -> first editable. function resolveSelectionIntent(intent: SelectionIntent): SelectedCell | null { const direct = editableCells.find( (cell) => cell.rowKey === intent.rowKey && cell.cellKey === intent.cellKey @@ -958,6 +997,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return editableCells[0] ?? null; } + // Reload helper for write paths. Stores pending intent because row/cell keys can change after reload. async function reloadWithIntent(intent?: SelectionIntent | null) { if (intent) { pendingSelectionAfterReload.current = intent; @@ -968,12 +1008,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } } + // Runs a normal command and records it in session-local history. async function runCommand(command: HistoryCommand) { try { setError(null); setIsSaving(true); const intent = await command.redo(); await reloadWithIntent(intent ?? null); + // Any new forward command invalidates redo history branch. setUndoStack((current) => [...current, command]); setRedoStack([]); } catch (err) { @@ -983,6 +1025,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } } + // Replays command in undo/redo mode. Commands encapsulate their own inverse logic. async function applyHistory(command: HistoryCommand, mode: "undo" | "redo") { try { setError(null); @@ -1005,6 +1048,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } } + // Undo command from session-local history stack. async function handleUndo() { if (historyBusy || isSaving || undoStack.length === 0) { return; @@ -1013,6 +1057,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str await applyHistory(command, "undo"); } + // Redo command from session-local history stack. async function handleRedo() { if (historyBusy || isSaving || redoStack.length === 0) { return; @@ -1021,6 +1066,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str await applyHistory(command, "redo"); } + // Persists currently sorted block order into section sortOrder values. async function handleApplySortedOrder() { if (!sortState || hasActiveFilters || !data) { return; @@ -1051,6 +1097,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return; } + // Sorting is view-only until explicitly applied; this avoids accidental persisted reorders. await runCommand({ label: "Apply sorted order", redo: async () => { @@ -1070,6 +1117,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }); } + // Toggles visible columns while keeping full column model intact for data/edit mapping. function toggleColumnVisibility(key: CellKey) { const column = allColumns.find((entry) => entry.key === key); if (!column || column.locked) { @@ -1109,6 +1157,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str setOpenFilterColumn(null); } + // Column drag affects layout state only and never mutates circuit/device data. function moveColumnByDrag(dragKey: CellKey, targetKey: CellKey) { if (dragKey === "equipmentIdentifier" || targetKey === "equipmentIdentifier") { return; @@ -1167,6 +1216,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str setColumnDropTargetKey(null); } + // Apply deferred selection after async reload only once visible grid is rebuilt. useEffect(() => { const intent = pendingSelectionAfterReload.current; if (!intent || !data) { @@ -1180,6 +1230,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str requestAnimationFrame(() => containerRef.current?.focus()); }, [data, editableCells, visibleRows]); + // Restores multi-selected device rows after operations that rebuild row keys. useEffect(() => { const pending = pendingSelectedDeviceRowIdsAfterReload.current; if (!pending || pending.length === 0) { @@ -1198,6 +1249,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str pendingSelectedDeviceRowIdsAfterReload.current = null; }, [visibleRows]); + // Restores multi-selected circuits after reorder/delete/recreate operations. useEffect(() => { const pending = pendingSelectedCircuitIdsAfterReload.current; if (!pending || pending.length === 0) { @@ -1220,6 +1272,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str pendingSelectedCircuitIdsAfterReload.current = null; }, [visibleRows]); + // PendingFocus is used by toolbar actions that should immediately enter edit mode on a target cell. useEffect(() => { if (!pendingFocus) { return; @@ -1254,20 +1307,25 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }); }, [editingCell?.focusToken]); + // Finds row in normalized grid by virtual row key. function findRow(rowKey: string) { return visibleRows.find((row) => row.rowKey === rowKey); } + // Finds cell in normalized grid row. function findCell(rowKey: string, cellKey: CellKey) { const row = findRow(rowKey); return row?.cells.find((cell) => cell.cellKey === cellKey); } + // Opens edit session for selected cell. + // Enter/F2/double-click use selectExisting; type-to-edit uses replaceWithTypedChar. function startEdit(cell: SelectedCell, mode: StartEditMode, typedChar?: string) { const visibleCell = findCell(cell.rowKey, cell.cellKey); if (!visibleCell || !visibleCell.editable) { return; } + // Spreadsheet behavior: typing on a selected cell starts overwrite mode. const currentDisplay = mode === "replaceWithTypedChar" ? typedChar ?? "" : String(visibleCell.value ?? ""); focusTokenRef.current += 1; setEditingCell({ @@ -1278,6 +1336,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }); } + // Horizontal keyboard navigation constrained to editable cells in current row. function moveHorizontal(direction: 1 | -1) { if (!selectedCell) { return; @@ -1291,6 +1350,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str setSelectedCell({ rowKey: selectedCell.rowKey, cellKey: next }); } + // Vertical keyboard navigation preserves nearest visible column when row cell sets differ. function moveVertical(direction: 1 | -1) { if (!selectedCell) { return; @@ -1322,6 +1382,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str setSelectedCell({ rowKey: targetRowKey, cellKey: best }); } + // Maps generic grid cell edits to circuit PATCH payload fields. async function patchCircuit(circuitId: string, key: CellKey, draft: string) { const payload: Record = {}; if (key === "protectionSummary" || key === "cableSummary") { @@ -1337,6 +1398,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str await updateCircuitById(circuitId, payload); } + // Maps generic grid cell edits to device-row PATCH payload fields. async function patchDeviceRow(rowId: string, key: CellKey, draft: string) { const payload: Record = {}; if (["quantity", "powerPerUnit", "simultaneityFactor", "cosPhi"].includes(key)) { @@ -1362,6 +1424,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str await updateCircuitDeviceRowById(rowId, payload); } + // Creates a real circuit from virtual placeholder row. + // Device-field edits create circuit + first manual row; circuit-field edits patch circuit directly. async function createFromPlaceholder( sectionId: string, key: CellKey, @@ -1401,6 +1465,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return { rowKey: `reserveCircuit:${createdCircuit.id}`, cellKey: key }; } + // Reads current persisted value into editable draft text for reversible edit commands. function getCurrentDraftForCell(row: VisibleGridRow, cellKey: CellKey, kind: CellKind) { if (kind === "deviceField" && row.device) { return String(getDeviceValue(row.device, cellKey) ?? ""); @@ -1411,6 +1476,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return ""; } + // Commits the active editor input through command history so edits remain undoable. async function commitEdit(direction: SaveDirection = "stay") { if (!editingCell) { return; @@ -1438,6 +1504,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return buildSelectionIntent(selected); }; + // Placeholder rows are not persisted entities; editing them materializes a real circuit first. if (row.rowType === "placeholder") { let createdCircuitId: string | null = null; const command: HistoryCommand = { @@ -1535,6 +1602,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } } + // Cancels current edit session and restores grid keyboard focus. function cancelEdit() { if (!editingCell) { return; @@ -1906,6 +1974,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return null; } + // Converts current row selection to device-row ids for bulk row moves. + // Section and placeholder rows are intentionally ignored. function getSelectedEligibleDeviceRowIds(primaryRowId?: string | null) { const selectedSet = new Set(selectedRowKeys); const ids: string[] = []; @@ -1923,6 +1993,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return primaryRowId ? [primaryRowId] : ids; } + // Converts current row selection to unique circuit ids for circuit block reorder. function getSelectedEligibleCircuitIds(primaryCircuitId?: string | null) { const selectedSet = new Set(selectedRowKeys); const ids: string[] = []; @@ -1955,6 +2026,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return null; } + // Applies same-section circuit reorder as explicit id ordering. + // No implicit renumbering is performed here. async function applyCircuitReorder(intent: CircuitReorderDropIntent, sourceCircuitIds: string[]) { const section = data?.sections.find((entry) => entry.id === intent.sectionId); if (!section) { @@ -1981,6 +2054,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str await reorderSectionCircuits(section.id, nextIds); } + // Handles project-device drag intent. This path creates linked rows/circuits and + // must remain separate from move handlers that operate on existing entities. async function handleDropWithIntent(event: DragEvent, intent: ProjectDeviceDropIntent) { event.preventDefault(); event.stopPropagation(); @@ -1998,6 +2073,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str setError("Invalid project device drop source."); return; } + // Project-device drop logic is isolated from row/circuit move logic because + // it can create new rows/circuits instead of moving existing ones. if (intent.kind === "new-circuit") { let createdCircuitId: string | null = null; await runCommand({ @@ -2047,6 +2124,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }); } + // Handles device-row drag intent (single or bulk) and preserves enough source grouping + // for deterministic undo back to original circuits. async function handleDeviceRowDropWithIntent(event: DragEvent, intent: DeviceRowMoveDropIntent) { event.preventDefault(); event.stopPropagation(); @@ -2077,6 +2156,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return; } + // Device-row drag supports bulk selection, but target intent is always explicit: + // move into existing circuit or create new circuit in a target section. if (intent.kind === "move-to-circuit") { if (rowIds.length === 1 && intent.circuitId === sourceCircuitId) { return; @@ -2141,6 +2222,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }); } + // Handles circuit drag intent and reorders whole circuit blocks within one section only. async function handleCircuitReorderDrop(event: DragEvent, intent: CircuitReorderDropIntent) { event.preventDefault(); event.stopPropagation(); @@ -2197,6 +2279,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }); } + // Delete undo recreates the row from captured snapshot because delete is destructive. async function handleDeleteDevice(rowId: string) { if (!confirm("Delete this device row?")) { return; @@ -2216,6 +2299,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return null; }, undo: async () => { + // Recreate instead of "undelete": backend ids are immutable once deleted. const created = (await createCircuitDeviceRow(sourceCircuitId, { linkedProjectDeviceId: rowSnapshot.linkedProjectDeviceId, name: rowSnapshot.name, @@ -2242,6 +2326,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }); } + // Deletes a circuit and all child rows; undo recreates from captured snapshot. async function handleDeleteCircuit(circuitId: string) { if (!confirm("Delete this circuit and all assigned device rows?")) { return; @@ -2266,6 +2351,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }); } + // Explicit renumber action. Undo restores previous BMKs via safe section identifier update. async function handleRenumberSection(sectionId: string) { if (!confirm("Renumber this section? Only circuits in this section will change.")) { return; @@ -2285,6 +2371,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return null; }, undo: async () => { + // Restore prior BMKs through safe bulk endpoint to avoid transient unique collisions. await updateSectionEquipmentIdentifiers( sectionId, before.map((entry) => ({ @@ -2297,6 +2384,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }); } + // Keeps container-level keyboard focus model valid when focus returns from toolbar/popovers. function handleContainerFocus() { if (editingCell) { const row = findRow(editingCell.rowKey); @@ -2311,6 +2399,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } } + // Spreadsheet-like keyboard dispatcher: + // - Enter/F2 starts edit with current value selected + // - printable key starts overwrite edit (type-to-edit) + // - Arrow/Tab move selectedCell when not editing function handleContainerKeyDown(event: KeyboardEvent) { if (editingCell) { if (event.key === "Tab") { @@ -2389,6 +2481,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return; } if (isPrintableKey(event) && selectedCell) { + // First printable key should become draft content directly; do not pre-select old text. event.preventDefault(); startEdit(selectedCell, "replaceWithTypedChar", event.key); } @@ -2477,6 +2570,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } const isSortedView = Boolean(sortState); + // Renumbering is intentionally blocked while sort/filter overlays are active so users + // cannot renumber against a transformed view that has not been persisted yet. const hasActiveSortOrFilter = isSortedView || hasActiveFilters; const draggingDeviceCount = draggingDeviceRowIds.length > 0 ? draggingDeviceRowIds.length : draggingDeviceRowId ? 1 : 0; const activeDraggedCircuitIds =