Compact single-board feeder circuit rows

This commit is contained in:
Julian Appel 2026-09-28 22:14:39 +02:00
parent ade21fe386
commit 9165b8238c
11 changed files with 181 additions and 14 deletions

View file

@ -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 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 on one circuit are supported. A board-only link affects hierarchy but adds no
load until a feeding circuit is selected. 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 floor assignment and a project-enabled supply type use
`distribution-board.update`; both values are snapshot/export fields and one `distribution-board.update`; both values are snapshot/export fields and one
persistent undo step. persistent undo step.

View file

@ -269,6 +269,13 @@ commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`.
submitted info fields, are restored together by persistent Undo/Redo submitted info fields, are restored together by persistent Undo/Redo
- `fedFrom` remains free text for external feeds such as HAK, metering, - `fedFrom` remains free text for external feeds such as HAK, metering,
transformer or generator and for supplementary notes 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` - `POST /projects/:projectId/distribution-boards/:distributionBoardId/copy`
- body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }` - body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }`
- duplicates the complete distribution-board subtree with new owning UUIDs - 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 footer components. Circuit and component protection devices use their
separate one-to-one DTOs. Calculated row, circuit, section and board totals separate one-to-one DTOs. Calculated row, circuit, section and board totals
remain included. Circuit totals contain the demand of linked downstream 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. `feedingReference` resolves the current board/circuit labels by id.
Response sketch: Response sketch:

View file

@ -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 Selbstverweise und Zyklen werden abgelehnt. Löschbefehle für referenzierte
Verteilungen und Stromkreise, einschließlich Unterbaum- und Undo-Pfaden, werden Verteilungen und Stromkreise, einschließlich Unterbaum- und Undo-Pfaden, werden
vor der Datenbankänderung fachlich gesperrt. 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 Die projektweite Circuit-Tree-Projektion berechnet rekursiv die Lasten aller
Verteilungen. Die Leistung einer Unterverteilung nach ihrem eigenen Verteilungen. Die Leistung einer Unterverteilung nach ihrem eigenen
Verteiler-GZF wird als virtuelle, schreibgeschützte Zeile genau dem gewählten Verteiler-GZF wird als virtuelle, schreibgeschützte Zeile genau dem gewählten

View file

@ -38,8 +38,9 @@ automatisch bei späteren Änderungen in einem anderen Tab.
3. **Verteilerpläne (quer, je Verteiler)** – Titelleiste mit 3. **Verteilerpläne (quer, je Verteiler)** – Titelleiste mit
Verteilernummer, Bezeichnung, Örtlichkeit, Netzart, „Gespeist von“ und Verteilernummer, Bezeichnung, Örtlichkeit, Netzart, „Gespeist von“ und
P<sub>gl</sub>; darunter Kopfkomponenten, Gruppen, Stromkreise P<sub>gl</sub>; darunter Kopfkomponenten, Gruppen, Stromkreise
(mehrzeilige mit Sammelzeile, eingerückten Gerätezeilen und (reine 1:1-Einspeiseabgänge kompakt in einer Zeile; sonst mehrzeilig mit
schreibgeschützten Unterverteilungszeilen), Sammelzeile, eingerückten Gerätezeilen und schreibgeschützten
Unterverteilungszeilen),
Gruppensummen und Verteilersumme. Fehlende Kabelangaben sind als „offen“ Gruppensummen und Verteilersumme. Fehlende Kabelangaben sind als „offen“
markiert. Die Titelleiste steht im Tabellenkopf und wiederholt sich beim markiert. Die Titelleiste steht im Tabellenkopf und wiederholt sich beim
Drucken auf Folgeseiten. Drucken auf Folgeseiten.

View file

@ -1537,6 +1537,10 @@ a.kpi:hover {
background: var(--panel-bg-subtle); background: var(--panel-bg-subtle);
} }
.tree-grid .feeder-circuit-row td:nth-child(2) {
font-weight: 600;
}
.tree-grid .device-row td:first-child { .tree-grid .device-row td:first-child {
color: var(--text-faint); color: var(--text-faint);
} }

