Persist circuit cell edits
This commit is contained in:
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user