diff --git a/docs/current-architecture.md b/docs/current-architecture.md index f3956cd..ed66858 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -357,9 +357,14 @@ vollständige Stromkreisblöcke und der freie Einfügeplatz. Leere Gruppen bleib 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. +Gruppen- und Fußkomponenten erscheinen als Zeilen über die volle Tabellenbreite +und nehmen nicht an Zellauswahl oder Tastatur-Einfügen teil. Hauptschalter und +Überspannungsableiter bleiben feste, schreibgeschützte Kopfkomponenten. +Optionale Gruppenvorsicherungen, Gruppen-FIs und zusätzliche Verteilergeräte +werden über beschriftete Modale angelegt, bearbeitet und nach Bestätigung +entfernt. Der Frontend-API-Adapter serialisiert dafür die vorhandenen +`distribution-board-component.*`-Commands; die verbindliche +geräteabhängige Validierung und atomare Revision bleiben serverseitig. `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 @@ -433,8 +438,9 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät. Snapshot-Schema 7. Persistente Insert/Delete/Update-Commands für 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. + zeigt die geschützte Struktur an und bearbeitet veränderliche Gruppen- und + Fußkomponenten über dedizierte Command-Modale. Gruppenverwaltung und + Stromkreisschutz folgen in weiteren abgegrenzten Arbeitspaketen. 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 33751af..6880875 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -34,6 +34,8 @@ requirements and intended sequencing, not proof of implementation. - [x] Phase E1: tree read model for components, groups and protection devices. - [x] Phase E2a: pure header/group/footer structure projection. - [x] Phase E2b: render the structure projection in the editor grid. +- [x] Phase E3a: create, edit and delete mutable group and footer components. +- [ ] Phase E3b: group management and circuit-protection editing. - [ ] 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 f1a8772..26bc6df 100644 --- a/docs/spec/09-distribution-board-components-and-protection-groups.md +++ b/docs/spec/09-distribution-board-components-and-protection-groups.md @@ -740,8 +740,8 @@ Acceptance: ### E. Editor Projection and Editing Status: In progress. The complete tree read model E1, pure structural -projection E2a and read-only grid rendering E2b are complete. Editing remains -pending. +projection E2a, grid rendering E2b and mutable component editing E3a are +complete. Group management and circuit-protection editing remain pending. - render fixed header components - render group components and circuit blocks @@ -750,7 +750,7 @@ pending. - add type-dependent protection editing - keep spreadsheet selection, keyboard and drag behavior coherent -Implemented in E1/E2a/E2b: +Implemented in E1/E2a/E2b/E3a: - the tree response exposes fixed header, group and auxiliary footer components plus both one-to-one protection layers @@ -764,6 +764,12 @@ Implemented in E1/E2a/E2b: 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 +- group headings offer at most one optional upstream protection device and one + optional group RCD; the agreed group-RCD default is applied only when it is + created +- mutable group protection and auxiliary footer components use labeled modals, + type-dependent protection fields and persistent project commands +- fixed main-switch and surge-protection header components remain read-only Acceptance: diff --git a/src/app/globals.css b/src/app/globals.css index 9fe85c5..e66489a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -691,6 +691,23 @@ body { font-size: 0.78rem; } +.tree-grid .structure-component-actions { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin-left: auto; +} + +.tree-grid .structure-component-actions button { + font-size: 0.75rem; + padding: 0.18rem 0.45rem; +} + +.tree-grid .structure-component-fixed { + color: #64748b; + font-size: 0.75rem; +} + .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 2ed122a..b3ae1fc 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -47,6 +47,15 @@ import { import { buildCircuitSectionRenumberAssignments, } from "../utils/circuit-section-renumber-command"; +import { + buildDistributionBoardComponentSnapshot, + getNextComponentSortOrder, + getSuggestedGroupComponentIdentifier, + toDistributionBoardComponentSnapshot, + updateDistributionBoardComponentSnapshot, + type DistributionBoardComponentEditorValues, + type MutableDistributionBoardComponentRole, +} from "../utils/distribution-board-component-editing"; import { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history"; import type { CellKey, @@ -62,11 +71,13 @@ import type { VisibleGridRow } from "../utils/circuit-grid-projection"; import { deleteCircuitCommand, deleteCircuitDeviceRowCommand, + deleteDistributionBoardComponentCommand, getCircuitTree, getNextCircuitIdentifier, getProjectHistory, insertCircuitCommand, insertCircuitDeviceRowCommand, + insertDistributionBoardComponentCommand, listProjectDevices, moveCircuitDeviceRowsCommand, moveCircuitDeviceRowsToNewCircuitCommand, @@ -76,10 +87,12 @@ import { redoProjectCommand, updateCircuitById, updateCircuitDeviceRowById, + updateDistributionBoardComponentCommand, undoProjectCommand, } from "../utils/api"; import type { CircuitTreeCircuitDto, + CircuitTreeComponentDto, CircuitTreeResponseDto, CreateCircuitDeviceRowInputDto, CreateCircuitInputDto, @@ -93,6 +106,7 @@ import type { import type { CircuitSnapshot, } from "../../domain/models/circuit-structure-project-command.model"; +import { DistributionBoardComponentModal } from "./distribution-board-component-modal"; type SaveDirection = "stay" | "next" | "prev"; type StartEditMode = "selectExisting" | "replaceWithTypedChar"; @@ -161,6 +175,18 @@ function normalizeUiError(err: unknown): string { return message; } +type StructureComponentEditorIntent = + | { + kind: "create"; + role: MutableDistributionBoardComponentRole; + sectionId?: string; + initialEquipmentIdentifier?: string; + } + | { + kind: "edit"; + component: CircuitTreeComponentDto; + }; + function getFullColumnLabel(column: ColumnDef): string { return column.fullLabel ?? column.label; } @@ -201,6 +227,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const [editingCell, setEditingCell] = useState(null); const [activeSectionId, setActiveSectionId] = useState(null); const [isSaving, setIsSaving] = useState(false); + const [componentEditorIntent, setComponentEditorIntent] = + useState(null); const [projectDevices, setProjectDevices] = useState([]); const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] = useState(false); @@ -1031,6 +1059,85 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str await applyHistory("undo"); } + async function handleSaveDistributionBoardComponent( + values: DistributionBoardComponentEditorValues + ) { + const intent = componentEditorIntent; + if (!intent || !data) { + return; + } + await runCommand({ + label: + intent.kind === "edit" + ? "Verteilerkomponente bearbeiten" + : "Verteilerkomponente hinzufügen", + redo: async () => { + const result = + intent.kind === "edit" + ? await (() => { + const expected = toDistributionBoardComponentSnapshot( + intent.component + ); + return updateDistributionBoardComponentCommand( + projectId, + getExpectedProjectRevision(), + expected, + updateDistributionBoardComponentSnapshot(expected, values) + ); + })() + : await (() => { + const components = + intent.role === "auxiliary" + ? data.footerComponents + : data.sections.find( + (section) => section.id === intent.sectionId + )?.components ?? []; + const snapshot = buildDistributionBoardComponentSnapshot({ + id: crypto.randomUUID(), + circuitListId, + role: intent.role, + sectionId: intent.sectionId, + sortOrder: getNextComponentSortOrder(components), + values, + }); + return insertDistributionBoardComponentCommand( + projectId, + getExpectedProjectRevision(), + snapshot + ); + })(); + applyProjectCommandResult(result); + setComponentEditorIntent(null); + return null; + }, + }); + } + + async function handleDeleteDistributionBoardComponent( + component: CircuitTreeComponentDto + ) { + if ( + !confirm( + `Verteilerkomponente „${component.equipmentIdentifier} ${component.name}“ entfernen?` + ) + ) { + return; + } + await runCommand({ + label: "Verteilerkomponente entfernen", + redo: async () => { + const result = await deleteDistributionBoardComponentCommand( + projectId, + getExpectedProjectRevision(), + toDistributionBoardComponentSnapshot(component) + ); + applyProjectCommandResult(result); + setComponentEditorIntent(null); + return null; + }, + }); + } + async function handleRedo() { if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) { return; @@ -2605,6 +2712,29 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return (
+ {componentEditorIntent ? ( + setComponentEditorIntent(null)} + onSave={handleSaveDistributionBoardComponent} + role={ + componentEditorIntent.kind === "edit" + ? (componentEditorIntent.component + .role as MutableDistributionBoardComponentRole) + : componentEditorIntent.role + } + /> + ) : null}
+ + + ) : ( + + Feste Verteilerkomponente + + )} +
@@ -3040,6 +3219,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } if (row.rowType === "section") { const section = data.sections.find((entry) => entry.id === row.sectionId)!; + const hasUpstreamProtection = section.components.some( + (component) => + component.role === "group_upstream_protection" + ); + const hasGroupRcd = section.components.some( + (component) => + component.role === "group_residual_current_protection" + ); return (
+ + diff --git a/src/frontend/components/distribution-board-component-modal.tsx b/src/frontend/components/distribution-board-component-modal.tsx new file mode 100644 index 0000000..332d800 --- /dev/null +++ b/src/frontend/components/distribution-board-component-modal.tsx @@ -0,0 +1,316 @@ +"use client"; + +import { type FormEvent, useState } from "react"; +import { + allowedRatedCurrentsAByProtectionDeviceType, + breakerTripCharacteristics, + fuseProtectionDeviceTypes, + fuseUtilizationCategories, + protectionDeviceTypeLabels, + ratedResidualCurrentsMa, + rcdTypes, + type ProtectionDeviceType, +} from "../../shared/constants/protection-device"; +import type { CircuitTreeComponentDto } from "../types"; +import type { + DistributionBoardComponentEditorValues, + MutableDistributionBoardComponentRole, +} from "../utils/distribution-board-component-editing"; +import { FormModal } from "./form-modal"; + +interface DistributionBoardComponentModalProps { + initialComponent?: CircuitTreeComponentDto; + initialEquipmentIdentifier?: string; + isSaving: boolean; + onClose: () => void; + onSave: (values: DistributionBoardComponentEditorValues) => Promise; + role: MutableDistributionBoardComponentRole; +} + +const upstreamProtectionTypes: readonly ProtectionDeviceType[] = [ + ...fuseProtectionDeviceTypes, + "LS", +]; + +export function DistributionBoardComponentModal({ + initialComponent, + initialEquipmentIdentifier = "", + isSaving, + onClose, + onSave, + role, +}: DistributionBoardComponentModalProps) { + const [equipmentIdentifier, setEquipmentIdentifier] = useState( + initialComponent?.equipmentIdentifier ?? initialEquipmentIdentifier + ); + const [name, setName] = useState( + initialComponent?.name ?? defaultName(role) + ); + const initialProtection = initialComponent?.protectionDevice; + const [protectionType, setProtectionType] = useState< + ProtectionDeviceType | "" + >( + initialProtection?.type ?? + (role === "group_residual_current_protection" ? "FI" : "") + ); + const [ratedCurrentA, setRatedCurrentA] = useState( + initialProtection?.ratedCurrentA ?? + (role === "group_residual_current_protection" ? 40 : 0) + ); + const [fuseUtilizationCategory, setFuseUtilizationCategory] = useState( + initialProtection?.fuseUtilizationCategory ?? "gG" + ); + const [tripCharacteristic, setTripCharacteristic] = useState( + initialProtection?.tripCharacteristic ?? "B" + ); + const [rcdType, setRcdType] = useState( + initialProtection?.rcdType ?? "A" + ); + const [ratedResidualCurrentMa, setRatedResidualCurrentMa] = useState( + initialProtection?.ratedResidualCurrentMa ?? 30 + ); + + const isAuxiliary = role === "auxiliary"; + const availableProtectionTypes = + role === "group_residual_current_protection" + ? (["FI"] as const) + : upstreamProtectionTypes; + const usesFuseCategory = + protectionType !== "" && + (fuseProtectionDeviceTypes as readonly string[]).includes(protectionType); + const usesTripCharacteristic = protectionType === "LS"; + const usesResidualCurrent = protectionType === "FI"; + const allowedRatedCurrents = + protectionType === "" + ? [] + : allowedRatedCurrentsAByProtectionDeviceType[protectionType]; + + function handleProtectionTypeChange(type: ProtectionDeviceType) { + setProtectionType(type); + setRatedCurrentA(allowedRatedCurrentsAByProtectionDeviceType[type][0]); + setFuseUtilizationCategory("gG"); + setTripCharacteristic("B"); + setRcdType("A"); + setRatedResidualCurrentMa(30); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + await onSave({ + equipmentIdentifier, + name, + ...(!isAuxiliary && protectionType + ? { + protectionDevice: { + type: protectionType, + ratedCurrentA, + ...(usesFuseCategory + ? { fuseUtilizationCategory } + : {}), + ...(usesTripCharacteristic + ? { tripCharacteristic } + : {}), + ...(usesResidualCurrent + ? { rcdType, ratedResidualCurrentMa } + : {}), + }, + } + : {}), + }); + } + + const isValid = + equipmentIdentifier.trim().length > 0 && + name.trim().length > 0 && + (isAuxiliary || + (protectionType !== "" && + (allowedRatedCurrents as readonly number[]).includes(ratedCurrentA))); + + return ( + +
+
+ + setEquipmentIdentifier(event.target.value)} + required + value={equipmentIdentifier} + /> +
+
+ + setName(event.target.value)} + required + value={name} + /> +
+ {!isAuxiliary ? ( + <> +
+ + +
+
+ + +
+ {usesFuseCategory ? ( +
+ + +
+ ) : null} + {usesTripCharacteristic ? ( +
+ + +
+ ) : null} + {usesResidualCurrent ? ( + <> +
+ + +
+
+ + +
+ + ) : null} + + ) : null} +
+
+ ); +} + +function defaultName(role: MutableDistributionBoardComponentRole): string { + if (role === "group_upstream_protection") { + return "Gruppenvorsicherung"; + } + if (role === "group_residual_current_protection") { + return "Gruppen-FI"; + } + return "Verteilergerät"; +} diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 7759959..0ad5cf0 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -52,6 +52,9 @@ import type { import type { CircuitSectionRenumberAssignment, } from "../../domain/models/circuit-section-renumber-project-command.model"; +import type { + DistributionBoardComponentSnapshot, +} from "../../domain/models/distribution-board-component-structure-project-command.model"; async function request(url: string, init?: RequestInit): Promise { const response = await fetch(url, { @@ -320,6 +323,58 @@ export function getCircuitTree(projectId: string, circuitListId: string) { return request(`/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`); } +export function insertDistributionBoardComponentCommand( + projectId: string, + expectedRevision: number, + snapshot: DistributionBoardComponentSnapshot +) { + return executeProjectCommand( + projectId, + expectedRevision, + { + schemaVersion: 1, + type: "distribution-board-component.insert", + payload: { snapshot }, + }, + "Verteilerkomponente hinzufügen" + ); +} + +export function updateDistributionBoardComponentCommand( + projectId: string, + expectedRevision: number, + expected: DistributionBoardComponentSnapshot, + target: DistributionBoardComponentSnapshot +) { + return executeProjectCommand( + projectId, + expectedRevision, + { + schemaVersion: 1, + type: "distribution-board-component.update", + payload: { expected, target }, + }, + "Verteilerkomponente bearbeiten" + ); +} + +export function deleteDistributionBoardComponentCommand( + projectId: string, + expectedRevision: number, + snapshot: DistributionBoardComponentSnapshot +) { + return executeProjectCommand( + projectId, + expectedRevision, + { + schemaVersion: 1, + type: "distribution-board-component.delete", + payload: { snapshot }, + }, + "Verteilerkomponente entfernen" + ); +} + export function updateCircuitById( projectId: string, expectedRevision: number, diff --git a/src/frontend/utils/distribution-board-component-editing.ts b/src/frontend/utils/distribution-board-component-editing.ts new file mode 100644 index 0000000..c6bb879 --- /dev/null +++ b/src/frontend/utils/distribution-board-component-editing.ts @@ -0,0 +1,139 @@ +import type { + DistributionBoardComponentSnapshot, +} from "../../domain/models/distribution-board-component-structure-project-command.model"; +import type { + CircuitTreeComponentDto, + CircuitTreeProtectionDeviceDto, + CircuitTreeSectionDto, +} from "../types"; +import { circuitGroupCategoryNumbers } from "../../shared/constants/circuit-group"; + +export type MutableDistributionBoardComponentRole = + | "group_upstream_protection" + | "group_residual_current_protection" + | "auxiliary"; + +export interface DistributionBoardComponentEditorValues { + equipmentIdentifier: string; + name: string; + protectionDevice?: CircuitTreeProtectionDeviceDto; +} + +export function toDistributionBoardComponentSnapshot( + component: CircuitTreeComponentDto +): DistributionBoardComponentSnapshot { + if ( + component.role !== "group_upstream_protection" && + component.role !== "group_residual_current_protection" && + component.role !== "auxiliary" + ) { + throw new Error("Feste Verteilerkomponenten können hier nicht bearbeitet werden."); + } + return { + component: { + id: component.id, + circuitListId: component.circuitListId, + sectionId: component.sectionId ?? null, + equipmentIdentifier: component.equipmentIdentifier, + name: component.name, + role: component.role, + placement: component.placement, + sortOrder: component.sortOrder, + }, + protectionDevice: component.protectionDevice + ? { + componentId: component.id, + type: component.protectionDevice.type, + ratedCurrentA: component.protectionDevice.ratedCurrentA, + fuseUtilizationCategory: + component.protectionDevice.fuseUtilizationCategory ?? null, + tripCharacteristic: + component.protectionDevice.tripCharacteristic ?? null, + rcdType: component.protectionDevice.rcdType ?? null, + ratedResidualCurrentMa: + component.protectionDevice.ratedResidualCurrentMa ?? null, + } + : null, + }; +} + +export function buildDistributionBoardComponentSnapshot(input: { + id: string; + circuitListId: string; + role: MutableDistributionBoardComponentRole; + sectionId?: string; + sortOrder: number; + values: DistributionBoardComponentEditorValues; +}): DistributionBoardComponentSnapshot { + const groupComponent = input.role !== "auxiliary"; + if (groupComponent && !input.sectionId) { + throw new Error("Für den Gruppenschutz fehlt die Stromkreisgruppe."); + } + if (groupComponent && !input.values.protectionDevice) { + throw new Error("Für den Gruppenschutz fehlt die Schutzgerätekonfiguration."); + } + return { + component: { + id: input.id, + circuitListId: input.circuitListId, + sectionId: groupComponent ? input.sectionId! : null, + equipmentIdentifier: input.values.equipmentIdentifier.trim(), + name: input.values.name.trim(), + role: input.role, + placement: groupComponent ? "group" : "footer", + sortOrder: input.sortOrder, + }, + protectionDevice: + groupComponent && input.values.protectionDevice + ? { + componentId: input.id, + type: input.values.protectionDevice.type, + ratedCurrentA: input.values.protectionDevice.ratedCurrentA, + fuseUtilizationCategory: + input.values.protectionDevice.fuseUtilizationCategory ?? null, + tripCharacteristic: + input.values.protectionDevice.tripCharacteristic ?? null, + rcdType: input.values.protectionDevice.rcdType ?? null, + ratedResidualCurrentMa: + input.values.protectionDevice.ratedResidualCurrentMa ?? null, + } + : null, + }; +} + +export function updateDistributionBoardComponentSnapshot( + expected: DistributionBoardComponentSnapshot, + values: DistributionBoardComponentEditorValues +): DistributionBoardComponentSnapshot { + return buildDistributionBoardComponentSnapshot({ + id: expected.component.id, + circuitListId: expected.component.circuitListId, + role: expected.component.role, + sectionId: expected.component.sectionId ?? undefined, + sortOrder: expected.component.sortOrder, + values, + }); +} + +export function getSuggestedGroupComponentIdentifier( + section: CircuitTreeSectionDto, + role: + | "group_upstream_protection" + | "group_residual_current_protection" +): string { + if (!section.category || !section.groupNumber) { + return ""; + } + const categoryNumber = circuitGroupCategoryNumbers[section.category]; + const deviceLetter = + role === "group_upstream_protection" ? "F" : "Q"; + return `-${categoryNumber}${deviceLetter}${section.groupNumber}.0`; +} + +export function getNextComponentSortOrder( + components: readonly { sortOrder: number }[] +): number { + return components.length === 0 + ? 10 + : Math.max(...components.map((component) => component.sortOrder)) + 10; +} diff --git a/tests/distribution-board-component.test.ts b/tests/distribution-board-component.test.ts index 5652e9c..9396675 100644 --- a/tests/distribution-board-component.test.ts +++ b/tests/distribution-board-component.test.ts @@ -7,6 +7,13 @@ import { distributionBoardComponentRoles, } from "../src/shared/constants/distribution-board-component.js"; import { circuitGroupCategories } from "../src/shared/constants/circuit-group.js"; +import { + buildDistributionBoardComponentSnapshot, + getNextComponentSortOrder, + getSuggestedGroupComponentIdentifier, + toDistributionBoardComponentSnapshot, + updateDistributionBoardComponentSnapshot, +} from "../src/frontend/utils/distribution-board-component-editing.js"; import "./distribution-board-component-structure-project-command.repository.test.js"; describe("distribution board component catalog", () => { @@ -47,4 +54,92 @@ describe("distribution board component catalog", () => { placement: "header", }); }); + + it("builds exact editable component snapshots for persistent commands", () => { + const groupRcd = buildDistributionBoardComponentSnapshot({ + id: "component-1", + circuitListId: "list-1", + role: "group_residual_current_protection", + sectionId: "section-1", + sortOrder: 20, + values: { + equipmentIdentifier: " -1Q1.0 ", + name: " Gruppen-FI ", + protectionDevice: { + type: "FI", + ratedCurrentA: 40, + rcdType: "A", + ratedResidualCurrentMa: 30, + }, + }, + }); + + assert.deepEqual(groupRcd, { + component: { + id: "component-1", + circuitListId: "list-1", + sectionId: "section-1", + equipmentIdentifier: "-1Q1.0", + name: "Gruppen-FI", + role: "group_residual_current_protection", + placement: "group", + sortOrder: 20, + }, + protectionDevice: { + componentId: "component-1", + type: "FI", + ratedCurrentA: 40, + fuseUtilizationCategory: null, + tripCharacteristic: null, + rcdType: "A", + ratedResidualCurrentMa: 30, + }, + }); + assert.deepEqual( + updateDistributionBoardComponentSnapshot(groupRcd, { + equipmentIdentifier: "-1Q1.0", + name: "FI Beleuchtung", + protectionDevice: { + type: "FI", + ratedCurrentA: 63, + rcdType: "B+", + ratedResidualCurrentMa: 300, + }, + }).component, + { ...groupRcd.component, name: "FI Beleuchtung" } + ); + }); + + it("maps tree DTOs and derives stable group suggestions and sort positions", () => { + const snapshot = toDistributionBoardComponentSnapshot({ + id: "actor-1", + circuitListId: "list-1", + equipmentIdentifier: "-K1", + name: "KNX Aktor", + role: "auxiliary", + placement: "footer", + sortOrder: 10, + }); + assert.equal(snapshot.component.sectionId, null); + assert.equal(snapshot.protectionDevice, null); + assert.equal(getNextComponentSortOrder([{ sortOrder: 10 }, { sortOrder: 40 }]), 50); + assert.equal( + getSuggestedGroupComponentIdentifier( + { + id: "section-1", + key: "lighting", + displayName: "Beleuchtung 2", + prefix: "-1F2", + sortOrder: 10, + category: "lighting", + groupNumber: 2, + sectionTotalPower: 0, + components: [], + circuits: [], + }, + "group_upstream_protection" + ), + "-1F2.0" + ); + }); });