diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 716238d..1ad0eab 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -378,6 +378,14 @@ Projektzugehörigkeit und schreibt Schutzgerät, Revision und Historienübergang atomar. Benutzer können Schutzdaten anlegen oder ändern, aber nicht entfernen; Undo einer erstmaligen Anlage darf den zuvor fehlenden Datensatz exakt wiederherstellen. +Der vollständige `CircuitSnapshot` kann rückwärtskompatibel einen +`protectionDevice`-Datensatz enthalten. Neue gruppierte Stromkreise und durch +Geräteverschiebung erzeugte Zielstromkreise verwenden die vereinbarten +Kategorie-Standardwerte und schreiben Schutzgerät, Stromkreis sowie +Gerätezeilen atomar. Delete/Undo erfasst denselben Datensatz vollständig. +Im Editor sind die bisherigen Schutzspalten deshalb nur noch eine +schreibgeschützte Projektion der 1:1-Daten; Änderungen erfolgen über ein +geräteabhängiges Schutzgeräte-Modal und `circuit-protection.update`. `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 diff --git a/docs/spec/08-current-product-backlog.md b/docs/spec/08-current-product-backlog.md index 6a1ca07..b44aa9a 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -37,7 +37,7 @@ requirements and intended sequencing, not proof of implementation. - [x] Phase E3a: create, edit and delete mutable group and footer components. - [x] Phase E3b1: create, rename and safely delete empty circuit groups. - [x] Phase E3b2a: persistent circuit-protection update command. -- [ ] Phase E3b2b: circuit-protection defaults on insert and editor modal. +- [x] Phase E3b2b: circuit-protection defaults on insert and editor modal. - [ ] Phase E4: group reorder, renumber, circuit moves and populated-delete warning. - [ ] Phase E: editor projection and editing. - [ ] Phase F: documentation and full GUI verification. 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 9a44621..236fcd2 100644 --- a/docs/spec/09-distribution-board-components-and-protection-groups.md +++ b/docs/spec/09-distribution-board-components-and-protection-groups.md @@ -743,8 +743,8 @@ Status: In progress. The complete tree read model E1, pure structural projection E2a, grid rendering E2b and mutable component editing E3a are complete. Basic group management E3b1 is also complete. Circuit-protection editing now has its persistent E3b2a command boundary; insertion defaults and -the editor modal as well as structural drag/renumber/delete workflows remain -pending. +the editor modal are complete in E3b2b. Structural drag/renumber/delete +workflows remain pending. - render fixed header components - render group components and circuit blocks @@ -783,6 +783,12 @@ Implemented in E1/E2a/E2b/E3a: snapshot, rejects stale state and preserves exact persistent Undo/Redo - user commands cannot remove circuit protection; a nullable target exists only as the inverse of adding protection to a retained circuit without it +- newly inserted circuits and placeholder targets carry the category-specific + default protection in their complete atomic snapshot +- circuit deletion and restoration preserve the exact 1:1 protection row + together with the circuit and all device rows +- the editor modal exposes only fields valid for the selected protection type; + the old flat protection columns now project the new 1:1 data read-only Acceptance: diff --git a/src/db/repositories/circuit-device-row-move-project-command.repository.ts b/src/db/repositories/circuit-device-row-move-project-command.repository.ts index 66b8273..0325d73 100644 --- a/src/db/repositories/circuit-device-row-move-project-command.repository.ts +++ b/src/db/repositories/circuit-device-row-move-project-command.repository.ts @@ -17,6 +17,7 @@ import type { import type { AppDatabase } from "../database-context.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitLists } from "../schema/circuit-lists.js"; +import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js"; import { circuitSections } from "../schema/circuit-sections.js"; import { circuits } from "../schema/circuits.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; @@ -396,6 +397,12 @@ export class CircuitDeviceRowMoveProjectCommandRepository remark: snapshot.remark, }) .run(); + if (snapshot.protectionDevice) { + database + .insert(circuitProtectionDevices) + .values(snapshot.protectionDevice) + .run(); + } } private assertTargetCircuitUnchanged( @@ -438,6 +445,27 @@ export class CircuitDeviceRowMoveProjectCommandRepository "Created target circuit changed before command execution." ); } + const protectionDevice = database + .select() + .from(circuitProtectionDevices) + .where(eq(circuitProtectionDevices.circuitId, snapshot.id)) + .get(); + if ( + snapshot.protectionDevice === undefined + ? protectionDevice !== undefined + : snapshot.protectionDevice === null + ? protectionDevice !== undefined + : !protectionDevice || + Object.entries(snapshot.protectionDevice).some( + ([key, value]) => + (protectionDevice as Record)[key] !== + value + ) + ) { + throw new Error( + "Created target circuit protection changed before command execution." + ); + } const currentRowIds = database .select({ id: circuitDeviceRows.id }) diff --git a/src/db/repositories/circuit-structure-project-command.repository.ts b/src/db/repositories/circuit-structure-project-command.repository.ts index 97e7000..11e6fd6 100644 --- a/src/db/repositories/circuit-structure-project-command.repository.ts +++ b/src/db/repositories/circuit-structure-project-command.repository.ts @@ -17,6 +17,7 @@ import { isElectricalPhaseType } from "../../domain/services/project-voltage.ser import type { AppDatabase } from "../database-context.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitLists } from "../schema/circuit-lists.js"; +import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js"; import { circuitSections } from "../schema/circuit-sections.js"; import { circuits } from "../schema/circuits.js"; import { @@ -175,6 +176,12 @@ export class CircuitStructureProjectCommandRepository if (snapshot.deviceRows.length > 0) { database.insert(circuitDeviceRows).values(snapshot.deviceRows).run(); } + if (snapshot.protectionDevice) { + database + .insert(circuitProtectionDevices) + .values(snapshot.protectionDevice) + .run(); + } return createCircuitDeleteProjectCommand( snapshot.id, @@ -219,6 +226,11 @@ export class CircuitStructureProjectCommandRepository asc(circuitDeviceRows.id) ) .all(); + const protectionDevice = database + .select() + .from(circuitProtectionDevices) + .where(eq(circuitProtectionDevices.circuitId, circuit.id)) + .get(); const inverse = createCircuitInsertProjectCommand({ id: circuit.id, circuitListId: circuit.circuitListId, @@ -240,6 +252,7 @@ export class CircuitStructureProjectCommandRepository isReserve: Boolean(circuit.isReserve), remark: circuit.remark, deviceRows: rows.map(toCircuitDeviceRowSnapshot), + protectionDevice: protectionDevice ?? null, }); const result = database diff --git a/src/domain/models/circuit-protection-project-command.model.ts b/src/domain/models/circuit-protection-project-command.model.ts index f4604f6..2e8e6f4 100644 --- a/src/domain/models/circuit-protection-project-command.model.ts +++ b/src/domain/models/circuit-protection-project-command.model.ts @@ -66,17 +66,20 @@ export function assertCircuitProtectionUpdateProjectCommand( throw new Error("Circuit protection update must change state."); } if (expected !== null) { - assertSnapshot(expected, circuitId); + assertCircuitProtectionSnapshot(expected, circuitId); } if (target !== null) { - assertSnapshot(target, circuitId); + assertCircuitProtectionSnapshot(target, circuitId); } if (JSON.stringify(expected) === JSON.stringify(target)) { throw new Error("Circuit protection update must change state."); } } -function assertSnapshot(value: unknown, circuitId: string) { +export function assertCircuitProtectionSnapshot( + value: unknown, + circuitId: string +) { if ( !isPlainObject(value) || Object.keys(value).length !== 7 || diff --git a/src/domain/models/circuit-structure-project-command.model.ts b/src/domain/models/circuit-structure-project-command.model.ts index 42e1803..9cbde65 100644 --- a/src/domain/models/circuit-structure-project-command.model.ts +++ b/src/domain/models/circuit-structure-project-command.model.ts @@ -5,6 +5,10 @@ import { type CircuitDeviceRowSnapshot, } from "./circuit-device-row-structure-project-command.model.js"; import type { SerializedProjectCommand } from "./project-command.model.js"; +import { + assertCircuitProtectionSnapshot, + type CircuitProtectionSnapshot, +} from "./circuit-protection-project-command.model.js"; export const circuitInsertCommandType = "circuit.insert" as const; export const circuitDeleteCommandType = "circuit.delete" as const; @@ -31,6 +35,7 @@ export interface CircuitSnapshot { isReserve: boolean; remark: string | null; deviceRows: CircuitDeviceRowSnapshot[]; + protectionDevice?: CircuitProtectionSnapshot | null; } export interface CircuitInsertCommandPayload { @@ -142,6 +147,15 @@ export function assertCircuitInsertProjectCommand( if (!Array.isArray(circuit.deviceRows)) { throw new Error("circuit.deviceRows must be an array."); } + if ( + circuit.protectionDevice !== undefined && + circuit.protectionDevice !== null + ) { + assertCircuitProtectionSnapshot( + circuit.protectionDevice, + circuit.id as string + ); + } if (circuit.isReserve !== (circuit.deviceRows.length === 0)) { throw new Error( "Circuit reserve state must match whether device rows exist." diff --git a/src/frontend/components/circuit-protection-modal.tsx b/src/frontend/components/circuit-protection-modal.tsx new file mode 100644 index 0000000..9037711 --- /dev/null +++ b/src/frontend/components/circuit-protection-modal.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { type FormEvent, useState } from "react"; +import { + allowedRatedCurrentsAByProtectionDeviceType, + breakerTripCharacteristics, + fuseProtectionDeviceTypes, + fuseUtilizationCategories, + protectionDeviceTypeLabels, + protectionDeviceTypes, + ratedResidualCurrentsMa, + rcdTypes, + type ProtectionDeviceType, +} from "../../shared/constants/protection-device"; +import type { CircuitTreeProtectionDeviceDto } from "../types"; +import { FormModal } from "./form-modal"; + +interface CircuitProtectionModalProps { + equipmentIdentifier: string; + initialProtection: CircuitTreeProtectionDeviceDto; + isSaving: boolean; + onClose: () => void; + onSave: (protection: CircuitTreeProtectionDeviceDto) => Promise; +} + +export function CircuitProtectionModal({ + equipmentIdentifier, + initialProtection, + isSaving, + onClose, + onSave, +}: CircuitProtectionModalProps) { + const [type, setType] = useState(initialProtection.type); + const [ratedCurrentA, setRatedCurrentA] = useState( + initialProtection.ratedCurrentA + ); + 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 usesFuseCategory = ( + fuseProtectionDeviceTypes as readonly string[] + ).includes(type); + const usesTripCharacteristic = + type === "LS" || type === "FI_LS" || type === "AFDD"; + const usesResidualCurrent = type === "FI" || type === "FI_LS"; + const allowedRatedCurrents = + allowedRatedCurrentsAByProtectionDeviceType[type]; + + function handleTypeChange(nextType: ProtectionDeviceType) { + setType(nextType); + setRatedCurrentA( + allowedRatedCurrentsAByProtectionDeviceType[nextType][0] + ); + setFuseUtilizationCategory("gG"); + setTripCharacteristic("B"); + setRcdType("A"); + setRatedResidualCurrentMa(30); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + await onSave({ + type, + ratedCurrentA, + ...(usesFuseCategory ? { fuseUtilizationCategory } : {}), + ...(usesTripCharacteristic ? { tripCharacteristic } : {}), + ...(usesResidualCurrent + ? { rcdType, ratedResidualCurrentMa } + : {}), + }); + } + + return ( + +
+
+ + +
+
+ + +
+ {usesFuseCategory ? ( +
+ + +
+ ) : null} + {usesTripCharacteristic ? ( +
+ + +
+ ) : null} + {usesResidualCurrent ? ( + <> +
+ + +
+
+ + +
+ + ) : null} +
+
+ ); +} diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 6ecd482..d3ce898 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -38,6 +38,10 @@ import { buildCircuitDeviceRowInsertSnapshot, buildCircuitInsertSnapshot, } from "../utils/circuit-structure-command"; +import { + getCircuitProtectionEditorInitialValue, + toCircuitProtectionSnapshot, +} from "../utils/circuit-protection-editing"; import { buildCircuitDeviceRowMoveAssignments, } from "../utils/circuit-device-row-move-command"; @@ -95,6 +99,7 @@ import { redoProjectCommand, updateCircuitById, updateCircuitDeviceRowById, + updateCircuitProtectionCommand, updateCircuitGroupCommand, updateDistributionBoardComponentCommand, undoProjectCommand, @@ -102,6 +107,7 @@ import { import type { CircuitTreeCircuitDto, CircuitTreeComponentDto, + CircuitTreeProtectionDeviceDto, CircuitTreeResponseDto, CreateCircuitDeviceRowInputDto, CreateCircuitInputDto, @@ -117,6 +123,7 @@ import type { } from "../../domain/models/circuit-structure-project-command.model"; import { DistributionBoardComponentModal } from "./distribution-board-component-modal"; import { CircuitGroupModal } from "./circuit-group-modal"; +import { CircuitProtectionModal } from "./circuit-protection-modal"; import type { CircuitGroupCategory } from "../../shared/constants/circuit-group"; type SaveDirection = "stay" | "next" | "prev"; @@ -246,6 +253,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str useState(null); const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] = useState(null); + const [protectionEditorCircuit, setProtectionEditorCircuit] = + useState(null); const [projectDevices, setProjectDevices] = useState([]); const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] = useState(false); @@ -962,6 +971,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str circuitListId, values: { ...values, voltage }, deviceRows, + category: section.category, }); } @@ -1225,6 +1235,35 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }); } + async function handleSaveCircuitProtection( + protection: CircuitTreeProtectionDeviceDto + ) { + const circuit = protectionEditorCircuit; + if (!circuit) { + return; + } + await runCommand({ + label: "Stromkreisschutz bearbeiten", + redo: async () => { + const result = await updateCircuitProtectionCommand( + projectId, + getExpectedProjectRevision(), + circuit.id, + circuit.protectionDevice + ? toCircuitProtectionSnapshot( + circuit.id, + circuit.protectionDevice + ) + : null, + toCircuitProtectionSnapshot(circuit.id, protection) + ); + applyProjectCommandResult(result); + setProtectionEditorCircuit(null); + return null; + }, + }); + } + async function handleRedo() { if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) { return; @@ -2834,6 +2873,23 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str onSave={handleSaveCircuitGroup} /> ) : null} + {protectionEditorCircuit ? ( + + section.id === protectionEditorCircuit.sectionId + )?.category, + protectionEditorCircuit.protectionDevice + )} + isSaving={isSaving} + onClose={() => setProtectionEditorCircuit(null)} + onSave={handleSaveCircuitProtection} + /> + ) : null}