From 31589875b592e8fe4823a87758e6da5e06854496 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 31 Jul 2026 07:22:26 +0200 Subject: [PATCH] Render protected circuit structure --- docs/current-architecture.md | 12 ++- docs/spec/08-current-product-backlog.md | 2 +- ...-board-components-and-protection-groups.md | 11 ++- src/app/globals.css | 34 +++++++++ .../components/circuit-tree-editor.tsx | 62 +++++++++++++-- src/frontend/utils/circuit-grid-insertion.ts | 10 +++ src/frontend/utils/circuit-grid-model.ts | 5 +- src/frontend/utils/circuit-grid-projection.ts | 76 +++++++++++++++---- .../utils/circuit-structure-projection.ts | 2 +- tests/circuit-grid-insertion.test.ts | 8 ++ tests/circuit-grid-projection.test.ts | 61 +++++++++++++++ 11 files changed, 254 insertions(+), 29 deletions(-) diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 0534219..f3956cd 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -355,6 +355,11 @@ rein und deterministisch in die drei Zonen Kopf, vollständige Gruppen und Fuß. Innerhalb einer Gruppe folgen auf die Überschrift optionale Schutzkomponenten, vollständige Stromkreisblöcke und der freie Einfügeplatz. Leere Gruppen bleiben sichtbar; Komponenten werden stabil nach `sortOrder` und ID sortiert. +`circuit-grid-projection.ts` normalisiert diese Struktur zusammen mit den +bisherigen kompakten und mehrzeiligen Stromkreisblöcken für den Editor. Kopf-, +Gruppen- und Fußkomponenten erscheinen dort zunächst als schreibgeschützte +Zeilen über die volle Tabellenbreite und nehmen nicht an Zellauswahl, +Tastatur-Einfügen oder Löschen teil. `distribution-board.update` versioniert Etage, Netzart und den verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und @@ -426,9 +431,10 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät. stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden Snapshot-Schema 7. Persistente Insert/Delete/Update-Commands für - veränderliche Verteilerkomponenten sowie CRUD-Commands für leere Gruppen - sowie vollständige Gruppensortierung sind integriert; befüllte Unterbäume - und UI folgen in abgegrenzten Arbeitspaketen. + veränderliche Verteilerkomponenten, Gruppen einschließlich befüllter + Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor + zeigt die geschützte Struktur bereits schreibgeschützt an; dedizierte + Bearbeitungsfunktionen folgen in einem abgegrenzten Arbeitspaket. PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen diff --git a/docs/spec/08-current-product-backlog.md b/docs/spec/08-current-product-backlog.md index 4614858..33751af 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -33,7 +33,7 @@ requirements and intended sequencing, not proof of implementation. - [x] Phase D3b: persistent populated-group delete/restore command. - [x] Phase E1: tree read model for components, groups and protection devices. - [x] Phase E2a: pure header/group/footer structure projection. -- [ ] Phase E2b: render the structure projection in the editor grid. +- [x] Phase E2b: render the structure projection in the editor grid. - [ ] Phase E: editor projection and editing. - [ ] Phase F: documentation and full GUI verification. - [ ] Keep full electrical sizing and cable-dimensioning rules separate until diff --git a/docs/spec/09-distribution-board-components-and-protection-groups.md b/docs/spec/09-distribution-board-components-and-protection-groups.md index 5bdbd87..f1a8772 100644 --- a/docs/spec/09-distribution-board-components-and-protection-groups.md +++ b/docs/spec/09-distribution-board-components-and-protection-groups.md @@ -739,8 +739,9 @@ Acceptance: ### E. Editor Projection and Editing -Status: In progress. The complete tree read model E1 and pure structural -projection E2a are complete. Grid rendering and editing remain pending. +Status: In progress. The complete tree read model E1, pure structural +projection E2a and read-only grid rendering E2b are complete. Editing remains +pending. - render fixed header components - render group components and circuit blocks @@ -749,7 +750,7 @@ projection E2a are complete. Grid rendering and editing remain pending. - add type-dependent protection editing - keep spreadsheet selection, keyboard and drag behavior coherent -Implemented in E1/E2a: +Implemented in E1/E2a/E2b: - the tree response exposes fixed header, group and auxiliary footer components plus both one-to-one protection layers @@ -759,6 +760,10 @@ Implemented in E1/E2a: circuit blocks and free placeholder together - empty groups remain visible and circuit device rows remain owned by their circuit block +- the editor renders fixed header, group protection and auxiliary footer + components as read-only full-width rows +- structural component rows are excluded from spreadsheet cell navigation, + insertion and deletion commands until their dedicated editing controls exist Acceptance: diff --git a/src/app/globals.css b/src/app/globals.css index 351a429..9fe85c5 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -657,6 +657,40 @@ body { font-weight: 600; } +.tree-grid .structure-component-row td { + padding: 0.38rem 0.6rem; + border-bottom-color: #d8dee9; +} + +.tree-grid .structure-component-row.headerComponent td { + background: #e2e8f0; +} + +.tree-grid .structure-component-row.groupComponent td { + background: #f8fafc; +} + +.tree-grid .structure-component-row.footerComponent td { + background: #f1f5f9; +} + +.tree-grid .structure-component-content { + display: flex; + align-items: baseline; + gap: 0.75rem; + min-width: 0; +} + +.tree-grid .structure-component-content strong { + flex: 0 0 auto; + font-variant-numeric: tabular-nums; +} + +.tree-grid .structure-component-protection { + color: #475569; + font-size: 0.78rem; +} + .tree-grid .section-drop-cell { padding: 0.45rem 0.6rem; } diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 469e4b2..2ed122a 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -11,6 +11,7 @@ import { } from "../../domain/services/project-voltage.service"; import { getInsertionSortOrder, + isGridInsertionRowType, resolveGridInsertionIntent, } from "../utils/circuit-grid-insertion"; import { @@ -53,7 +54,7 @@ import type { RowType, } from "../utils/circuit-grid-model"; import { - buildVisibleGridRows, + buildVisibleGridRowsWithStructure, filterAndSortCircuitSections, getDistinctFilterValues, } from "../utils/circuit-grid-projection"; @@ -386,8 +387,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str // 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(() => { - return buildVisibleGridRows(filteredSortedSections); - }, [filteredSortedSections]); + return buildVisibleGridRowsWithStructure(filteredSortedSections, { + headerComponents: data?.headerComponents ?? [], + footerComponents: data?.footerComponents ?? [], + }); + }, [data, filteredSortedSections]); function clearColumnFilter(key: CellKey) { setColumnFilters((current) => { @@ -1660,7 +1664,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const row = selectedCell ? findRow(selectedCell.rowKey) : null; const cell = row?.cells.find((entry) => entry.cellKey === selectedCell?.cellKey); const selection = - row && cell && row.rowType !== "section" + row && cell && isGridInsertionRowType(row.rowType) ? { rowType: row.rowType, cellKind: cell.kind, @@ -2351,7 +2355,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const row = selectedCell ? findRow(selectedCell.rowKey) : null; const cell = row?.cells.find((entry) => entry.cellKey === selectedCell?.cellKey); const intent = resolveGridDeleteIntent( - row && cell && row.rowType !== "section" + row && cell && isGridInsertionRowType(row.rowType) ? { rowType: row.rowType, cellKind: cell.kind, @@ -2986,6 +2990,54 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str {visibleRows.map((row) => { + if ( + row.rowType === "headerComponent" || + row.rowType === "groupComponent" || + row.rowType === "footerComponent" + ) { + const component = row.component!; + const protection = component.protectionDevice; + const protectionSummary = protection + ? [ + protection.type, + `${formatValue( + protection.ratedCurrentA, + "protectionRatedCurrent" + )} A`, + protection.fuseUtilizationCategory, + protection.tripCharacteristic, + protection.rcdType + ? `Typ ${protection.rcdType}` + : undefined, + protection.ratedResidualCurrentMa !== undefined + ? `${formatValue( + protection.ratedResidualCurrentMa, + "protectionRatedCurrent" + )} mA` + : undefined, + ] + .filter(Boolean) + .join(" · ") + : null; + return ( + + +
+ {component.equipmentIdentifier} + {component.name} + {protectionSummary ? ( + + {protectionSummary} + + ) : null} +
+ + + ); + } if (row.rowType === "section") { const section = data.sections.find((entry) => entry.id === row.sectionId)!; return ( diff --git a/src/frontend/utils/circuit-grid-insertion.ts b/src/frontend/utils/circuit-grid-insertion.ts index ccb73ad..b207193 100644 --- a/src/frontend/utils/circuit-grid-insertion.ts +++ b/src/frontend/utils/circuit-grid-insertion.ts @@ -7,6 +7,16 @@ export type GridInsertionRowType = export type GridInsertionCellKind = "circuitField" | "deviceField" | "readonly" | "computed"; +export function isGridInsertionRowType(rowType: string): rowType is GridInsertionRowType { + return ( + rowType === "circuitCompact" || + rowType === "circuitSummary" || + rowType === "deviceRow" || + rowType === "reserveCircuit" || + rowType === "placeholder" + ); +} + export interface GridInsertionSelection { rowType: GridInsertionRowType; cellKind: GridInsertionCellKind; diff --git a/src/frontend/utils/circuit-grid-model.ts b/src/frontend/utils/circuit-grid-model.ts index b922eb2..e96abef 100644 --- a/src/frontend/utils/circuit-grid-model.ts +++ b/src/frontend/utils/circuit-grid-model.ts @@ -37,12 +37,15 @@ export type CellKey = | "isReserve"; export type RowType = + | "headerComponent" | "section" + | "groupComponent" | "circuitCompact" | "circuitSummary" | "deviceRow" | "reserveCircuit" - | "placeholder"; + | "placeholder" + | "footerComponent"; export type CellKind = "circuitField" | "deviceField" | "computed" | "readonly"; export type GridValue = string | number | boolean | undefined; diff --git a/src/frontend/utils/circuit-grid-projection.ts b/src/frontend/utils/circuit-grid-projection.ts index 4eca978..ec28259 100644 --- a/src/frontend/utils/circuit-grid-projection.ts +++ b/src/frontend/utils/circuit-grid-projection.ts @@ -1,8 +1,10 @@ import type { CircuitTreeCircuitDto, + CircuitTreeComponentDto, CircuitTreeDeviceRowDto, CircuitTreeSectionDto, } from "../types.js"; +import { buildCircuitStructureProjection } from "./circuit-structure-projection"; import { allColumns, circuitOnlyColumns, @@ -42,6 +44,7 @@ export interface VisibleGridRow { sectionId: string; circuit?: CircuitTreeCircuitDto; device?: CircuitTreeDeviceRowDto; + component?: CircuitTreeComponentDto; cells: VisibleGridCell[]; } @@ -160,21 +163,64 @@ function makeVisibleGridRow( } export function buildVisibleGridRows(sections: readonly CircuitTreeSectionDto[]): VisibleGridRow[] { - const rows: VisibleGridRow[] = []; - for (const section of sections) { - rows.push({ - rowKey: `section:${section.id}`, - rowType: "section", - sectionId: section.id, - cells: allColumns.map((column) => ({ - cellKey: column.key, - editable: false, - kind: "readonly", - value: undefined, - })), - }); + return buildVisibleGridRowsWithStructure(sections, { + headerComponents: [], + footerComponents: [], + }); +} - for (const circuit of section.circuits) { +export function buildVisibleGridRowsWithStructure( + sections: readonly CircuitTreeSectionDto[], + structure: { + headerComponents: readonly CircuitTreeComponentDto[]; + footerComponents: readonly CircuitTreeComponentDto[]; + } +): VisibleGridRow[] { + const rows: VisibleGridRow[] = []; + const projected = buildCircuitStructureProjection({ + headerComponents: [...structure.headerComponents], + sections: [...sections], + footerComponents: [...structure.footerComponents], + }); + for (const row of projected) { + if ("component" in row) { + const component = row.component; + rows.push({ + rowKey: row.rowKey, + rowType: row.rowType, + sectionId: component.sectionId ?? `__${row.zone}__`, + component, + cells: allColumns.map((column) => ({ + cellKey: column.key, + editable: false, + kind: "readonly", + value: + column.key === "equipmentIdentifier" + ? component.equipmentIdentifier + : column.key === "displayName" + ? component.name + : undefined, + })), + }); + continue; + } + const section = row.section; + if (row.rowType === "groupHeader") { + rows.push({ + rowKey: `section:${section.id}`, + rowType: "section", + sectionId: section.id, + cells: allColumns.map((column) => ({ + cellKey: column.key, + editable: false, + kind: "readonly", + value: undefined, + })), + }); + continue; + } + if (row.rowType === "circuitBlock") { + const circuit = row.circuit; if (circuit.deviceRows.length === 0) { rows.push(makeVisibleGridRow("reserveCircuit", section.id, circuit)); } else if (circuit.deviceRows.length === 1) { @@ -185,8 +231,8 @@ export function buildVisibleGridRows(sections: readonly CircuitTreeSectionDto[]) rows.push(makeVisibleGridRow("deviceRow", section.id, circuit, device)); } } + continue; } - rows.push(makeVisibleGridRow("placeholder", section.id)); } return rows; diff --git a/src/frontend/utils/circuit-structure-projection.ts b/src/frontend/utils/circuit-structure-projection.ts index 9c2f15d..a667541 100644 --- a/src/frontend/utils/circuit-structure-projection.ts +++ b/src/frontend/utils/circuit-structure-projection.ts @@ -55,7 +55,7 @@ export function buildCircuitStructureProjection( zone: "group", section, }); - for (const component of ordered(section.components)) { + for (const component of ordered(section.components ?? [])) { rows.push({ rowKey: `group-component:${component.id}`, rowType: "groupComponent", diff --git a/tests/circuit-grid-insertion.test.ts b/tests/circuit-grid-insertion.test.ts index d4cf108..c2260f8 100644 --- a/tests/circuit-grid-insertion.test.ts +++ b/tests/circuit-grid-insertion.test.ts @@ -2,10 +2,18 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { getInsertionSortOrder, + isGridInsertionRowType, resolveGridInsertionIntent, } from "../src/frontend/utils/circuit-grid-insertion.js"; describe("circuit grid insertion", () => { + it("excludes structural rows from insertion and deletion commands", () => { + assert.equal(isGridInsertionRowType("headerComponent"), false); + assert.equal(isGridInsertionRowType("groupComponent"), false); + assert.equal(isGridInsertionRowType("footerComponent"), false); + assert.equal(isGridInsertionRowType("circuitCompact"), true); + }); + it("inserts a circuit after circuit-level selections", () => { assert.deepEqual( resolveGridInsertionIntent( diff --git a/tests/circuit-grid-projection.test.ts b/tests/circuit-grid-projection.test.ts index ad241e0..c5fdfdb 100644 --- a/tests/circuit-grid-projection.test.ts +++ b/tests/circuit-grid-projection.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { buildVisibleGridRows, + buildVisibleGridRowsWithStructure, filterAndSortCircuitSections, getDistinctFilterValues, } from "../src/frontend/utils/circuit-grid-projection.js"; @@ -176,6 +177,66 @@ describe("circuit grid projection", () => { ); }); + it("normalizes structural component rows around complete circuit groups", () => { + const component = { + circuitListId: "list-1", + equipmentIdentifier: "-Q0", + name: "Hauptschalter", + role: "main_switch" as const, + placement: "header" as const, + sortOrder: 10, + }; + const groupedSections = sections.map((section) => ({ + ...section, + components: [ + { + ...component, + id: "group-rcd", + sectionId: section.id, + equipmentIdentifier: "-1Q1.0", + name: "Gruppen-FI", + role: "group_residual_current_protection" as const, + placement: "group" as const, + }, + ], + })); + const rows = buildVisibleGridRowsWithStructure(groupedSections, { + headerComponents: [{ ...component, id: "main" }], + footerComponents: [ + { + ...component, + id: "actor", + equipmentIdentifier: "-K1", + name: "KNX Aktor", + role: "auxiliary", + placement: "footer", + }, + ], + }); + + assert.deepEqual( + rows.map((row) => row.rowType), + [ + "headerComponent", + "section", + "groupComponent", + "circuitCompact", + "circuitSummary", + "deviceRow", + "deviceRow", + "reserveCircuit", + "placeholder", + "footerComponent", + ] + ); + assert.equal( + rows.find((row) => row.rowType === "groupComponent")?.cells.some( + (cell) => cell.editable + ), + false + ); + }); + it("collects filter options from both circuit and device values", () => { const values = getDistinctFilterValues(sections, [ { key: "displayName", label: "Display name" },