Persist circuit cell edits

This commit is contained in:
2026-07-25 20:26:45 +02:00
parent 3977a6e6e1
commit 76d8d59391
24 changed files with 348 additions and 301 deletions
+117 -64
View File
@@ -17,12 +17,11 @@ import {
} from "../utils/circuit-grid-safety";
import {
allColumns,
buildCircuitEditPatch,
buildDeviceRowEditPatch,
defaultVisibleColumnKeys,
deviceFieldKeys,
formatValue,
getCircuitValue,
getDeviceValue,
parseNumeric,
} from "../utils/circuit-grid-model";
import type {
CellKey,
@@ -49,14 +48,17 @@ import {
moveCircuitDeviceRowById,
reorderSectionCircuits,
renumberCircuitSection,
redoProjectCommand,
updateSectionEquipmentIdentifiers,
updateCircuitById,
updateCircuitDeviceRowById,
undoProjectCommand,
} from "../utils/api";
import type {
CircuitTreeCircuitDto,
CircuitTreeDeviceRowDto,
CircuitTreeResponseDto,
ProjectCommandResultDto,
ProjectDeviceDto,
} from "../types";
@@ -88,6 +90,10 @@ interface HistoryCommand {
label: string;
redo: () => Promise<SelectionIntent | null | void>;
undo: () => Promise<SelectionIntent | null | void>;
persistent?: {
undoIntent: () => SelectionIntent | null;
redoIntent: () => SelectionIntent | null;
};
}
interface CircuitSnapshot {
@@ -163,6 +169,16 @@ function isPrintableKey(event: KeyboardEvent<HTMLElement>) {
return event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey;
}
function createValuesFromEditPatch(
patch: Record<string, unknown>
): Record<string, unknown> {
return Object.fromEntries(
Object.entries(patch).filter(
([, value]) => value !== null && value !== ""
)
);
}
export function CircuitTreeEditor(props: { projectId: string; circuitListId: string }) {
/*
Circuit-tree editor invariant overview:
@@ -201,7 +217,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [draggingCircuitId, setDraggingCircuitId] = useState<string | null>(null);
const [draggingCircuitIds, setDraggingCircuitIds] = useState<string[]>([]);
const [circuitReorderIntent, setCircuitReorderIntent] = useState<CircuitReorderDropIntent | null>(null);
// Undo/redo history is session-local UI state (not persisted server-side).
// The visible stack is session-local during the cutover. Circuit/device field
// commands already use the persistent project-wide server history.
const [undoStack, setUndoStack] = useState<HistoryCommand[]>([]);
const [redoStack, setRedoStack] = useState<HistoryCommand[]>([]);
const [historyBusy, setHistoryBusy] = useState(false);
@@ -223,6 +240,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const containerRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const focusTokenRef = useRef(1);
const projectRevisionRef = useRef<number | null>(null);
const orderedColumns = useMemo(
() => columnOrder.map((key) => allColumns.find((column) => column.key === key)).filter(Boolean) as ColumnDef[],
@@ -241,6 +259,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError(null);
try {
const tree = await getCircuitTree(projectId, circuitListId);
projectRevisionRef.current = tree.currentRevision;
setData(tree);
} catch (err) {
setError(normalizeUiError(err));
@@ -767,6 +786,25 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
}
function getExpectedProjectRevision() {
if (projectRevisionRef.current === null) {
throw new Error("Der Projektstand ist noch nicht geladen.");
}
return projectRevisionRef.current;
}
function applyProjectCommandResult(result: ProjectCommandResultDto) {
projectRevisionRef.current = result.history.currentRevision;
setData((current) =>
current
? {
...current,
currentRevision: result.history.currentRevision,
}
: current
);
}
// Runs a normal command and records it in session-local history.
async function runCommand(command: HistoryCommand) {
try {
@@ -790,7 +828,29 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError(null);
setHistoryBusy(true);
setIsSaving(true);
const intent = mode === "undo" ? await command.undo() : await command.redo();
let intent: SelectionIntent | null | void;
if (command.persistent) {
const result =
mode === "undo"
? await undoProjectCommand(
projectId,
getExpectedProjectRevision()
)
: await redoProjectCommand(
projectId,
getExpectedProjectRevision()
);
applyProjectCommandResult(result);
intent =
mode === "undo"
? command.persistent.undoIntent()
: command.persistent.redoIntent();
} else {
intent =
mode === "undo"
? await command.undo()
: await command.redo();
}
await reloadWithIntent(intent ?? null);
if (mode === "undo") {
setUndoStack((current) => current.slice(0, -1));
@@ -1145,46 +1205,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setSelectedCell({ rowKey: targetRowKey, cellKey: best });
}
// Maps generic grid cell edits to circuit PATCH payload fields.
// Maps grid edits to typed persistent project commands.
async function patchCircuit(circuitId: string, key: CellKey, draft: string) {
const payload: Record<string, unknown> = {};
if (key === "protectionSummary" || key === "cableSummary") {
payload.remark = draft.trim() === "" ? undefined : draft;
} else if (["protectionRatedCurrent", "cableLength", "voltage"].includes(key)) {
payload[key] = parseNumeric(key, draft);
} else if (key === "isReserve") {
const normalized = draft.trim().toLowerCase();
payload.isReserve = ["1", "true", "yes", "ja"].includes(normalized);
} else {
payload[key] = draft.trim() === "" ? undefined : draft;
}
await updateCircuitById(circuitId, payload);
const result = await updateCircuitById(
projectId,
getExpectedProjectRevision(),
circuitId,
buildCircuitEditPatch(key, draft)
);
applyProjectCommandResult(result);
}
// Maps generic grid cell edits to device-row PATCH payload fields.
// Device-row commands derive local ProjectDevice override metadata server-side.
async function patchDeviceRow(rowId: string, key: CellKey, draft: string) {
const payload: Record<string, unknown> = {};
if (["quantity", "powerPerUnit", "simultaneityFactor", "cosPhi"].includes(key)) {
payload[key] = parseNumeric(key, draft);
} else if (key === "roomSummary") {
const trimmed = draft.trim();
if (!trimmed) {
payload.roomNumberSnapshot = undefined;
payload.roomNameSnapshot = undefined;
} else {
const firstSpace = trimmed.indexOf(" ");
if (firstSpace > 0) {
payload.roomNumberSnapshot = trimmed.slice(0, firstSpace).trim();
payload.roomNameSnapshot = trimmed.slice(firstSpace + 1).trim();
} else {
payload.roomNumberSnapshot = trimmed;
payload.roomNameSnapshot = undefined;
}
}
} else {
payload[key] = draft.trim() === "" ? undefined : draft;
}
await updateCircuitDeviceRowById(rowId, payload);
const result = await updateCircuitDeviceRowById(
projectId,
getExpectedProjectRevision(),
rowId,
buildDeviceRowEditPatch(key, draft)
);
applyProjectCommandResult(result);
}
// Creates a real circuit from virtual placeholder row.
@@ -1204,6 +1244,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const isDeviceField = deviceFieldKeys.has(key);
if (isDeviceField) {
const editValues = createValuesFromEditPatch(
buildDeviceRowEditPatch(key, draft)
);
const created = await createCircuitWithDeviceRows(projectId, circuitListId, {
circuit: {
sectionId,
@@ -1221,37 +1264,28 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
powerPerUnit: 0,
simultaneityFactor: 1,
cosPhi: 1,
...editValues,
},
],
});
const createdCircuit = created.circuit;
const createdRow = created.deviceRows[0];
await patchDeviceRow(createdRow.id, key, draft);
return { rowKey: `circuitCompact:${createdCircuit.id}`, cellKey: key };
}
const editValues = createValuesFromEditPatch(
buildCircuitEditPatch(key, draft)
);
const createdCircuit = (await createCircuit(projectId, circuitListId, {
sectionId,
equipmentIdentifier: next.nextIdentifier,
displayName: "Neuer Stromkreis",
sortOrder,
isReserve: true,
...editValues,
})) as CircuitTreeCircuitDto;
await patchCircuit(createdCircuit.id, key, draft);
return { rowKey: `reserveCircuit:${createdCircuit.id}`, cellKey: key };
}
// Reads current persisted value into editable draft text for reversible edit commands.
function getCurrentDraftForCell(row: VisibleGridRow, cellKey: CellKey, kind: CellKind) {
if (kind === "deviceField" && row.device) {
return String(getDeviceValue(row.device, cellKey) ?? "");
}
if (kind === "circuitField" && row.circuit) {
return String(getCircuitValue(row.circuit, cellKey) ?? "");
}
return "";
}
// Commits the active editor input through command history so edits remain undoable.
async function commitEdit(direction: SaveDirection = "stay") {
if (!editingCell) {
@@ -1316,7 +1350,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
if (cell.kind === "circuitField" && row.circuit) {
const previous = getCurrentDraftForCell(row, editingCell.cellKey, cell.kind);
const next = editingCell.draft;
const command: HistoryCommand = {
label: "Stromkreisfeld bearbeiten",
@@ -1324,9 +1357,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await patchCircuit(row.circuit!.id, editingCell.cellKey, next);
return nextSelectionIntent();
},
undo: async () => {
await patchCircuit(row.circuit!.id, editingCell.cellKey, previous);
return buildSelectionIntent({ rowKey: row.rowKey, cellKey: editingCell.cellKey });
undo: async () =>
buildSelectionIntent({
rowKey: row.rowKey,
cellKey: editingCell.cellKey,
}),
persistent: {
undoIntent: () =>
buildSelectionIntent({
rowKey: row.rowKey,
cellKey: editingCell.cellKey,
}),
redoIntent: nextSelectionIntent,
},
};
setEditingCell(null);
@@ -1335,7 +1377,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
if (cell.kind === "deviceField" && row.device) {
const previous = getCurrentDraftForCell(row, editingCell.cellKey, cell.kind);
const next = editingCell.draft;
const command: HistoryCommand = {
label: "Gerätefeld bearbeiten",
@@ -1343,9 +1384,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await patchDeviceRow(row.device!.id, editingCell.cellKey, next);
return nextSelectionIntent();
},
undo: async () => {
await patchDeviceRow(row.device!.id, editingCell.cellKey, previous);
return buildSelectionIntent({ rowKey: `device:${row.device!.id}`, cellKey: editingCell.cellKey });
undo: async () =>
buildSelectionIntent({
rowKey: `device:${row.device!.id}`,
cellKey: editingCell.cellKey,
}),
persistent: {
undoIntent: () =>
buildSelectionIntent({
rowKey: `device:${row.device!.id}`,
cellKey: editingCell.cellKey,
}),
redoIntent: nextSelectionIntent,
},
};
setEditingCell(null);
@@ -1359,6 +1409,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const command: HistoryCommand = {
label: "Gerätezeile im Reservestromkreis erstellen",
redo: async () => {
const editValues = createValuesFromEditPatch(
buildDeviceRowEditPatch(editingCell.cellKey, next)
);
const created = (await createCircuitDeviceRow(row.circuit!.id, {
name: "Reserveverbraucher",
displayName: "Reserveverbraucher",
@@ -1367,9 +1420,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
powerPerUnit: 0,
simultaneityFactor: 1,
cosPhi: 1,
...editValues,
})) as { id: string };
createdRowId = created.id;
await patchDeviceRow(created.id, editingCell.cellKey, next);
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey };
return nextSelectionIntent();
},
+1 -3
View File
@@ -240,6 +240,7 @@ export interface CircuitTreeMigrationReportDto {
export interface CircuitTreeResponseDto {
circuitListId: string;
currentRevision: number;
sections: CircuitTreeSectionDto[];
migrationReport?: CircuitTreeMigrationReportDto;
}
@@ -264,8 +265,6 @@ export interface CreateCircuitInputDto {
remark?: string;
}
export type UpdateCircuitInputDto = Partial<CreateCircuitInputDto>;
export interface CreateCircuitDeviceRowInputDto {
linkedProjectDeviceId?: string;
name: string;
@@ -287,5 +286,4 @@ export interface CreateCircuitDeviceRowInputDto {
sortOrder?: number;
}
export type UpdateCircuitDeviceRowInputDto = Partial<CreateCircuitDeviceRowInputDto>;
import type { ProjectDeviceSyncField } from "../shared/constants/project-device-sync-fields";
+32 -12
View File
@@ -20,11 +20,17 @@ import type {
CircuitTreeCircuitDto,
CircuitTreeDeviceRowDto,
CreateCircuitInputDto,
UpdateCircuitInputDto,
CreateCircuitDeviceRowInputDto,
UpdateCircuitDeviceRowInputDto,
} from "../types";
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
import {
createCircuitUpdateProjectCommand,
type CircuitUpdatePatch,
} from "../../domain/models/circuit-project-command.model";
import {
createCircuitDeviceRowUpdateProjectCommand,
type CircuitDeviceRowUpdatePatch,
} from "../../domain/models/circuit-device-row-project-command.model";
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
@@ -180,11 +186,18 @@ export function createCircuitWithDeviceRows(
);
}
export function updateCircuitById(circuitId: string, input: UpdateCircuitInputDto) {
return request(`/api/circuits/${circuitId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
export function updateCircuitById(
projectId: string,
expectedRevision: number,
circuitId: string,
input: CircuitUpdatePatch
) {
return executeProjectCommand(
projectId,
expectedRevision,
createCircuitUpdateProjectCommand(circuitId, input),
"Stromkreisfeld bearbeiten"
);
}
export function deleteCircuitById(circuitId: string) {
@@ -206,11 +219,18 @@ export function createCircuitDeviceRow(circuitId: string, input: CreateCircuitDe
});
}
export function updateCircuitDeviceRowById(rowId: string, input: UpdateCircuitDeviceRowInputDto) {
return request(`/api/circuit-device-rows/${rowId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
export function updateCircuitDeviceRowById(
projectId: string,
expectedRevision: number,
rowId: string,
input: CircuitDeviceRowUpdatePatch
) {
return executeProjectCommand(
projectId,
expectedRevision,
createCircuitDeviceRowUpdateProjectCommand(rowId, input),
"Gerätezeilenfeld bearbeiten"
);
}
export function deleteCircuitDeviceRowById(rowId: string) {
+74
View File
@@ -1,4 +1,6 @@
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../types.js";
import type { CircuitUpdatePatch } from "../../domain/models/circuit-project-command.model.js";
import type { CircuitDeviceRowUpdatePatch } from "../../domain/models/circuit-device-row-project-command.model.js";
export type CellKey =
| "equipmentIdentifier"
@@ -189,6 +191,78 @@ export function parseNumeric(cellKey: CellKey, draft: string): number | undefine
return parsed;
}
export function buildCircuitEditPatch(
key: CellKey,
draft: string
): CircuitUpdatePatch {
if (key === "protectionSummary" || key === "cableSummary") {
return { remark: draft.trim() || null };
}
if (
key === "protectionRatedCurrent" ||
key === "cableLength" ||
key === "voltage"
) {
return { [key]: parseNumeric(key, draft) ?? null };
}
if (key === "isReserve") {
const normalized = draft.trim().toLowerCase();
return {
isReserve: ["1", "true", "yes", "ja"].includes(normalized),
};
}
if (key === "equipmentIdentifier") {
return { equipmentIdentifier: draft.trim() };
}
return { [key]: draft.trim() || null } as CircuitUpdatePatch;
}
export function buildDeviceRowEditPatch(
key: CellKey,
draft: string
): CircuitDeviceRowUpdatePatch {
if (
key === "quantity" ||
key === "powerPerUnit" ||
key === "simultaneityFactor"
) {
const value = parseNumeric(key, draft);
if (value === undefined) {
throw new Error("Dieses Zahlenfeld darf nicht leer sein.");
}
return { [key]: value };
}
if (key === "cosPhi") {
return { cosPhi: parseNumeric(key, draft) ?? null };
}
if (key === "roomSummary") {
const trimmed = draft.trim();
if (!trimmed) {
return {
roomNumberSnapshot: null,
roomNameSnapshot: null,
};
}
const firstSpace = trimmed.indexOf(" ");
return firstSpace > 0
? {
roomNumberSnapshot: trimmed.slice(0, firstSpace).trim(),
roomNameSnapshot: trimmed.slice(firstSpace + 1).trim() || null,
}
: {
roomNumberSnapshot: trimmed,
roomNameSnapshot: null,
};
}
if (key === "technicalName") {
return { name: draft.trim() };
}
if (key === "displayName") {
return { displayName: draft.trim() };
}
return { [key]: draft.trim() || null } as CircuitDeviceRowUpdatePatch;
}
export function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): GridValue {
switch (key) {
case "technicalName": return device.name;