Persist device row moves

This commit is contained in:
2026-07-25 21:09:02 +02:00
parent 2eba4ea75e
commit 08a2775a88
25 changed files with 391 additions and 1678 deletions
@@ -1,282 +0,0 @@
import crypto from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import type {
CircuitDeviceRowTransactionStore,
CreateCircuitDeviceRowTransactionInput,
CreateCircuitWithDeviceRowsTransactionInput,
MoveCircuitDeviceRowsTransactionInput,
} from "../../domain/ports/circuit-device-row-transaction.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuits } from "../schema/circuits.js";
import { toCircuitCreateValues } from "./circuit.persistence.js";
import {
toCircuitDeviceRowCreateValues,
} from "./circuit-device-row.persistence.js";
export class CircuitDeviceRowTransactionRepository
implements CircuitDeviceRowTransactionStore
{
constructor(private readonly database: AppDatabase) {}
createInCircuit(input: CreateCircuitDeviceRowTransactionInput) {
const id = crypto.randomUUID();
this.database.transaction((tx) => {
const [circuit] = tx
.select({ id: circuits.id })
.from(circuits)
.where(eq(circuits.id, input.circuitId))
.limit(1)
.all();
if (!circuit) {
throw new Error("Der Stromkreis ist ungültig.");
}
const existingRows = tx
.select({ sortOrder: circuitDeviceRows.sortOrder })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, input.circuitId))
.all();
const lastSortOrder = existingRows.reduce(
(highest, row) => Math.max(highest, row.sortOrder),
0
);
const sortOrder = input.sortOrder ?? lastSortOrder + 10;
tx
.insert(circuitDeviceRows)
.values(toCircuitDeviceRowCreateValues(id, { ...input, sortOrder }))
.run();
tx
.update(circuits)
.set({ isReserve: 0 })
.where(eq(circuits.id, input.circuitId))
.run();
});
return id;
}
createCircuitWithDeviceRows(input: CreateCircuitWithDeviceRowsTransactionInput) {
if (input.deviceRows.length === 0) {
throw new Error("Mindestens eine Gerätezeile ist erforderlich.");
}
const circuitId = crypto.randomUUID();
const rowIds = input.deviceRows.map(() => crypto.randomUUID());
this.database.transaction((tx) => {
tx
.insert(circuits)
.values(
toCircuitCreateValues(circuitId, {
...input.circuit,
isReserve: false,
})
)
.run();
for (let index = 0; index < input.deviceRows.length; index += 1) {
const row = input.deviceRows[index];
tx
.insert(circuitDeviceRows)
.values(
toCircuitDeviceRowCreateValues(rowIds[index], {
...row,
circuitId,
sortOrder: row.sortOrder ?? (index + 1) * 10,
})
)
.run();
}
});
return { circuitId, rowIds };
}
deleteFromCircuit(rowId: string, expectedCircuitId: string) {
this.database.transaction((tx) => {
const [row] = tx
.select({ id: circuitDeviceRows.id, circuitId: circuitDeviceRows.circuitId })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, rowId))
.limit(1)
.all();
if (!row || row.circuitId !== expectedCircuitId) {
throw new Error("Die Gerätezeile wurde vor dem Löschen verändert.");
}
const result = tx
.delete(circuitDeviceRows)
.where(
and(
eq(circuitDeviceRows.id, rowId),
eq(circuitDeviceRows.circuitId, expectedCircuitId)
)
)
.run();
if (result.changes !== 1) {
throw new Error("Die Gerätezeile konnte nicht gelöscht werden.");
}
const remainingRows = tx
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, expectedCircuitId))
.all();
tx
.update(circuits)
.set({ isReserve: remainingRows.length === 0 ? 1 : 0 })
.where(eq(circuits.id, expectedCircuitId))
.run();
});
}
moveRows(input: MoveCircuitDeviceRowsTransactionInput) {
if (input.rows.length === 0) {
throw new Error("Keine Gerätezeilen zum Verschieben angegeben.");
}
if (Boolean(input.targetCircuitId) === Boolean(input.createTargetCircuit)) {
throw new Error("Für die Mehrfachverschiebung muss genau ein Ziel angegeben werden.");
}
const rowIds = input.rows.map((row) => row.id);
if (new Set(rowIds).size !== rowIds.length) {
throw new Error("Gerätezeilen dürfen nicht mehrfach ausgewählt sein.");
}
const createdCircuitId = input.createTargetCircuit ? crypto.randomUUID() : undefined;
const targetCircuitId = input.targetCircuitId ?? createdCircuitId!;
this.database.transaction((tx) => {
const persistedRows = tx
.select({ id: circuitDeviceRows.id, circuitId: circuitDeviceRows.circuitId })
.from(circuitDeviceRows)
.where(inArray(circuitDeviceRows.id, rowIds))
.all();
if (persistedRows.length !== input.rows.length) {
throw new Error("Eine oder mehrere Gerätezeilen sind ungültig.");
}
const persistedRowsById = new Map(persistedRows.map((row) => [row.id, row]));
for (const expectedRow of input.rows) {
if (
persistedRowsById.get(expectedRow.id)?.circuitId !==
expectedRow.expectedCircuitId
) {
throw new Error(
"Eine Gerätezeile wurde vor Abschluss der Verschiebung verändert."
);
}
}
let targetCircuit: { id: string; circuitListId: string };
if (input.createTargetCircuit) {
tx
.insert(circuits)
.values({
id: targetCircuitId,
circuitListId: input.createTargetCircuit.circuitListId,
sectionId: input.createTargetCircuit.sectionId,
equipmentIdentifier: input.createTargetCircuit.equipmentIdentifier,
displayName: input.createTargetCircuit.displayName,
sortOrder: input.createTargetCircuit.sortOrder,
isReserve: 0,
})
.run();
targetCircuit = {
id: targetCircuitId,
circuitListId: input.createTargetCircuit.circuitListId,
};
} else {
const [persistedTargetCircuit] = tx
.select({ id: circuits.id, circuitListId: circuits.circuitListId })
.from(circuits)
.where(eq(circuits.id, targetCircuitId))
.limit(1)
.all();
if (!persistedTargetCircuit) {
throw new Error("Der Zielstromkreis ist ungültig.");
}
targetCircuit = persistedTargetCircuit;
}
const sourceCircuitIds = [
...new Set(input.rows.map((row) => row.expectedCircuitId)),
];
const sourceCircuits = tx
.select({ id: circuits.id, circuitListId: circuits.circuitListId })
.from(circuits)
.where(inArray(circuits.id, sourceCircuitIds))
.all();
if (sourceCircuits.length !== sourceCircuitIds.length) {
throw new Error("Einer oder mehrere Quellstromkreise sind ungültig.");
}
if (
sourceCircuits.some(
(circuit) => circuit.circuitListId !== targetCircuit.circuitListId
)
) {
throw new Error(
"Alle verschobenen Zeilen müssen zur Stromkreisliste des Ziels gehören."
);
}
const movedRowIdSet = new Set(rowIds);
const retainedTargetRows = tx
.select({ id: circuitDeviceRows.id, sortOrder: circuitDeviceRows.sortOrder })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, targetCircuitId))
.all()
.filter((row) => !movedRowIdSet.has(row.id));
const lastTargetSortOrder = retainedTargetRows.reduce(
(highest, row) => Math.max(highest, row.sortOrder),
0
);
for (let index = 0; index < input.rows.length; index += 1) {
const row = input.rows[index];
const result = tx
.update(circuitDeviceRows)
.set({
circuitId: targetCircuitId,
sortOrder: lastTargetSortOrder + (index + 1) * 10,
})
.where(
and(
eq(circuitDeviceRows.id, row.id),
eq(circuitDeviceRows.circuitId, row.expectedCircuitId)
)
)
.run();
if (result.changes !== 1) {
throw new Error(
"Eine Gerätezeile wurde vor Abschluss der Verschiebung verändert."
);
}
}
for (const sourceCircuitId of sourceCircuitIds) {
const remainingRows = tx
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, sourceCircuitId))
.all();
tx
.update(circuits)
.set({ isReserve: remainingRows.length === 0 ? 1 : 0 })
.where(eq(circuits.id, sourceCircuitId))
.run();
}
tx
.update(circuits)
.set({ isReserve: 0 })
.where(eq(circuits.id, targetCircuitId))
.run();
});
return {
movedRowIds: rowIds,
targetCircuitId,
createdCircuitId,
};
}
}
@@ -1,75 +0,0 @@
export interface CreateCircuitDeviceRowTransactionInput {
circuitId: string;
linkedProjectDeviceId?: string;
sortOrder?: number;
name: string;
displayName: string;
phaseType?: string;
connectionKind?: string;
costGroup?: string;
category?: string;
level?: string;
roomId?: string;
roomNumberSnapshot?: string;
roomNameSnapshot?: string;
quantity: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi?: number;
remark?: string;
overriddenFields?: string;
}
export interface CreateCircuitTransactionInput {
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName?: string;
sortOrder: number;
protectionType?: string;
protectionRatedCurrent?: number;
protectionCharacteristic?: string;
cableType?: string;
cableCrossSection?: string;
cableLength?: number;
voltage?: number;
controlRequirement?: string;
remark?: string;
rcdAssignment?: string;
terminalDesignation?: string;
status?: string;
}
export interface CreateCircuitWithDeviceRowsTransactionInput {
circuit: CreateCircuitTransactionInput;
deviceRows: Array<Omit<CreateCircuitDeviceRowTransactionInput, "circuitId">>;
}
export interface MoveCircuitDeviceRowsTransactionInput {
rows: Array<{ id: string; expectedCircuitId: string }>;
targetCircuitId?: string;
createTargetCircuit?: {
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName: string;
sortOrder: number;
};
}
export interface MoveCircuitDeviceRowsTransactionResult {
movedRowIds: string[];
targetCircuitId: string;
createdCircuitId?: string;
}
export interface CircuitDeviceRowTransactionStore {
createInCircuit(input: CreateCircuitDeviceRowTransactionInput): string;
createCircuitWithDeviceRows(
input: CreateCircuitWithDeviceRowsTransactionInput
): { circuitId: string; rowIds: string[] };
deleteFromCircuit(rowId: string, expectedCircuitId: string): void;
moveRows(
input: MoveCircuitDeviceRowsTransactionInput
): MoveCircuitDeviceRowsTransactionResult;
}
@@ -1,11 +1,7 @@
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
import type { CircuitDeviceRowTransactionStore } from "../ports/circuit-device-row-transaction.store.js";
import type { CircuitSectionTransactionStore } from "../ports/circuit-section-transaction.store.js";
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
import type {
MoveCircuitDeviceRowInput,
MoveCircuitDeviceRowsBulkInput,
ReorderSectionCircuitsInput,
UpdateSectionEquipmentIdentifiersInput,
} from "../../shared/validation/circuit.schemas.js";
@@ -14,46 +10,21 @@ import { CircuitNumberingService } from "./circuit-numbering.service.js";
export class CircuitWriteService {
private readonly circuitRepository: CircuitRepository;
private readonly circuitSectionRepository: CircuitSectionRepository;
private readonly deviceRowRepository: CircuitDeviceRowRepository;
private readonly deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
private readonly circuitSectionTransactionStore?: CircuitSectionTransactionStore;
private readonly numberingService: CircuitNumberingService;
constructor(deps?: {
circuitRepository?: CircuitRepository;
circuitSectionRepository?: CircuitSectionRepository;
deviceRowRepository?: CircuitDeviceRowRepository;
deviceRowTransactionStore?: CircuitDeviceRowTransactionStore;
circuitSectionTransactionStore?: CircuitSectionTransactionStore;
numberingService?: CircuitNumberingService;
}) {
this.circuitRepository = deps?.circuitRepository ?? new CircuitRepository();
this.circuitSectionRepository = deps?.circuitSectionRepository ?? new CircuitSectionRepository();
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
this.deviceRowTransactionStore = deps?.deviceRowTransactionStore;
this.circuitSectionTransactionStore = deps?.circuitSectionTransactionStore;
this.numberingService = deps?.numberingService ?? new CircuitNumberingService();
}
// Ensures writes never connect a section to the wrong circuit list.
private async assertSectionInList(sectionId: string, circuitListId: string) {
const section = await this.circuitSectionRepository.findById(sectionId);
if (!section) {
throw new Error("Invalid section id.");
}
if (section.circuitListId !== circuitListId) {
throw new Error("Section does not belong to circuit list.");
}
return section;
}
private getDeviceRowTransactionStore() {
if (!this.deviceRowTransactionStore) {
throw new Error("Circuit device-row transactions are not configured.");
}
return this.deviceRowTransactionStore;
}
private getCircuitSectionTransactionStore() {
if (!this.circuitSectionTransactionStore) {
throw new Error("Circuit-section transactions are not configured.");
@@ -61,157 +32,6 @@ export class CircuitWriteService {
return this.circuitSectionTransactionStore;
}
async deleteCircuit(circuitId: string) {
const current = await this.circuitRepository.findById(circuitId);
if (!current) {
throw new Error("Invalid circuit id.");
}
await this.circuitRepository.delete(circuitId);
}
async moveDeviceRow(rowId: string, input: MoveCircuitDeviceRowInput) {
const row = await this.deviceRowRepository.findById(rowId);
if (!row) {
throw new Error("Invalid device row id.");
}
const sourceCircuit = await this.circuitRepository.findById(row.circuitId);
if (!sourceCircuit) {
throw new Error("Invalid circuit id.");
}
let targetCircuit = input.targetCircuitId
? await this.circuitRepository.findById(input.targetCircuitId)
: null;
if (input.targetCircuitId && !targetCircuit) {
throw new Error("Invalid target circuit id.");
}
let createTargetCircuit:
| {
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName: string;
sortOrder: number;
}
| undefined;
// Placeholder-target move prepares a new circuit explicitly. Its creation and
// the row assignment are committed together without renumbering other circuits.
if (!targetCircuit) {
if (!input.targetSectionId || !input.createNewCircuit) {
throw new Error("Invalid move target.");
}
const section = await this.assertSectionInList(input.targetSectionId, sourceCircuit.circuitListId);
const nextIdentifier = await this.numberingService.getNextIdentifier(section.id);
const sectionCircuits = await this.circuitRepository.listBySection(section.id);
const nextSortOrder =
sectionCircuits.length > 0 ? Math.max(...sectionCircuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
createTargetCircuit = {
circuitListId: sourceCircuit.circuitListId,
sectionId: section.id,
equipmentIdentifier: nextIdentifier,
displayName: "Neuer Stromkreis",
sortOrder: nextSortOrder,
};
}
if (targetCircuit && targetCircuit.circuitListId !== sourceCircuit.circuitListId) {
throw new Error("Target circuit does not belong to same circuit list.");
}
if (targetCircuit?.id === sourceCircuit.id) {
return this.deviceRowRepository.findById(rowId);
}
this.getDeviceRowTransactionStore().moveRows({
rows: [{ id: row.id, expectedCircuitId: row.circuitId }],
targetCircuitId: targetCircuit?.id,
createTargetCircuit,
});
return this.deviceRowRepository.findById(rowId);
}
async moveDeviceRowsBulk(input: MoveCircuitDeviceRowsBulkInput) {
// Bulk move keeps input order and resolves all source circuits first so undo can
// restore per-source assignment deterministically.
const uniqueRowIds = [...new Set(input.rowIds)];
if (uniqueRowIds.length === 0) {
throw new Error("No device rows provided.");
}
const rows = [];
for (const rowId of uniqueRowIds) {
const row = await this.deviceRowRepository.findById(rowId);
if (!row) {
throw new Error("Invalid device row id.");
}
rows.push(row);
}
const sourceCircuits = new Map<string, Awaited<ReturnType<CircuitRepository["findById"]>>>();
for (const row of rows) {
if (!sourceCircuits.has(row.circuitId)) {
const circuit = await this.circuitRepository.findById(row.circuitId);
if (!circuit) {
throw new Error("Invalid circuit id.");
}
sourceCircuits.set(row.circuitId, circuit);
}
}
const referenceSourceCircuit = sourceCircuits.get(rows[0].circuitId)!;
let targetCircuit = input.targetCircuitId
? await this.circuitRepository.findById(input.targetCircuitId)
: null;
if (input.targetCircuitId && !targetCircuit) {
throw new Error("Invalid target circuit id.");
}
// Bulk placeholder move prepares exactly one new circuit as common target.
// Its actual creation happens together with the row moves in one transaction.
let createTargetCircuit:
| {
circuitListId: string;
sectionId: string;
equipmentIdentifier: string;
displayName: string;
sortOrder: number;
}
| undefined;
if (!targetCircuit) {
if (!input.targetSectionId || !input.createNewCircuit) {
throw new Error("Invalid move target.");
}
const section = await this.assertSectionInList(input.targetSectionId, referenceSourceCircuit.circuitListId);
const nextIdentifier = await this.numberingService.getNextIdentifier(section.id);
const sectionCircuits = await this.circuitRepository.listBySection(section.id);
const nextSortOrder =
sectionCircuits.length > 0 ? Math.max(...sectionCircuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
createTargetCircuit = {
circuitListId: referenceSourceCircuit.circuitListId,
sectionId: section.id,
equipmentIdentifier: nextIdentifier,
displayName: "Neuer Stromkreis",
sortOrder: nextSortOrder,
};
}
const targetCircuitListId = targetCircuit?.circuitListId ?? createTargetCircuit!.circuitListId;
for (const sourceCircuit of sourceCircuits.values()) {
if (sourceCircuit.circuitListId !== targetCircuitListId) {
throw new Error("All moved rows must belong to same circuit list as target.");
}
}
return this.getDeviceRowTransactionStore().moveRows({
rows: rows.map((row) => ({ id: row.id, expectedCircuitId: row.circuitId })),
targetCircuitId: targetCircuit?.id,
createTargetCircuit,
});
}
async getNextIdentifier(sectionId: string) {
return this.numberingService.getNextIdentifier(sectionId);
}
+99 -45
View File
@@ -27,6 +27,9 @@ import {
buildCircuitDeviceRowInsertSnapshot,
buildCircuitInsertSnapshot,
} from "../utils/circuit-structure-command";
import {
buildCircuitDeviceRowMoveAssignments,
} from "../utils/circuit-device-row-move-command";
import type {
CellKey,
CellKind,
@@ -40,7 +43,6 @@ import {
} from "../utils/circuit-grid-projection";
import type { VisibleGridRow } from "../utils/circuit-grid-projection";
import {
deleteCircuitById,
deleteCircuitCommand,
deleteCircuitDeviceRowCommand,
getCircuitTree,
@@ -48,8 +50,8 @@ import {
insertCircuitCommand,
insertCircuitDeviceRowCommand,
listProjectDevices,
moveCircuitDeviceRowsBulk,
moveCircuitDeviceRowById,
moveCircuitDeviceRowsCommand,
moveCircuitDeviceRowsToNewCircuitCommand,
reorderSectionCircuits,
renumberCircuitSection,
redoProjectCommand,
@@ -2230,8 +2232,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
});
}
// Handles device-row drag intent (single or bulk) and preserves enough source grouping
// for deterministic undo back to original circuits.
// Handles single and bulk device-row drag through persistent project history.
// Exact source and target positions make undo deterministic.
async function handleDeviceRowDropWithIntent(event: DragEvent<HTMLElement>, intent: DeviceRowMoveDropIntent) {
event.preventDefault();
event.stopPropagation();
@@ -2269,65 +2271,117 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return;
}
// Device-row drag supports bulk selection, but target intent is always explicit:
// move into existing circuit or create new circuit in a target section.
const circuits = (data?.sections ?? []).flatMap(
(section) => section.circuits
);
const label =
rowIds.length > 1
? `${rowIds.length} Gerätezeilen verschieben`
: "Gerätezeile verschieben";
if (intent.kind === "move-to-circuit") {
const groupedBySource = new Map<string, string[]>();
for (const rowId of rowIds) {
const source = sourceByRowId.get(rowId)!;
if (!groupedBySource.has(source)) {
groupedBySource.set(source, []);
}
groupedBySource.get(source)!.push(rowId);
const targetCircuit = circuits.find(
(circuit) => circuit.id === intent.circuitId
);
if (!targetCircuit) {
setError("Der Zielstromkreis ist ungültig.");
return;
}
const moves = buildCircuitDeviceRowMoveAssignments({
circuits,
rowIds,
targetCircuitId: targetCircuit.id,
targetDeviceRows: targetCircuit.deviceRows,
});
if (moves.length === 0) {
return;
}
await runCommand({
label: rowIds.length > 1 ? `${rowIds.length} Gerätezeilen verschieben` : "Gerätezeile verschieben",
label,
redo: async () => {
await moveCircuitDeviceRowsBulk({ rowIds, targetCircuitId: intent.circuitId });
const result = await moveCircuitDeviceRowsCommand(
projectId,
getExpectedProjectRevision(),
moves,
label
);
applyProjectCommandResult(result);
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null;
},
undo: async () => {
for (const [source, ids] of groupedBySource) {
await moveCircuitDeviceRowsBulk({ rowIds: ids, targetCircuitId: source });
}
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null;
undo: async () => null,
persistent: {
undoIntent: () => {
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null;
},
redoIntent: () => {
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null;
},
},
});
return;
}
let createdCircuitId: string | null = null;
const groupedBySource = new Map<string, string[]>();
for (const rowId of rowIds) {
const source = sourceByRowId.get(rowId)!;
if (!groupedBySource.has(source)) {
groupedBySource.set(source, []);
}
groupedBySource.get(source)!.push(rowId);
const targetSection = data?.sections.find(
(section) => section.id === intent.sectionId
);
if (!targetSection) {
setError("Der Zielbereich ist ungültig.");
return;
}
const newCircuitLabel =
rowIds.length > 1
? `${rowIds.length} Gerätezeilen in neuen Stromkreis verschieben`
: "Gerätezeile in neuen Stromkreis verschieben";
await runCommand({
label: rowIds.length > 1 ? `${rowIds.length} Gerätezeilen in neuen Stromkreis verschieben` : "Gerätezeile in neuen Stromkreis verschieben",
label: newCircuitLabel,
redo: async () => {
const moved = await moveCircuitDeviceRowsBulk({
rowIds,
targetSectionId: intent.sectionId,
createNewCircuit: true,
const next = await getNextCircuitIdentifier(intent.sectionId);
const sortOrder =
targetSection.circuits.length > 0
? Math.max(
...targetSection.circuits.map(
(circuit) => circuit.sortOrder
)
) + 10
: 10;
const targetCircuit = createCircuitSnapshot({
sectionId: intent.sectionId,
equipmentIdentifier: next.nextIdentifier,
displayName: "Neuer Stromkreis",
sortOrder,
isReserve: true,
});
createdCircuitId = moved.createdCircuitId ?? null;
const moves = buildCircuitDeviceRowMoveAssignments({
circuits,
rowIds,
targetCircuitId: targetCircuit.id,
targetDeviceRows: [],
});
const result =
await moveCircuitDeviceRowsToNewCircuitCommand(
projectId,
getExpectedProjectRevision(),
targetCircuit,
moves,
newCircuitLabel
);
applyProjectCommandResult(result);
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null;
},
undo: async () => {
for (const [source, ids] of groupedBySource) {
await moveCircuitDeviceRowsBulk({ rowIds: ids, targetCircuitId: source });
}
if (createdCircuitId) {
await deleteCircuitById(createdCircuitId);
}
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null;
undo: async () => null,
persistent: {
undoIntent: () => {
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null;
},
redoIntent: () => {
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null;
},
},
});
}
+37 -24
View File
@@ -33,6 +33,9 @@ import type {
import type {
CircuitSnapshot,
} from "../../domain/models/circuit-structure-project-command.model";
import type {
CircuitDeviceRowMoveAssignment,
} from "../../domain/models/circuit-device-row-move-project-command.model";
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
@@ -212,12 +215,6 @@ export function deleteCircuitCommand(
);
}
export function deleteCircuitById(circuitId: string) {
return request<void>(`/api/circuits/${circuitId}`, {
method: "DELETE",
});
}
export function getNextCircuitIdentifier(sectionId: string) {
return request<{ sectionId: string; nextIdentifier: string }>(
`/api/circuit-sections/${sectionId}/next-identifier`
@@ -275,28 +272,44 @@ export function deleteCircuitDeviceRowCommand(
);
}
export function moveCircuitDeviceRowById(
rowId: string,
input: { targetCircuitId?: string; targetSectionId?: string; createNewCircuit?: boolean }
export function moveCircuitDeviceRowsCommand(
projectId: string,
expectedRevision: number,
moves: CircuitDeviceRowMoveAssignment[],
description: string
) {
return request(`/api/circuit-device-rows/${rowId}/move`, {
method: "PATCH",
body: JSON.stringify(input),
});
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit-device-row.move",
payload: { moves },
},
description
);
}
export function moveCircuitDeviceRowsBulk(input: {
rowIds: string[];
targetCircuitId?: string;
targetSectionId?: string;
createNewCircuit?: boolean;
}) {
return request<{ movedRowIds: string[]; targetCircuitId: string; createdCircuitId?: string }>(
"/api/circuit-device-rows/move-bulk",
export function moveCircuitDeviceRowsToNewCircuitCommand(
projectId: string,
expectedRevision: number,
targetCircuit: CircuitSnapshot,
moves: CircuitDeviceRowMoveAssignment[],
description: string
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
method: "PATCH",
body: JSON.stringify(input),
}
schemaVersion: 1,
type: "circuit-device-row.move-with-new-circuit",
payload: {
targetCircuitAction: "create",
targetCircuit,
moves,
},
},
description
);
}
@@ -0,0 +1,66 @@
import type {
CircuitTreeCircuitDto,
CircuitTreeDeviceRowDto,
} from "../types.js";
import type {
CircuitDeviceRowMoveAssignment,
} from "../../domain/models/circuit-device-row-move-project-command.model.js";
export function buildCircuitDeviceRowMoveAssignments(input: {
circuits: CircuitTreeCircuitDto[];
rowIds: string[];
targetCircuitId: string;
targetDeviceRows: CircuitTreeDeviceRowDto[];
}): CircuitDeviceRowMoveAssignment[] {
const { circuits, rowIds, targetCircuitId, targetDeviceRows } =
input;
if (new Set(rowIds).size !== rowIds.length) {
throw new Error("Gerätezeilen dürfen nicht mehrfach ausgewählt sein.");
}
const sourceByRowId = new Map<
string,
{ circuitId: string; sortOrder: number }
>();
for (const circuit of circuits) {
for (const row of circuit.deviceRows) {
sourceByRowId.set(row.id, {
circuitId: circuit.id,
sortOrder: row.sortOrder,
});
}
}
const movedRowIds = new Set(rowIds);
const retainedTargetRows = targetDeviceRows.filter(
(row) => !movedRowIds.has(row.id)
);
const lastTargetSortOrder = retainedTargetRows.reduce(
(highest, row) => Math.max(highest, row.sortOrder),
0
);
return rowIds.flatMap((rowId, index) => {
const source = sourceByRowId.get(rowId);
if (!source) {
throw new Error("Eine ausgewählte Gerätezeile wurde nicht gefunden.");
}
const targetSortOrder =
lastTargetSortOrder + (index + 1) * 10;
if (
source.circuitId === targetCircuitId &&
source.sortOrder === targetSortOrder
) {
return [];
}
return [
{
rowId,
expectedCircuitId: source.circuitId,
expectedSortOrder: source.sortOrder,
targetCircuitId,
targetSortOrder,
},
];
});
}
@@ -1,9 +1,7 @@
import { CircuitWriteService } from "../../domain/services/circuit-write.service.js";
import { db } from "../../db/client.js";
import { CircuitDeviceRowTransactionRepository } from "../../db/repositories/circuit-device-row-transaction.repository.js";
import { CircuitSectionTransactionRepository } from "../../db/repositories/circuit-section-transaction.repository.js";
export const circuitWriteService = new CircuitWriteService({
deviceRowTransactionStore: new CircuitDeviceRowTransactionRepository(db),
circuitSectionTransactionStore: new CircuitSectionTransactionRepository(db),
});
@@ -1,39 +0,0 @@
import type { Request, Response } from "express";
import { circuitWriteService } from "../composition/circuit-write-service.js";
import {
moveCircuitDeviceRowsBulkSchema,
moveCircuitDeviceRowSchema,
} from "../../shared/validation/circuit.schemas.js";
export async function moveCircuitDeviceRow(req: Request, res: Response) {
const { rowId } = req.params;
if (typeof rowId !== "string") {
return res.status(400).json({ error: "Invalid rowId" });
}
const parsed = moveCircuitDeviceRowSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const moved = await circuitWriteService.moveDeviceRow(rowId, parsed.data);
return res.json(moved);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to move device row." });
}
}
export async function moveCircuitDeviceRowsBulk(req: Request, res: Response) {
const parsed = moveCircuitDeviceRowsBulkSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const moved = await circuitWriteService.moveDeviceRowsBulk(parsed.data);
return res.json(moved);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to move device rows." });
}
}
@@ -1,20 +1,6 @@
import type { Request, Response } from "express";
import { circuitWriteService } from "../composition/circuit-write-service.js";
export async function deleteCircuit(req: Request, res: Response) {
const { circuitId } = req.params;
if (typeof circuitId !== "string") {
return res.status(400).json({ error: "Invalid circuitId" });
}
try {
await circuitWriteService.deleteCircuit(circuitId);
return res.status(204).send();
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to delete circuit." });
}
}
export async function getNextCircuitIdentifier(req: Request, res: Response) {
const { sectionId } = req.params;
if (typeof sectionId !== "string") {
-2
View File
@@ -1,5 +1,4 @@
import express from "express";
import { circuitDeviceRowRouter } from "./routes/circuit-device-row.routes.js";
import { circuitRouter } from "./routes/circuit.routes.js";
import { circuitSectionRouter } from "./routes/circuit-section.routes.js";
import { globalDeviceRouter } from "./routes/global-device.routes.js";
@@ -18,7 +17,6 @@ app.get("/health", (_req, res) => {
app.use("/api/projects", projectRouter);
app.use("/api", circuitRouter);
app.use("/api", circuitDeviceRowRouter);
app.use("/api", circuitSectionRouter);
app.use("/api/global-devices", globalDeviceRouter);
app.use("/api/project-devices", projectDeviceRouter);
@@ -1,10 +0,0 @@
import { Router } from "express";
import {
moveCircuitDeviceRowsBulk,
moveCircuitDeviceRow,
} from "../controllers/circuit-device-row.controller.js";
export const circuitDeviceRowRouter = Router();
circuitDeviceRowRouter.patch("/circuit-device-rows/move-bulk", moveCircuitDeviceRowsBulk);
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId/move", moveCircuitDeviceRow);
-2
View File
@@ -1,11 +1,9 @@
import { Router } from "express";
import {
deleteCircuit,
getNextCircuitIdentifier,
} from "../controllers/circuit.controller.js";
export const circuitRouter = Router();
circuitRouter.delete("/circuits/:circuitId", deleteCircuit);
circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier);
-29
View File
@@ -1,32 +1,5 @@
import { z } from "zod";
export const moveCircuitDeviceRowSchema = z
.object({
targetCircuitId: z.string().min(1).optional(),
targetSectionId: z.string().min(1).optional(),
createNewCircuit: z.boolean().optional(),
})
.refine(
(value) =>
Boolean(value.targetCircuitId) ||
(Boolean(value.targetSectionId) && value.createNewCircuit === true),
{ message: "Either targetCircuitId or targetSectionId+createNewCircuit=true is required." }
);
export const moveCircuitDeviceRowsBulkSchema = z
.object({
rowIds: z.array(z.string().min(1)).min(1),
targetCircuitId: z.string().min(1).optional(),
targetSectionId: z.string().min(1).optional(),
createNewCircuit: z.boolean().optional(),
})
.refine(
(value) =>
Boolean(value.targetCircuitId) ||
(Boolean(value.targetSectionId) && value.createNewCircuit === true),
{ message: "Either targetCircuitId or targetSectionId+createNewCircuit=true is required." }
);
export const reorderSectionCircuitsSchema = z.object({
orderedCircuitIds: z.array(z.string().min(1)).min(1),
});
@@ -42,7 +15,5 @@ export const updateSectionEquipmentIdentifiersSchema = z.object({
.min(1),
});
export type MoveCircuitDeviceRowInput = z.infer<typeof moveCircuitDeviceRowSchema>;
export type MoveCircuitDeviceRowsBulkInput = z.infer<typeof moveCircuitDeviceRowsBulkSchema>;
export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>;
export type UpdateSectionEquipmentIdentifiersInput = z.infer<typeof updateSectionEquipmentIdentifiersSchema>;