Render protected circuit structure
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr
|
||||
key={row.rowKey}
|
||||
className={`structure-component-row ${row.rowType}`}
|
||||
>
|
||||
<td colSpan={visibleColumns.length + 1}>
|
||||
<div className="structure-component-content">
|
||||
<strong>{component.equipmentIdentifier}</strong>
|
||||
<span>{component.name}</span>
|
||||
{protectionSummary ? (
|
||||
<span className="structure-component-protection">
|
||||
{protectionSummary}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
if (row.rowType === "section") {
|
||||
const section = data.sections.find((entry) => entry.id === row.sectionId)!;
|
||||
return (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user