View file

@ -1766,7 +1766,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const currentDisplay = const currentDisplay =
mode === "replaceWithTypedChar" && cell.cellKey !== "phaseType" mode === "replaceWithTypedChar" && cell.cellKey !== "phaseType"
? typedChar ?? "" ? typedChar ?? ""
: String(visibleCell.value ?? ""); : String(visibleCell.editValue ?? visibleCell.value ?? "");
focusTokenRef.current += 1; focusTokenRef.current += 1;
setEditingCell({ setEditingCell({
...cell, ...cell,
@ -4077,7 +4077,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
key={row.rowKey} key={row.rowKey}
className={`${ className={`${
row.rowType === "circuitSummary" row.rowType === "circuitSummary"
? "summary-row" ? row.downstreamBoardLoad
? "feeder-circuit-row"
: "summary-row"
: row.rowType === "deviceRow" : row.rowType === "deviceRow"
? "device-row" ? "device-row"
: row.rowType === "downstreamBoardLoad" : row.rowType === "downstreamBoardLoad"
@ -4708,11 +4710,19 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
tabIndex={-1} tabIndex={-1}
/> />
<IconButton <IconButton
disabled={isSaving} disabled={
isSaving ||
(row.circuit.downstreamBoardLoads?.length ?? 0) > 0
}
icon={Trash2} icon={Trash2}
label="Stromkreis löschen" label="Stromkreis löschen"
onClick={() => void handleDeleteCircuit(row.circuit!.id)} onClick={() => void handleDeleteCircuit(row.circuit!.id)}
tabIndex={-1} tabIndex={-1}
title={
(row.circuit.downstreamBoardLoads?.length ?? 0) > 0
? "Speist eine Unterverteilung – zuerst deren Einspeisung ändern"
: undefined
}
variant="danger" variant="danger"
/> />
</> </>

View file

@ -431,6 +431,25 @@ function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
</tr> </tr>
); );
} }
if (rows.length === 0 && downstreamBoards.length === 1) {
const boardLabel =
downstreamBoards[0].boardNumber ?? downstreamBoards[0].name;
return (
<tr className="pd-circuit">
<td className="pd-bmk">{circuit.equipmentIdentifier}</td>
<td>
{boardLabel}
{circuit.displayName ? (
<span className="pd-sub"> · {circuit.displayName}</span>
) : null}
</td>
<td>–</td>
<td /><td /><td />
<td className="pd-r"><strong>{formatKilowatt(circuit.circuitTotalPower)}</strong></td>
{circuitCells}
</tr>
);
}
if (rows.length === 1 && downstreamBoards.length === 0) { if (rows.length === 1 && downstreamBoards.length === 0) {
const row = rows[0]; const row = rows[0];
const title = circuit.displayName ?? row.displayName; const title = circuit.displayName ?? row.displayName;

View file

@ -617,7 +617,11 @@ export function getCellKind(rowType: RowType, key: CellKey): CellKind {
if (rowType === "deviceRow") return deviceFieldKeys.has(key) ? "deviceField" : "readonly"; if (rowType === "deviceRow") return deviceFieldKeys.has(key) ? "deviceField" : "readonly";
if (rowType === "downstreamBoardLoad") return "readonly"; if (rowType === "downstreamBoardLoad") return "readonly";
if (rowType === "circuitSummary") return circuitFieldKeys.has(key) ? "circuitField" : "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 (deviceFieldKeys.has(key)) return "deviceField";
if (circuitFieldKeys.has(key)) return "circuitField"; if (circuitFieldKeys.has(key)) return "circuitField";
} }

View file

@ -37,6 +37,8 @@ export interface VisibleGridCell {
editable: boolean; editable: boolean;
kind: CellKind; kind: CellKind;
value: GridValue; value: GridValue;
/** Separate persisted value when the displayed value is derived. */
editValue?: GridValue;
} }
export interface VisibleGridRow { export interface VisibleGridRow {
@ -144,6 +146,7 @@ function makeVisibleGridRow(
const kind = getCellKind(rowType, column.key); const kind = getCellKind(rowType, column.key);
const editable = kind === "circuitField" || kind === "deviceField"; const editable = kind === "circuitField" || kind === "deviceField";
let value: GridValue; let value: GridValue;
let editValue: GridValue | undefined;
if (rowType === "downstreamBoardLoad" && downstreamBoardLoad) { if (rowType === "downstreamBoardLoad" && downstreamBoardLoad) {
value = column.key === "displayName" value = column.key === "displayName"
@ -155,6 +158,35 @@ function makeVisibleGridRow(
: column.key === "remark" : column.key === "remark"
? "Automatisch aus der verknüpften Verteilung" ? "Automatisch aus der verknüpften Verteilung"
: undefined; : 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") { } else if (rowType === "placeholder") {
value = column.key === "equipmentIdentifier" ? "-frei-" : undefined; value = column.key === "equipmentIdentifier" ? "-frei-" : undefined;
} else if (kind === "deviceField" && device) { } else if (kind === "deviceField" && device) {
@ -178,7 +210,7 @@ function makeVisibleGridRow(
value = circuit.voltage; value = circuit.voltage;
} }
return { cellKey: column.key, editable, kind, value }; return { cellKey: column.key, editable, kind, value, editValue };
}); });
return { return {
@ -247,6 +279,19 @@ export function buildVisibleGridRowsWithStructure(
const downstreamLoads = circuit.downstreamBoardLoads ?? []; const downstreamLoads = circuit.downstreamBoardLoads ?? [];
if (circuit.deviceRows.length === 0 && downstreamLoads.length === 0) { if (circuit.deviceRows.length === 0 && downstreamLoads.length === 0) {
rows.push(makeVisibleGridRow("reserveCircuit", section.id, circuit)); 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) { } else if (circuit.deviceRows.length === 1 && downstreamLoads.length === 0) {
rows.push(makeVisibleGridRow("circuitCompact", section.id, circuit, circuit.deviceRows[0])); rows.push(makeVisibleGridRow("circuitCompact", section.id, circuit, circuit.deviceRows[0]));
} else { } else {

View file

@ -133,7 +133,9 @@ describe("circuit grid model", () => {
assert.equal(getCellKind("circuitCompact", "equipmentIdentifier"), "circuitField"); assert.equal(getCellKind("circuitCompact", "equipmentIdentifier"), "circuitField");
assert.equal(getCellKind("circuitSummary", "displayName"), "circuitField"); assert.equal(getCellKind("circuitSummary", "displayName"), "circuitField");
assert.equal(getCellKind("deviceRow", "displayName"), "deviceField"); 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("placeholder", "displayName"), "deviceField");
assert.equal(getCellKind("deviceRow", "equipmentIdentifier"), "readonly"); assert.equal(getCellKind("deviceRow", "equipmentIdentifier"), "readonly");
assert.equal(getCellKind("circuitSummary", "controlRequirement"), "circuitField"); assert.equal(getCellKind("circuitSummary", "controlRequirement"), "circuitField");

View file

@ -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 = { const feeder = {
...reserveCircuit, ...reserveCircuit,
displayName: undefined,
isReserve: false, isReserve: false,
circuitTotalPower: 4, circuitTotalPower: 4,
circuitApparentPower: 5, circuitApparentPower: 5,
@ -202,19 +203,65 @@ describe("circuit grid projection", () => {
assert.deepEqual(rows.map((row) => row.rowType), [ assert.deepEqual(rows.map((row) => row.rowType), [
"section", "section",
"circuitSummary", "circuitSummary",
"downstreamBoardLoad",
"placeholder", "placeholder",
]); ]);
const loadRow = rows[2]; const loadRow = rows[1];
assert.equal( assert.equal(
loadRow.cells.find((cell) => cell.cellKey === "displayName")?.value, 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( assert.equal(
loadRow.cells.find((cell) => cell.cellKey === "rowTotalPower")?.value, loadRow.cells.find((cell) => cell.cellKey === "rowTotalPower")?.value,
4 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", () => { it("shows circuit protection once on each visible circuit row", () => {