From 9165b8238c2ed67228ee0af9db5556fdf77e434c Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Mon, 28 Sep 2026 22:14:39 +0200 Subject: [PATCH] Compact single-board feeder circuit rows --- AGENTS.md | 11 ++++ docs/circuit-list-editor-api.md | 11 +++- docs/current-architecture.md | 15 +++++ docs/print-view.md | 5 +- src/app/globals.css | 4 ++ .../components/circuit-tree-editor.tsx | 16 +++++- src/frontend/print/print-document.tsx | 19 +++++++ src/frontend/utils/circuit-grid-model.ts | 6 +- src/frontend/utils/circuit-grid-projection.ts | 47 ++++++++++++++- tests/circuit-grid-model.test.ts | 4 +- tests/circuit-grid-projection.test.ts | 57 +++++++++++++++++-- 11 files changed, 181 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9ef5b87..93e5108 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -476,6 +476,17 @@ downstream board's demand after its own board simultaneity factor to the chosen feeding circuit as a read-only virtual row. Recursive chains and several boards on one circuit are supported. A board-only link affects hierarchy but adds no load until a feeding circuit is selected. +The distribution-board dialog can create one empty feeder circuit and link it +in the same `distribution-board.create-feeder-circuit` project command. Its +history inverse restores the complete previous board update before removing +only the unchanged generated circuit; Redo reuses its UUID and BMK. No +CircuitDeviceRow is created. The three-phase group is suggested, the optional +circuit display name stays empty by default, and protection remains open for +planner sizing. A circuit with exactly one downstream board and no device rows +is rendered as one compact row using the live board number/name; several +downstream boards or additional devices retain summary and child rows. Editing +the name or remark of an empty reserve circuit changes the circuit itself and +does not implicitly create a device row. Distribution-board floor assignment and a project-enabled supply type use `distribution-board.update`; both values are snapshot/export fields and one persistent undo step. diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index a8173f5..de724aa 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -269,6 +269,13 @@ commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`. submitted info fields, are restored together by persistent Undo/Redo - `fedFrom` remains free text for external feeds such as HAK, metering, transformer or generator and for supplementary notes +- The project-command endpoint additionally accepts + `distribution-board.create-feeder-circuit` with an empty complete circuit + snapshot and an embedded version-2 `distribution-board.update`. Both writes + are one transaction, revision and Undo step. The generated circuit has no + device row; Redo keeps its UUID and BMK. The history-only inverse + `distribution-board.remove-feeder-circuit` restores the previous board values + and removes only the unchanged circuit. - `POST /projects/:projectId/distribution-boards/:distributionBoardId/copy` - body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }` - duplicates the complete distribution-board subtree with new owning UUIDs @@ -346,7 +353,9 @@ commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`. footer components. Circuit and component protection devices use their separate one-to-one DTOs. Calculated row, circuit, section and board totals remain included. Circuit totals contain the demand of linked downstream - boards. `downstreamBoardLoads` exposes those contributions as read-only rows; + boards. `downstreamBoardLoads` exposes those contributions as read-only + values; the grid combines exactly one downstream board without device rows + into the circuit row and keeps child rows for mixed or multiple loads. `feedingReference` resolves the current board/circuit labels by id. Response sketch: diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 920002d..71b7feb 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -477,6 +477,21 @@ Verweise nicht. Der speisende Stromkreis muss zur speisenden Verteilung gehören Selbstverweise und Zyklen werden abgelehnt. Löschbefehle für referenzierte Verteilungen und Stromkreise, einschließlich Unterbaum- und Undo-Pfaden, werden vor der Datenbankänderung fachlich gesperrt. +Im Bearbeiten-Dialog kann nach Auswahl der speisenden Verteilung direkt ein +neuer Einspeiseabgang geplant werden. Die Oberfläche schlägt die dreiphasige +Gruppe und das nächste BMK vor. Der Befehl +`distribution-board.create-feeder-circuit` legt den leeren Stromkreis an und +wendet die vollständige Verteilungsänderung in derselben Transaktion und +Revision an; es entsteht keine Gerätezeile. Schutz und Leitung bleiben bewusst +offen, weil sie aus Last und Verlegebedingungen geplant werden müssen. Undo +stellt zuerst die vorherigen Verteilungswerte wieder her und entfernt danach +nur den unveränderten erzeugten Stromkreis; Redo verwendet UUID und BMK erneut. +Bei genau einer gespeisten Unterverteilung ohne Gerätezeilen projiziert das +Grid nur eine kompakte Stromkreiszeile mit dem über die ID aufgelösten +Verteilernamen. Erst mehrere Unterverteilungen oder zusätzliche Geräte erzeugen +Sammel- und Unterzeilen. Anzeigename und Bemerkung eines leeren +Reservestromkreises gehören zum Stromkreis und erzeugen beim Bearbeiten keine +Gerätezeile. Die projektweite Circuit-Tree-Projektion berechnet rekursiv die Lasten aller Verteilungen. Die Leistung einer Unterverteilung nach ihrem eigenen Verteiler-GZF wird als virtuelle, schreibgeschützte Zeile genau dem gewählten diff --git a/docs/print-view.md b/docs/print-view.md index a843030..ecc00e2 100644 --- a/docs/print-view.md +++ b/docs/print-view.md @@ -38,8 +38,9 @@ automatisch bei späteren Änderungen in einem anderen Tab. 3. **Verteilerpläne (quer, je Verteiler)** – Titelleiste mit Verteilernummer, Bezeichnung, Örtlichkeit, Netzart, „Gespeist von“ und Pgl; darunter Kopfkomponenten, Gruppen, Stromkreise - (mehrzeilige mit Sammelzeile, eingerückten Gerätezeilen und - schreibgeschützten Unterverteilungszeilen), + (reine 1:1-Einspeiseabgänge kompakt in einer Zeile; sonst mehrzeilig mit + Sammelzeile, eingerückten Gerätezeilen und schreibgeschützten + Unterverteilungszeilen), Gruppensummen und Verteilersumme. Fehlende Kabelangaben sind als „offen“ markiert. Die Titelleiste steht im Tabellenkopf und wiederholt sich beim Drucken auf Folgeseiten. diff --git a/src/app/globals.css b/src/app/globals.css index d14431b..88df3e9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1537,6 +1537,10 @@ a.kpi:hover { background: var(--panel-bg-subtle); } +.tree-grid .feeder-circuit-row td:nth-child(2) { + font-weight: 600; +} + .tree-grid .device-row td:first-child { color: var(--text-faint); } diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index bac08ce..f75b64e 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -1766,7 +1766,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const currentDisplay = mode === "replaceWithTypedChar" && cell.cellKey !== "phaseType" ? typedChar ?? "" - : String(visibleCell.value ?? ""); + : String(visibleCell.editValue ?? visibleCell.value ?? ""); focusTokenRef.current += 1; setEditingCell({ ...cell, @@ -4077,7 +4077,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str key={row.rowKey} className={`${ row.rowType === "circuitSummary" - ? "summary-row" + ? row.downstreamBoardLoad + ? "feeder-circuit-row" + : "summary-row" : row.rowType === "deviceRow" ? "device-row" : row.rowType === "downstreamBoardLoad" @@ -4708,11 +4710,19 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str tabIndex={-1} /> 0 + } icon={Trash2} label="Stromkreis löschen" onClick={() => void handleDeleteCircuit(row.circuit!.id)} tabIndex={-1} + title={ + (row.circuit.downstreamBoardLoads?.length ?? 0) > 0 + ? "Speist eine Unterverteilung – zuerst deren Einspeisung ändern" + : undefined + } variant="danger" /> diff --git a/src/frontend/print/print-document.tsx b/src/frontend/print/print-document.tsx index 5a0152b..a13d7e1 100644 --- a/src/frontend/print/print-document.tsx +++ b/src/frontend/print/print-document.tsx @@ -431,6 +431,25 @@ function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) { ); } + if (rows.length === 0 && downstreamBoards.length === 1) { + const boardLabel = + downstreamBoards[0].boardNumber ?? downstreamBoards[0].name; + return ( + + {circuit.equipmentIdentifier} + + {boardLabel} + {circuit.displayName ? ( + · {circuit.displayName} + ) : null} + + – + + {formatKilowatt(circuit.circuitTotalPower)} + {circuitCells} + + ); + } if (rows.length === 1 && downstreamBoards.length === 0) { const row = rows[0]; const title = circuit.displayName ?? row.displayName; diff --git a/src/frontend/utils/circuit-grid-model.ts b/src/frontend/utils/circuit-grid-model.ts index faa4a11..fafeca4 100644 --- a/src/frontend/utils/circuit-grid-model.ts +++ b/src/frontend/utils/circuit-grid-model.ts @@ -617,7 +617,11 @@ export function getCellKind(rowType: RowType, key: CellKey): CellKind { if (rowType === "deviceRow") return deviceFieldKeys.has(key) ? "deviceField" : "readonly"; if (rowType === "downstreamBoardLoad") return "readonly"; if (rowType === "circuitSummary") return circuitFieldKeys.has(key) ? "circuitField" : "readonly"; - if (rowType === "reserveCircuit" || rowType === "circuitCompact") { + if (rowType === "reserveCircuit") { + if (circuitFieldKeys.has(key)) return "circuitField"; + if (deviceFieldKeys.has(key)) return "deviceField"; + } + if (rowType === "circuitCompact") { if (deviceFieldKeys.has(key)) return "deviceField"; if (circuitFieldKeys.has(key)) return "circuitField"; } diff --git a/src/frontend/utils/circuit-grid-projection.ts b/src/frontend/utils/circuit-grid-projection.ts index a9b11de..30e5ecf 100644 --- a/src/frontend/utils/circuit-grid-projection.ts +++ b/src/frontend/utils/circuit-grid-projection.ts @@ -37,6 +37,8 @@ export interface VisibleGridCell { editable: boolean; kind: CellKind; value: GridValue; + /** Separate persisted value when the displayed value is derived. */ + editValue?: GridValue; } export interface VisibleGridRow { @@ -144,6 +146,7 @@ function makeVisibleGridRow( const kind = getCellKind(rowType, column.key); const editable = kind === "circuitField" || kind === "deviceField"; let value: GridValue; + let editValue: GridValue | undefined; if (rowType === "downstreamBoardLoad" && downstreamBoardLoad) { value = column.key === "displayName" @@ -155,6 +158,35 @@ function makeVisibleGridRow( : column.key === "remark" ? "Automatisch aus der verknüpften Verteilung" : undefined; + } else if ( + rowType === "circuitSummary" && + circuit && + downstreamBoardLoad && + circuit.deviceRows.length === 0 + ) { + if (column.key === "displayName") { + const boardLabel = + downstreamBoardLoad.boardNumber ?? downstreamBoardLoad.name; + value = circuit.displayName + ? `${boardLabel} · ${circuit.displayName}` + : boardLabel; + editValue = circuit.displayName ?? ""; + } else if (column.key === "cosPhi") { + value = circuit.circuitCosPhi; + } else if ( + kind === "computed" && + circuitOnlyColumns.has(column.key) + ) { + value = getCircuitValue(circuit, column.key); + } else if (kind === "circuitField") { + value = getCircuitValue(circuit, column.key); + } else if (column.key === "rowTotalPower") { + value = circuit.circuitTotalPower; + } else if (column.key === "circuitTotalPower") { + value = circuit.circuitTotalPower; + } else if (column.key === "voltage") { + value = circuit.voltage; + } } else if (rowType === "placeholder") { value = column.key === "equipmentIdentifier" ? "-frei-" : undefined; } else if (kind === "deviceField" && device) { @@ -178,7 +210,7 @@ function makeVisibleGridRow( value = circuit.voltage; } - return { cellKey: column.key, editable, kind, value }; + return { cellKey: column.key, editable, kind, value, editValue }; }); return { @@ -247,6 +279,19 @@ export function buildVisibleGridRowsWithStructure( const downstreamLoads = circuit.downstreamBoardLoads ?? []; if (circuit.deviceRows.length === 0 && downstreamLoads.length === 0) { rows.push(makeVisibleGridRow("reserveCircuit", section.id, circuit)); + } else if ( + circuit.deviceRows.length === 0 && + downstreamLoads.length === 1 + ) { + rows.push( + makeVisibleGridRow( + "circuitSummary", + section.id, + circuit, + undefined, + downstreamLoads[0] + ) + ); } else if (circuit.deviceRows.length === 1 && downstreamLoads.length === 0) { rows.push(makeVisibleGridRow("circuitCompact", section.id, circuit, circuit.deviceRows[0])); } else { diff --git a/tests/circuit-grid-model.test.ts b/tests/circuit-grid-model.test.ts index 641a770..475d781 100644 --- a/tests/circuit-grid-model.test.ts +++ b/tests/circuit-grid-model.test.ts @@ -133,7 +133,9 @@ describe("circuit grid model", () => { assert.equal(getCellKind("circuitCompact", "equipmentIdentifier"), "circuitField"); assert.equal(getCellKind("circuitSummary", "displayName"), "circuitField"); assert.equal(getCellKind("deviceRow", "displayName"), "deviceField"); - assert.equal(getCellKind("reserveCircuit", "remark"), "deviceField"); + assert.equal(getCellKind("reserveCircuit", "displayName"), "circuitField"); + assert.equal(getCellKind("reserveCircuit", "remark"), "circuitField"); + assert.equal(getCellKind("reserveCircuit", "powerPerUnit"), "deviceField"); assert.equal(getCellKind("placeholder", "displayName"), "deviceField"); assert.equal(getCellKind("deviceRow", "equipmentIdentifier"), "readonly"); assert.equal(getCellKind("circuitSummary", "controlRequirement"), "circuitField"); diff --git a/tests/circuit-grid-projection.test.ts b/tests/circuit-grid-projection.test.ts index 1a16913..6551aee 100644 --- a/tests/circuit-grid-projection.test.ts +++ b/tests/circuit-grid-projection.test.ts @@ -179,9 +179,10 @@ describe("circuit grid projection", () => { ); }); - it("renders linked downstream boards as separate read-only rows", () => { + it("renders one linked downstream board as one compact editable circuit row", () => { const feeder = { ...reserveCircuit, + displayName: undefined, isReserve: false, circuitTotalPower: 4, circuitApparentPower: 5, @@ -202,19 +203,65 @@ describe("circuit grid projection", () => { assert.deepEqual(rows.map((row) => row.rowType), [ "section", "circuitSummary", - "downstreamBoardLoad", "placeholder", ]); - const loadRow = rows[2]; + const loadRow = rows[1]; assert.equal( loadRow.cells.find((cell) => cell.cellKey === "displayName")?.value, - "Unterverteilung UV-02" + "UV-02" + ); + assert.equal( + loadRow.cells.find((cell) => cell.cellKey === "displayName")?.editValue, + "" ); assert.equal( loadRow.cells.find((cell) => cell.cellKey === "rowTotalPower")?.value, 4 ); - assert.equal(loadRow.cells.every((cell) => !cell.editable), true); + assert.equal( + loadRow.cells.find((cell) => cell.cellKey === "displayName")?.kind, + "circuitField" + ); + }); + + it("keeps separate read-only rows for several linked downstream boards", () => { + const feeder = { + ...reserveCircuit, + isReserve: false, + circuitTotalPower: 6, + circuitApparentPower: 7, + circuitCosPhi: 6 / 7, + downstreamBoardLoads: [ + { + distributionBoardId: "board-a", + name: "Unterverteilung A", + boardNumber: "UV-A", + activePowerKw: 4, + apparentPowerKva: 5, + cosPhi: 0.8, + }, + { + distributionBoardId: "board-b", + name: "Unterverteilung B", + boardNumber: "UV-B", + activePowerKw: 2, + apparentPowerKva: 2, + cosPhi: 1, + }, + ], + }; + const rows = buildVisibleGridRowsWithStructure( + [{ ...sections[0], circuits: [feeder] }], + { headerComponents: [], footerComponents: [] } + ); + assert.deepEqual(rows.map((row) => row.rowType), [ + "section", + "circuitSummary", + "downstreamBoardLoad", + "downstreamBoardLoad", + "placeholder", + ]); + assert.equal(rows[2].cells.every((cell) => !cell.editable), true); }); it("shows circuit protection once on each visible circuit row", () => {