Documentation
This commit is contained in:
@@ -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)
|
- 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)
|
- 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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -141,6 +141,8 @@ export class CircuitRepository {
|
|||||||
if (updates.length === 0) {
|
if (updates.length === 0) {
|
||||||
return;
|
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) => {
|
db.transaction((tx) => {
|
||||||
const ids = updates.map((entry) => entry.id);
|
const ids = updates.map((entry) => entry.id);
|
||||||
const existing = tx
|
const existing = tx
|
||||||
@@ -152,6 +154,10 @@ export class CircuitRepository {
|
|||||||
throw new Error("One or more circuit ids are invalid for circuit list.");
|
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();
|
const stamp = Date.now();
|
||||||
for (let index = 0; index < updates.length; index += 1) {
|
for (let index = 0; index < updates.length; index += 1) {
|
||||||
const entry = updates[index];
|
const entry = updates[index];
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export class CircuitWriteService {
|
|||||||
this.numberingService = deps?.numberingService ?? new CircuitNumberingService();
|
this.numberingService = deps?.numberingService ?? new CircuitNumberingService();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensures writes never connect a section to the wrong circuit list.
|
||||||
private async assertSectionInList(sectionId: string, circuitListId: string) {
|
private async assertSectionInList(sectionId: string, circuitListId: string) {
|
||||||
const section = await this.circuitSectionRepository.findById(sectionId);
|
const section = await this.circuitSectionRepository.findById(sectionId);
|
||||||
if (!section) {
|
if (!section) {
|
||||||
@@ -50,6 +51,7 @@ export class CircuitWriteService {
|
|||||||
return section;
|
return section;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enforces BMK uniqueness inside one circuit list.
|
||||||
private async assertUniqueEquipmentIdentifier(
|
private async assertUniqueEquipmentIdentifier(
|
||||||
circuitListId: string,
|
circuitListId: string,
|
||||||
equipmentIdentifier: 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) {
|
private async assertValidLinkedProjectDevice(circuitId: string, linkedProjectDeviceId?: string) {
|
||||||
if (!linkedProjectDeviceId) {
|
if (!linkedProjectDeviceId) {
|
||||||
return;
|
return;
|
||||||
@@ -184,6 +187,7 @@ export class CircuitWriteService {
|
|||||||
overriddenFields: input.overriddenFields,
|
overriddenFields: input.overriddenFields,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reserve circuits become active as soon as at least one device row exists.
|
||||||
if (Boolean(circuit.isReserve)) {
|
if (Boolean(circuit.isReserve)) {
|
||||||
await this.circuitRepository.update(circuit.id, {
|
await this.circuitRepository.update(circuit.id, {
|
||||||
sectionId: circuit.sectionId,
|
sectionId: circuit.sectionId,
|
||||||
@@ -247,6 +251,7 @@ export class CircuitWriteService {
|
|||||||
}
|
}
|
||||||
await this.deviceRowRepository.delete(rowId);
|
await this.deviceRowRepository.delete(rowId);
|
||||||
const remaining = await this.deviceRowRepository.countByCircuit(current.circuitId);
|
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) {
|
if (remaining === 0) {
|
||||||
await this.circuitRepository.update(circuit.id, {
|
await this.circuitRepository.update(circuit.id, {
|
||||||
sectionId: circuit.sectionId,
|
sectionId: circuit.sectionId,
|
||||||
@@ -287,6 +292,7 @@ export class CircuitWriteService {
|
|||||||
throw new Error("Invalid target circuit id.");
|
throw new Error("Invalid target circuit id.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Placeholder-target move creates a new circuit explicitly; no implicit renumbering of others.
|
||||||
if (!targetCircuit) {
|
if (!targetCircuit) {
|
||||||
if (!input.targetSectionId || !input.createNewCircuit) {
|
if (!input.targetSectionId || !input.createNewCircuit) {
|
||||||
throw new Error("Invalid move target.");
|
throw new Error("Invalid move target.");
|
||||||
@@ -321,6 +327,7 @@ export class CircuitWriteService {
|
|||||||
await this.deviceRowRepository.moveToCircuit(rowId, targetCircuit.id, (targetCount + 1) * 10);
|
await this.deviceRowRepository.moveToCircuit(rowId, targetCircuit.id, (targetCount + 1) * 10);
|
||||||
|
|
||||||
const sourceRemaining = await this.deviceRowRepository.countByCircuit(sourceCircuit.id);
|
const sourceRemaining = await this.deviceRowRepository.countByCircuit(sourceCircuit.id);
|
||||||
|
// Source circuit becomes reserve when all rows are moved away.
|
||||||
if (sourceRemaining === 0) {
|
if (sourceRemaining === 0) {
|
||||||
await this.circuitRepository.update(sourceCircuit.id, {
|
await this.circuitRepository.update(sourceCircuit.id, {
|
||||||
sectionId: sourceCircuit.sectionId,
|
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)) {
|
if (Boolean(targetCircuit.isReserve)) {
|
||||||
await this.circuitRepository.update(targetCircuit.id, {
|
await this.circuitRepository.update(targetCircuit.id, {
|
||||||
sectionId: targetCircuit.sectionId,
|
sectionId: targetCircuit.sectionId,
|
||||||
@@ -367,6 +375,8 @@ export class CircuitWriteService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async moveDeviceRowsBulk(input: MoveCircuitDeviceRowsBulkInput) {
|
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)];
|
const uniqueRowIds = [...new Set(input.rowIds)];
|
||||||
if (uniqueRowIds.length === 0) {
|
if (uniqueRowIds.length === 0) {
|
||||||
throw new Error("No device rows provided.");
|
throw new Error("No device rows provided.");
|
||||||
@@ -400,6 +410,7 @@ export class CircuitWriteService {
|
|||||||
throw new Error("Invalid target circuit id.");
|
throw new Error("Invalid target circuit id.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bulk placeholder move creates exactly one new circuit as common target.
|
||||||
if (!targetCircuit) {
|
if (!targetCircuit) {
|
||||||
if (!input.targetSectionId || !input.createNewCircuit) {
|
if (!input.targetSectionId || !input.createNewCircuit) {
|
||||||
throw new Error("Invalid move target.");
|
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()) {
|
for (const sourceCircuit of sourceCircuits.values()) {
|
||||||
if (sourceCircuit.circuitListId !== targetCircuit.circuitListId) {
|
if (sourceCircuit.circuitListId !== targetCircuit.circuitListId) {
|
||||||
throw new Error("All moved rows must belong to same circuit list as target.");
|
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) {
|
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);
|
const section = await this.circuitSectionRepository.findById(sectionId);
|
||||||
if (!section) {
|
if (!section) {
|
||||||
throw new Error("Invalid section id.");
|
throw new Error("Invalid section id.");
|
||||||
@@ -514,6 +528,7 @@ export class CircuitWriteService {
|
|||||||
finalAssignments.push({ id: circuit.id, equipmentIdentifier: candidate });
|
finalAssignments.push({ id: circuit.id, equipmentIdentifier: candidate });
|
||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
|
// Uses safe two-phase identifier update to avoid UNIQUE collisions during swaps.
|
||||||
await this.circuitRepository.updateEquipmentIdentifiersSafely(
|
await this.circuitRepository.updateEquipmentIdentifiersSafely(
|
||||||
section.circuitListId,
|
section.circuitListId,
|
||||||
finalAssignments,
|
finalAssignments,
|
||||||
@@ -551,6 +566,7 @@ export class CircuitWriteService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async reorderCircuitsInSection(sectionId: string, input: ReorderSectionCircuitsInput) {
|
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);
|
const section = await this.circuitSectionRepository.findById(sectionId);
|
||||||
if (!section) {
|
if (!section) {
|
||||||
throw new Error("Invalid section id.");
|
throw new Error("Invalid section id.");
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface LegacyConsumerForPlanning {
|
|||||||
phaseCount: number | null;
|
phaseCount: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accepts only normalized BMK-like legacy circuit numbers used for grouping.
|
||||||
export function normalizeCircuitNumber(value: string | null): string | null {
|
export function normalizeCircuitNumber(value: string | null): string | null {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return null;
|
return null;
|
||||||
@@ -20,6 +21,8 @@ export function normalizeCircuitNumber(value: string | null): string | null {
|
|||||||
return trimmed;
|
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 {
|
export function inferSectionKeyFromLegacyInput(consumer: LegacyConsumerForPlanning): string | null {
|
||||||
const category = (consumer.category ?? "").toLowerCase();
|
const category = (consumer.category ?? "").toLowerCase();
|
||||||
if (category.includes("light") || category.includes("beleuchtung")) {
|
if (category.includes("light") || category.includes("beleuchtung")) {
|
||||||
@@ -42,6 +45,7 @@ export function inferSectionKeyFromLegacyInput(consumer: LegacyConsumerForPlanni
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefix-based section inference for already normalized equipment identifiers.
|
||||||
export function inferSectionKeyFromEquipmentIdentifier(equipmentIdentifier: string): string | null {
|
export function inferSectionKeyFromEquipmentIdentifier(equipmentIdentifier: string): string | null {
|
||||||
if (equipmentIdentifier.startsWith("-1F")) {
|
if (equipmentIdentifier.startsWith("-1F")) {
|
||||||
return "lighting";
|
return "lighting";
|
||||||
@@ -54,4 +58,3 @@ export function inferSectionKeyFromEquipmentIdentifier(equipmentIdentifier: stri
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export class LegacyConsumerMigrationService {
|
|||||||
private readonly roomRepository = new RoomRepository();
|
private readonly roomRepository = new RoomRepository();
|
||||||
|
|
||||||
async migrateCircuitList(projectId: string, circuitListId: string): Promise<LegacyMigrationReport> {
|
async migrateCircuitList(projectId: string, circuitListId: string): Promise<LegacyMigrationReport> {
|
||||||
|
// 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);
|
const list = await this.circuitListRepository.findById(projectId, circuitListId);
|
||||||
if (!list) {
|
if (!list) {
|
||||||
throw new Error("Circuit list not found in project.");
|
throw new Error("Circuit list not found in project.");
|
||||||
@@ -73,6 +75,7 @@ export class LegacyConsumerMigrationService {
|
|||||||
warnings: [],
|
warnings: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Idempotency guard: skip consumers already mapped in previous migration run.
|
||||||
const migratedRows = await db
|
const migratedRows = await db
|
||||||
.select({ consumerId: legacyConsumerCircuitMigrations.consumerId })
|
.select({ consumerId: legacyConsumerCircuitMigrations.consumerId })
|
||||||
.from(legacyConsumerCircuitMigrations)
|
.from(legacyConsumerCircuitMigrations)
|
||||||
@@ -80,6 +83,8 @@ export class LegacyConsumerMigrationService {
|
|||||||
const migratedConsumerIds = new Set(migratedRows.map((row) => row.consumerId));
|
const migratedConsumerIds = new Set(migratedRows.map((row) => row.consumerId));
|
||||||
const consumersToMigrate = legacyConsumers.filter((consumer) => !migratedConsumerIds.has(consumer.id));
|
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<string, LegacyConsumerRow[]>();
|
const byNormalizedCircuitNumber = new Map<string, LegacyConsumerRow[]>();
|
||||||
const withoutNormalizedCircuitNumber: LegacyConsumerRow[] = [];
|
const withoutNormalizedCircuitNumber: LegacyConsumerRow[] = [];
|
||||||
for (const consumer of consumersToMigrate) {
|
for (const consumer of consumersToMigrate) {
|
||||||
@@ -94,6 +99,8 @@ export class LegacyConsumerMigrationService {
|
|||||||
byNormalizedCircuitNumber.get(normalized)!.push(consumer);
|
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()]
|
report.groupedDuplicateCircuitNumbers = [...byNormalizedCircuitNumber.entries()]
|
||||||
.filter(([, grouped]) => grouped.length > 1)
|
.filter(([, grouped]) => grouped.length > 1)
|
||||||
.map(([normalizedCircuitNumber, grouped]) => ({ normalizedCircuitNumber, count: grouped.length }));
|
.map(([normalizedCircuitNumber, grouped]) => ({ normalizedCircuitNumber, count: grouped.length }));
|
||||||
@@ -105,6 +112,7 @@ export class LegacyConsumerMigrationService {
|
|||||||
isGeneratedIdentifier: boolean;
|
isGeneratedIdentifier: boolean;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
|
// Stable normalized circuit numbers keep existing intent where available.
|
||||||
for (const [normalizedCircuitNumber, grouped] of byNormalizedCircuitNumber.entries()) {
|
for (const [normalizedCircuitNumber, grouped] of byNormalizedCircuitNumber.entries()) {
|
||||||
groups.push({
|
groups.push({
|
||||||
equipmentIdentifier: normalizedCircuitNumber,
|
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) {
|
for (const consumer of withoutNormalizedCircuitNumber) {
|
||||||
groups.push({
|
groups.push({
|
||||||
equipmentIdentifier: null,
|
equipmentIdentifier: null,
|
||||||
|
|||||||
@@ -148,6 +148,9 @@ interface ColumnDef {
|
|||||||
locked?: boolean;
|
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[] = [
|
const allColumns: ColumnDef[] = [
|
||||||
{ key: "equipmentIdentifier", label: "Equipment identifier", defaultVisible: true, locked: true },
|
{ key: "equipmentIdentifier", label: "Equipment identifier", defaultVisible: true, locked: true },
|
||||||
{ key: "displayName", label: "Display name", defaultVisible: true },
|
{ key: "displayName", label: "Display name", defaultVisible: true },
|
||||||
@@ -463,13 +466,24 @@ function isPrintableKey(event: KeyboardEvent<HTMLElement>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CircuitTreeEditor(props: { projectId: string; circuitListId: string }) {
|
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 { projectId, circuitListId } = props;
|
||||||
const [data, setData] = useState<CircuitTreeResponseDto | null>(null);
|
const [data, setData] = useState<CircuitTreeResponseDto | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// selectedCell is spreadsheet-style keyboard focus target within normalized grid.
|
||||||
|
// It can exist without an active edit input.
|
||||||
const [selectedCell, setSelectedCell] = useState<SelectedCell | null>(null);
|
const [selectedCell, setSelectedCell] = useState<SelectedCell | null>(null);
|
||||||
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||||
const [anchorRowKey, setAnchorRowKey] = useState<string | null>(null);
|
const [anchorRowKey, setAnchorRowKey] = useState<string | null>(null);
|
||||||
|
// editingCell is mounted input session state (draft + mode + focus token).
|
||||||
|
// It should only exist for currently editable grid cells.
|
||||||
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
const [editingCell, setEditingCell] = useState<EditingCell | 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);
|
||||||
@@ -478,6 +492,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
const [selectedProjectDeviceId, setSelectedProjectDeviceId] = useState<string | null>(null);
|
const [selectedProjectDeviceId, setSelectedProjectDeviceId] = useState<string | null>(null);
|
||||||
const [targetSectionId, setTargetSectionId] = useState<string | null>(null);
|
const [targetSectionId, setTargetSectionId] = useState<string | null>(null);
|
||||||
const [targetCircuitId, setTargetCircuitId] = useState<string | null>(null);
|
const [targetCircuitId, setTargetCircuitId] = useState<string | null>(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<string | null>(null);
|
const [draggingProjectDeviceId, setDraggingProjectDeviceId] = useState<string | null>(null);
|
||||||
const [dropIntent, setDropIntent] = useState<ProjectDeviceDropIntent | null>(null);
|
const [dropIntent, setDropIntent] = useState<ProjectDeviceDropIntent | null>(null);
|
||||||
const [draggingDeviceRowId, setDraggingDeviceRowId] = useState<string | null>(null);
|
const [draggingDeviceRowId, setDraggingDeviceRowId] = useState<string | null>(null);
|
||||||
@@ -486,6 +503,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
const [draggingCircuitId, setDraggingCircuitId] = useState<string | null>(null);
|
const [draggingCircuitId, setDraggingCircuitId] = useState<string | null>(null);
|
||||||
const [draggingCircuitIds, setDraggingCircuitIds] = useState<string[]>([]);
|
const [draggingCircuitIds, setDraggingCircuitIds] = useState<string[]>([]);
|
||||||
const [circuitReorderIntent, setCircuitReorderIntent] = useState<CircuitReorderDropIntent | null>(null);
|
const [circuitReorderIntent, setCircuitReorderIntent] = useState<CircuitReorderDropIntent | null>(null);
|
||||||
|
// Undo/redo history is session-local UI state (not persisted server-side).
|
||||||
const [undoStack, setUndoStack] = useState<HistoryCommand[]>([]);
|
const [undoStack, setUndoStack] = useState<HistoryCommand[]>([]);
|
||||||
const [redoStack, setRedoStack] = useState<HistoryCommand[]>([]);
|
const [redoStack, setRedoStack] = useState<HistoryCommand[]>([]);
|
||||||
const [historyBusy, setHistoryBusy] = useState(false);
|
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")];
|
return ["equipmentIdentifier" as CellKey, ...merged.filter((key) => key !== "equipmentIdentifier")];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initial/identity-change tree load for current route context.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadTree({ showLoading: true });
|
void loadTree({ showLoading: true });
|
||||||
}, [projectId, circuitListId]);
|
}, [projectId, circuitListId]);
|
||||||
|
|
||||||
|
// Loads project-device palette used for sidebar drag/drop and quick inserts.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadProjectDeviceList() {
|
async function loadProjectDeviceList() {
|
||||||
try {
|
try {
|
||||||
@@ -609,6 +629,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
);
|
);
|
||||||
|
|
||||||
const distinctValuesByColumn = useMemo(() => {
|
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<CellKey, string[]>;
|
const result = {} as Record<CellKey, string[]>;
|
||||||
for (const column of visibleColumns) {
|
for (const column of visibleColumns) {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
@@ -626,6 +648,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
}, [data, visibleColumns]);
|
}, [data, visibleColumns]);
|
||||||
|
|
||||||
const filteredSortedSections = useMemo(() => {
|
const filteredSortedSections = useMemo(() => {
|
||||||
|
// Filtering and sorting are frontend view state. Backend sort order remains unchanged
|
||||||
|
// until user explicitly applies sorted order.
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return [] as CircuitTreeResponseDto["sections"];
|
return [] as CircuitTreeResponseDto["sections"];
|
||||||
}
|
}
|
||||||
@@ -683,6 +707,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
.filter(Boolean) as CircuitTreeCircuitDto[];
|
.filter(Boolean) as CircuitTreeCircuitDto[];
|
||||||
|
|
||||||
if (sortState) {
|
if (sortState) {
|
||||||
|
// Sort on circuit blocks so multi-device circuits keep row grouping integrity.
|
||||||
circuits = [...circuits].sort((a, b) => {
|
circuits = [...circuits].sort((a, b) => {
|
||||||
const cmp = compareSortValues(getBlockSortValue(a, sortState.key), getBlockSortValue(b, sortState.key));
|
const cmp = compareSortValues(getBlockSortValue(a, sortState.key), getBlockSortValue(b, sortState.key));
|
||||||
return sortState.direction === "asc" ? cmp : -cmp;
|
return sortState.direction === "asc" ? cmp : -cmp;
|
||||||
@@ -695,6 +720,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
return sections;
|
return sections;
|
||||||
}, [data, columnFilters, hasActiveFilters, sortState]);
|
}, [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(() => {
|
const visibleRows = useMemo(() => {
|
||||||
if (!filteredSortedSections.length) {
|
if (!filteredSortedSections.length) {
|
||||||
return [] as VisibleGridRow[];
|
return [] as VisibleGridRow[];
|
||||||
@@ -705,6 +732,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
rowKey: `section:${section.id}`,
|
rowKey: `section:${section.id}`,
|
||||||
rowType: "section",
|
rowType: "section",
|
||||||
sectionId: section.id,
|
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 })),
|
cells: allColumns.map((col) => ({ cellKey: col.key, editable: false, kind: "readonly", value: undefined })),
|
||||||
});
|
});
|
||||||
for (const circuit of section.circuits) {
|
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));
|
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));
|
rows.push(makeRow("placeholder", section.id));
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
@@ -756,6 +786,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
circuit?: CircuitTreeCircuitDto,
|
circuit?: CircuitTreeCircuitDto,
|
||||||
device?: CircuitTreeDeviceRowDto
|
device?: CircuitTreeDeviceRowDto
|
||||||
): VisibleGridRow {
|
): VisibleGridRow {
|
||||||
|
// Row keys are UI identities, not persisted identities. They are rebuilt on each tree refresh.
|
||||||
const rowKey =
|
const rowKey =
|
||||||
rowType === "placeholder"
|
rowType === "placeholder"
|
||||||
? `placeholder:${sectionId}`
|
? `placeholder:${sectionId}`
|
||||||
@@ -764,6 +795,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
: `${rowType}:${circuit?.id ?? sectionId}`;
|
: `${rowType}:${circuit?.id ?? sectionId}`;
|
||||||
const cells = allColumns.map((col) => {
|
const cells = allColumns.map((col) => {
|
||||||
const kind = getCellKind(rowType, col.key);
|
const kind = getCellKind(rowType, col.key);
|
||||||
|
// Hidden columns remain in the normalized row data; only visibility controls rendering.
|
||||||
const editable = kind === "circuitField" || kind === "deviceField";
|
const editable = kind === "circuitField" || kind === "deviceField";
|
||||||
let value: string | number | boolean | undefined;
|
let value: string | number | boolean | undefined;
|
||||||
if (rowType === "placeholder") {
|
if (rowType === "placeholder") {
|
||||||
@@ -792,6 +824,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
[visibleRows]
|
[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 rowCellMap = useMemo(() => {
|
||||||
const visibleKeySet = new Set(visibleColumnKeys);
|
const visibleKeySet = new Set(visibleColumnKeys);
|
||||||
const map = new Map<string, CellKey[]>();
|
const map = new Map<string, CellKey[]>();
|
||||||
@@ -868,6 +902,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
setAnchorRowKey(anchor);
|
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(
|
function handleRowSelectionClick(
|
||||||
row: VisibleGridRow,
|
row: VisibleGridRow,
|
||||||
cellKey: CellKey,
|
cellKey: CellKey,
|
||||||
@@ -897,6 +933,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
requestAnimationFrame(() => containerRef.current?.focus());
|
requestAnimationFrame(() => containerRef.current?.focus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Captures semantic identity for post-reload focus restoration.
|
||||||
function buildSelectionIntent(cell: SelectedCell): SelectionIntent | null {
|
function buildSelectionIntent(cell: SelectedCell): SelectionIntent | null {
|
||||||
const row = findRow(cell.rowKey);
|
const row = findRow(cell.rowKey);
|
||||||
if (!row) {
|
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 {
|
function resolveSelectionIntent(intent: SelectionIntent): SelectedCell | null {
|
||||||
const direct = editableCells.find(
|
const direct = editableCells.find(
|
||||||
(cell) => cell.rowKey === intent.rowKey && cell.cellKey === intent.cellKey
|
(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;
|
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) {
|
async function reloadWithIntent(intent?: SelectionIntent | null) {
|
||||||
if (intent) {
|
if (intent) {
|
||||||
pendingSelectionAfterReload.current = 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) {
|
async function runCommand(command: HistoryCommand) {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
const intent = await command.redo();
|
const intent = await command.redo();
|
||||||
await reloadWithIntent(intent ?? null);
|
await reloadWithIntent(intent ?? null);
|
||||||
|
// Any new forward command invalidates redo history branch.
|
||||||
setUndoStack((current) => [...current, command]);
|
setUndoStack((current) => [...current, command]);
|
||||||
setRedoStack([]);
|
setRedoStack([]);
|
||||||
} catch (err) {
|
} 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") {
|
async function applyHistory(command: HistoryCommand, mode: "undo" | "redo") {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -1005,6 +1048,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Undo command from session-local history stack.
|
||||||
async function handleUndo() {
|
async function handleUndo() {
|
||||||
if (historyBusy || isSaving || undoStack.length === 0) {
|
if (historyBusy || isSaving || undoStack.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -1013,6 +1057,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
await applyHistory(command, "undo");
|
await applyHistory(command, "undo");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redo command from session-local history stack.
|
||||||
async function handleRedo() {
|
async function handleRedo() {
|
||||||
if (historyBusy || isSaving || redoStack.length === 0) {
|
if (historyBusy || isSaving || redoStack.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -1021,6 +1066,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
await applyHistory(command, "redo");
|
await applyHistory(command, "redo");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persists currently sorted block order into section sortOrder values.
|
||||||
async function handleApplySortedOrder() {
|
async function handleApplySortedOrder() {
|
||||||
if (!sortState || hasActiveFilters || !data) {
|
if (!sortState || hasActiveFilters || !data) {
|
||||||
return;
|
return;
|
||||||
@@ -1051,6 +1097,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sorting is view-only until explicitly applied; this avoids accidental persisted reorders.
|
||||||
await runCommand({
|
await runCommand({
|
||||||
label: "Apply sorted order",
|
label: "Apply sorted order",
|
||||||
redo: async () => {
|
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) {
|
function toggleColumnVisibility(key: CellKey) {
|
||||||
const column = allColumns.find((entry) => entry.key === key);
|
const column = allColumns.find((entry) => entry.key === key);
|
||||||
if (!column || column.locked) {
|
if (!column || column.locked) {
|
||||||
@@ -1109,6 +1157,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
setOpenFilterColumn(null);
|
setOpenFilterColumn(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Column drag affects layout state only and never mutates circuit/device data.
|
||||||
function moveColumnByDrag(dragKey: CellKey, targetKey: CellKey) {
|
function moveColumnByDrag(dragKey: CellKey, targetKey: CellKey) {
|
||||||
if (dragKey === "equipmentIdentifier" || targetKey === "equipmentIdentifier") {
|
if (dragKey === "equipmentIdentifier" || targetKey === "equipmentIdentifier") {
|
||||||
return;
|
return;
|
||||||
@@ -1167,6 +1216,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
setColumnDropTargetKey(null);
|
setColumnDropTargetKey(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply deferred selection after async reload only once visible grid is rebuilt.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const intent = pendingSelectionAfterReload.current;
|
const intent = pendingSelectionAfterReload.current;
|
||||||
if (!intent || !data) {
|
if (!intent || !data) {
|
||||||
@@ -1180,6 +1230,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
requestAnimationFrame(() => containerRef.current?.focus());
|
requestAnimationFrame(() => containerRef.current?.focus());
|
||||||
}, [data, editableCells, visibleRows]);
|
}, [data, editableCells, visibleRows]);
|
||||||
|
|
||||||
|
// Restores multi-selected device rows after operations that rebuild row keys.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const pending = pendingSelectedDeviceRowIdsAfterReload.current;
|
const pending = pendingSelectedDeviceRowIdsAfterReload.current;
|
||||||
if (!pending || pending.length === 0) {
|
if (!pending || pending.length === 0) {
|
||||||
@@ -1198,6 +1249,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
pendingSelectedDeviceRowIdsAfterReload.current = null;
|
pendingSelectedDeviceRowIdsAfterReload.current = null;
|
||||||
}, [visibleRows]);
|
}, [visibleRows]);
|
||||||
|
|
||||||
|
// Restores multi-selected circuits after reorder/delete/recreate operations.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const pending = pendingSelectedCircuitIdsAfterReload.current;
|
const pending = pendingSelectedCircuitIdsAfterReload.current;
|
||||||
if (!pending || pending.length === 0) {
|
if (!pending || pending.length === 0) {
|
||||||
@@ -1220,6 +1272,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
pendingSelectedCircuitIdsAfterReload.current = null;
|
pendingSelectedCircuitIdsAfterReload.current = null;
|
||||||
}, [visibleRows]);
|
}, [visibleRows]);
|
||||||
|
|
||||||
|
// PendingFocus is used by toolbar actions that should immediately enter edit mode on a target cell.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingFocus) {
|
if (!pendingFocus) {
|
||||||
return;
|
return;
|
||||||
@@ -1254,20 +1307,25 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
});
|
});
|
||||||
}, [editingCell?.focusToken]);
|
}, [editingCell?.focusToken]);
|
||||||
|
|
||||||
|
// Finds row in normalized grid by virtual row key.
|
||||||
function findRow(rowKey: string) {
|
function findRow(rowKey: string) {
|
||||||
return visibleRows.find((row) => row.rowKey === rowKey);
|
return visibleRows.find((row) => row.rowKey === rowKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Finds cell in normalized grid row.
|
||||||
function findCell(rowKey: string, cellKey: CellKey) {
|
function findCell(rowKey: string, cellKey: CellKey) {
|
||||||
const row = findRow(rowKey);
|
const row = findRow(rowKey);
|
||||||
return row?.cells.find((cell) => cell.cellKey === cellKey);
|
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) {
|
function startEdit(cell: SelectedCell, mode: StartEditMode, typedChar?: string) {
|
||||||
const visibleCell = findCell(cell.rowKey, cell.cellKey);
|
const visibleCell = findCell(cell.rowKey, cell.cellKey);
|
||||||
if (!visibleCell || !visibleCell.editable) {
|
if (!visibleCell || !visibleCell.editable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Spreadsheet behavior: typing on a selected cell starts overwrite mode.
|
||||||
const currentDisplay = mode === "replaceWithTypedChar" ? typedChar ?? "" : String(visibleCell.value ?? "");
|
const currentDisplay = mode === "replaceWithTypedChar" ? typedChar ?? "" : String(visibleCell.value ?? "");
|
||||||
focusTokenRef.current += 1;
|
focusTokenRef.current += 1;
|
||||||
setEditingCell({
|
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) {
|
function moveHorizontal(direction: 1 | -1) {
|
||||||
if (!selectedCell) {
|
if (!selectedCell) {
|
||||||
return;
|
return;
|
||||||
@@ -1291,6 +1350,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
setSelectedCell({ rowKey: selectedCell.rowKey, cellKey: next });
|
setSelectedCell({ rowKey: selectedCell.rowKey, cellKey: next });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vertical keyboard navigation preserves nearest visible column when row cell sets differ.
|
||||||
function moveVertical(direction: 1 | -1) {
|
function moveVertical(direction: 1 | -1) {
|
||||||
if (!selectedCell) {
|
if (!selectedCell) {
|
||||||
return;
|
return;
|
||||||
@@ -1322,6 +1382,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
setSelectedCell({ rowKey: targetRowKey, cellKey: best });
|
setSelectedCell({ rowKey: targetRowKey, cellKey: best });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Maps generic grid cell edits to circuit PATCH payload fields.
|
||||||
async function patchCircuit(circuitId: string, key: CellKey, draft: string) {
|
async function patchCircuit(circuitId: string, key: CellKey, draft: string) {
|
||||||
const payload: Record<string, unknown> = {};
|
const payload: Record<string, unknown> = {};
|
||||||
if (key === "protectionSummary" || key === "cableSummary") {
|
if (key === "protectionSummary" || key === "cableSummary") {
|
||||||
@@ -1337,6 +1398,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
await updateCircuitById(circuitId, payload);
|
await updateCircuitById(circuitId, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Maps generic grid cell edits to device-row PATCH payload fields.
|
||||||
async function patchDeviceRow(rowId: string, key: CellKey, draft: string) {
|
async function patchDeviceRow(rowId: string, key: CellKey, draft: string) {
|
||||||
const payload: Record<string, unknown> = {};
|
const payload: Record<string, unknown> = {};
|
||||||
if (["quantity", "powerPerUnit", "simultaneityFactor", "cosPhi"].includes(key)) {
|
if (["quantity", "powerPerUnit", "simultaneityFactor", "cosPhi"].includes(key)) {
|
||||||
@@ -1362,6 +1424,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
await updateCircuitDeviceRowById(rowId, payload);
|
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(
|
async function createFromPlaceholder(
|
||||||
sectionId: string,
|
sectionId: string,
|
||||||
key: CellKey,
|
key: CellKey,
|
||||||
@@ -1401,6 +1465,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
return { rowKey: `reserveCircuit:${createdCircuit.id}`, cellKey: key };
|
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) {
|
function getCurrentDraftForCell(row: VisibleGridRow, cellKey: CellKey, kind: CellKind) {
|
||||||
if (kind === "deviceField" && row.device) {
|
if (kind === "deviceField" && row.device) {
|
||||||
return String(getDeviceValue(row.device, cellKey) ?? "");
|
return String(getDeviceValue(row.device, cellKey) ?? "");
|
||||||
@@ -1411,6 +1476,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Commits the active editor input through command history so edits remain undoable.
|
||||||
async function commitEdit(direction: SaveDirection = "stay") {
|
async function commitEdit(direction: SaveDirection = "stay") {
|
||||||
if (!editingCell) {
|
if (!editingCell) {
|
||||||
return;
|
return;
|
||||||
@@ -1438,6 +1504,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
return buildSelectionIntent(selected);
|
return buildSelectionIntent(selected);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Placeholder rows are not persisted entities; editing them materializes a real circuit first.
|
||||||
if (row.rowType === "placeholder") {
|
if (row.rowType === "placeholder") {
|
||||||
let createdCircuitId: string | null = null;
|
let createdCircuitId: string | null = null;
|
||||||
const command: HistoryCommand = {
|
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() {
|
function cancelEdit() {
|
||||||
if (!editingCell) {
|
if (!editingCell) {
|
||||||
return;
|
return;
|
||||||
@@ -1906,6 +1974,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
return null;
|
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) {
|
function getSelectedEligibleDeviceRowIds(primaryRowId?: string | null) {
|
||||||
const selectedSet = new Set(selectedRowKeys);
|
const selectedSet = new Set(selectedRowKeys);
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
@@ -1923,6 +1993,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
return primaryRowId ? [primaryRowId] : ids;
|
return primaryRowId ? [primaryRowId] : ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Converts current row selection to unique circuit ids for circuit block reorder.
|
||||||
function getSelectedEligibleCircuitIds(primaryCircuitId?: string | null) {
|
function getSelectedEligibleCircuitIds(primaryCircuitId?: string | null) {
|
||||||
const selectedSet = new Set(selectedRowKeys);
|
const selectedSet = new Set(selectedRowKeys);
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
@@ -1955,6 +2026,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Applies same-section circuit reorder as explicit id ordering.
|
||||||
|
// No implicit renumbering is performed here.
|
||||||
async function applyCircuitReorder(intent: CircuitReorderDropIntent, sourceCircuitIds: string[]) {
|
async function applyCircuitReorder(intent: CircuitReorderDropIntent, sourceCircuitIds: string[]) {
|
||||||
const section = data?.sections.find((entry) => entry.id === intent.sectionId);
|
const section = data?.sections.find((entry) => entry.id === intent.sectionId);
|
||||||
if (!section) {
|
if (!section) {
|
||||||
@@ -1981,6 +2054,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
await reorderSectionCircuits(section.id, nextIds);
|
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<HTMLElement>, intent: ProjectDeviceDropIntent) {
|
async function handleDropWithIntent(event: DragEvent<HTMLElement>, intent: ProjectDeviceDropIntent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -1998,6 +2073,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
setError("Invalid project device drop source.");
|
setError("Invalid project device drop source.");
|
||||||
return;
|
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") {
|
if (intent.kind === "new-circuit") {
|
||||||
let createdCircuitId: string | null = null;
|
let createdCircuitId: string | null = null;
|
||||||
await runCommand({
|
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<HTMLElement>, intent: DeviceRowMoveDropIntent) {
|
async function handleDeviceRowDropWithIntent(event: DragEvent<HTMLElement>, intent: DeviceRowMoveDropIntent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -2077,6 +2156,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
return;
|
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 (intent.kind === "move-to-circuit") {
|
||||||
if (rowIds.length === 1 && intent.circuitId === sourceCircuitId) {
|
if (rowIds.length === 1 && intent.circuitId === sourceCircuitId) {
|
||||||
return;
|
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<HTMLElement>, intent: CircuitReorderDropIntent) {
|
async function handleCircuitReorderDrop(event: DragEvent<HTMLElement>, intent: CircuitReorderDropIntent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
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) {
|
async function handleDeleteDevice(rowId: string) {
|
||||||
if (!confirm("Delete this device row?")) {
|
if (!confirm("Delete this device row?")) {
|
||||||
return;
|
return;
|
||||||
@@ -2216,6 +2299,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
undo: async () => {
|
undo: async () => {
|
||||||
|
// Recreate instead of "undelete": backend ids are immutable once deleted.
|
||||||
const created = (await createCircuitDeviceRow(sourceCircuitId, {
|
const created = (await createCircuitDeviceRow(sourceCircuitId, {
|
||||||
linkedProjectDeviceId: rowSnapshot.linkedProjectDeviceId,
|
linkedProjectDeviceId: rowSnapshot.linkedProjectDeviceId,
|
||||||
name: rowSnapshot.name,
|
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) {
|
async function handleDeleteCircuit(circuitId: string) {
|
||||||
if (!confirm("Delete this circuit and all assigned device rows?")) {
|
if (!confirm("Delete this circuit and all assigned device rows?")) {
|
||||||
return;
|
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) {
|
async function handleRenumberSection(sectionId: string) {
|
||||||
if (!confirm("Renumber this section? Only circuits in this section will change.")) {
|
if (!confirm("Renumber this section? Only circuits in this section will change.")) {
|
||||||
return;
|
return;
|
||||||
@@ -2285,6 +2371,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
undo: async () => {
|
undo: async () => {
|
||||||
|
// Restore prior BMKs through safe bulk endpoint to avoid transient unique collisions.
|
||||||
await updateSectionEquipmentIdentifiers(
|
await updateSectionEquipmentIdentifiers(
|
||||||
sectionId,
|
sectionId,
|
||||||
before.map((entry) => ({
|
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() {
|
function handleContainerFocus() {
|
||||||
if (editingCell) {
|
if (editingCell) {
|
||||||
const row = findRow(editingCell.rowKey);
|
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<HTMLDivElement>) {
|
function handleContainerKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||||
if (editingCell) {
|
if (editingCell) {
|
||||||
if (event.key === "Tab") {
|
if (event.key === "Tab") {
|
||||||
@@ -2389,6 +2481,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isPrintableKey(event) && selectedCell) {
|
if (isPrintableKey(event) && selectedCell) {
|
||||||
|
// First printable key should become draft content directly; do not pre-select old text.
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
startEdit(selectedCell, "replaceWithTypedChar", event.key);
|
startEdit(selectedCell, "replaceWithTypedChar", event.key);
|
||||||
}
|
}
|
||||||
@@ -2477,6 +2570,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isSortedView = Boolean(sortState);
|
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 hasActiveSortOrFilter = isSortedView || hasActiveFilters;
|
||||||
const draggingDeviceCount = draggingDeviceRowIds.length > 0 ? draggingDeviceRowIds.length : draggingDeviceRowId ? 1 : 0;
|
const draggingDeviceCount = draggingDeviceRowIds.length > 0 ? draggingDeviceRowIds.length : draggingDeviceRowId ? 1 : 0;
|
||||||
const activeDraggedCircuitIds =
|
const activeDraggedCircuitIds =
|
||||||
|
|||||||
Reference in New Issue
Block a user