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
+12 -9
View File
@@ -220,15 +220,18 @@ After saving, the row becomes linked to the new project device.
## Undo / Redo ## Undo / Redo
Session-local UI undo/redo exists for structural and destructive operations. The editor's visible undo/redo stack remains session-local while structural and
The server already persists immutable revisions and project-wide undo/redo destructive operations are being migrated. Existing Circuit and
eligibility for the first commands. Public command, undo and redo endpoints CircuitDeviceRow cell edits already execute persistent project commands and
exist for Circuit and CircuitDeviceRow field updates as well as their toolbar undo/redo actions use the project-wide history endpoints. The
Circuit/CircuitDeviceRow insertion and deletion. Complete command coverage and tree response supplies the optimistic `currentRevision`; the obsolete direct
the UI cutover remain future work. CircuitDeviceRow moves between existing PATCH routes for these fields are removed. Reloading the page does not yet
circuits and moves that create one new placeholder target circuit are also reconstruct the visible editor stack. Public command, undo and redo endpoints
persisted. The latter stores the complete empty target snapshot so undo can also exist for Circuit/CircuitDeviceRow insertion and deletion.
restore the rows and remove only the unchanged generated circuit. Complete CircuitDeviceRow moves between existing circuits and moves that create one new
placeholder target circuit are persisted. The latter stores the complete empty
target snapshot so undo can restore the rows and remove only the unchanged
generated circuit. Complete
in-section Circuit reorders are persisted separately and change sort positions in-section Circuit reorders are persisted separately and change sort positions
without changing equipment identifiers. Explicit complete-section renumbering without changing equipment identifiers. Explicit complete-section renumbering
is persisted through a separate collision-safe command and is never triggered is persisted through a separate collision-safe command and is never triggered
+12 -9
View File
@@ -5,9 +5,9 @@
The circuit-first editor uses tree, circuit, row and project-device endpoints. The circuit-first editor uses tree, circuit, row and project-device endpoints.
All paths below are mounted below `/api`. There is no Consumer application API. All paths below are mounted below `/api`. There is no Consumer application API.
Project responses from `GET /projects` and `GET /projects/:projectId` include Project responses from `GET /projects`, `GET /projects/:projectId` and the
`currentRevision`. Versioned project commands use this value for optimistic circuit-tree endpoint include `currentRevision`. Versioned project commands use
concurrency checks. this value for optimistic concurrency checks.
### Project Commands and History ### Project Commands and History
@@ -35,8 +35,9 @@ with stable ids; delete commands include the expected parent identity. Circuit
snapshots contain zero, one or multiple complete device rows. Move commands snapshots contain zero, one or multiple complete device rows. Move commands
contain each row's expected and target circuit plus its exact expected and contain each row's expected and target circuit plus its exact expected and
target sort order. This makes deletion and moves undoable without generating target sort order. This makes deletion and moves undoable without generating
replacement identities or renumbering circuits. The editor does not consume replacement identities or renumbering circuits. Existing Circuit and
these endpoints yet. CircuitDeviceRow cell edits in the editor consume these endpoints; the
remaining structural editor writes are still being migrated.
`circuit-device-row.move` targets existing circuits in the same circuit list. `circuit-device-row.move` targets existing circuits in the same circuit list.
`circuit-device-row.move-with-new-circuit` atomically creates exactly one `circuit-device-row.move-with-new-circuit` atomically creates exactly one
@@ -137,6 +138,7 @@ Response sketch:
```json ```json
{ {
"circuitListId": "cl_1", "circuitListId": "cl_1",
"currentRevision": 12,
"sections": [ "sections": [
{ {
"id": "sec_1", "id": "sec_1",
@@ -167,8 +169,6 @@ Response sketch:
- create circuit in list/section - create circuit in list/section
- `POST /projects/:projectId/circuit-lists/:circuitListId/circuits-with-device-rows` - `POST /projects/:projectId/circuit-lists/:circuitListId/circuits-with-device-rows`
- atomically create one circuit and one or more initial device rows - atomically create one circuit and one or more initial device rows
- `PATCH /circuits/:circuitId`
- update circuit-level fields (BMK, protection, cable, reserve flag, etc.)
- `DELETE /circuits/:circuitId` - `DELETE /circuits/:circuitId`
- delete circuit (and related rows via DB relations) - delete circuit (and related rows via DB relations)
- `GET /circuit-sections/:sectionId/next-identifier` - `GET /circuit-sections/:sectionId/next-identifier`
@@ -189,11 +189,14 @@ Request sketch (`POST .../circuits`):
- `POST /circuits/:circuitId/device-rows` - `POST /circuits/:circuitId/device-rows`
- create row inside a circuit - create row inside a circuit
- `PATCH /circuit-device-rows/:rowId`
- update row values
- `DELETE /circuit-device-rows/:rowId` - `DELETE /circuit-device-rows/:rowId`
- delete row - delete row
Circuit and device-row field updates are available only as versioned
`circuit.update` and `circuit-device-row.update` commands through
`POST /projects/:projectId/commands`. There are no direct field-update PATCH
routes.
### Move Device Row ### Move Device Row
- `PATCH /circuit-device-rows/:rowId/move` - `PATCH /circuit-device-rows/:rowId/move`
+12 -4
View File
@@ -11,7 +11,10 @@ Inline cells are static text by default. A cell enters edit mode by:
`Enter` confirms changes. `Escape` cancels current edit draft. `Tab` and `Shift+Tab` confirm and move to next/previous editable cell. `Enter` confirms changes. `Escape` cancels current edit draft. `Tab` and `Shift+Tab` confirm and move to next/previous editable cell.
Committed cell edits are persisted as partial database updates. Fields that were not part of the edit are not written back from an older read and therefore cannot overwrite a concurrent change to another field. Committed cells on existing circuits and device rows are persisted as typed
project commands with an optimistic project revision. Only the edited fields
are included, so an older tree response cannot write unrelated fields back.
The server records the inverse in the same transaction.
## `selectedCell` vs `editingCell` ## `selectedCell` vs `editingCell`
@@ -119,7 +122,11 @@ Cross-section device moves show confirmation-required feedback before drop. The
## Undo/Redo ## Undo/Redo
Undo/redo wraps editor operations as command objects with async `redo`/`undo` and reloads tree after each command. Undo/redo wraps editor operations as command objects and reloads the tree after
each command. Existing Circuit and device-row cell edits execute persistent
project commands; their toolbar undo/redo calls the project-wide server
history. Other covered operations still use their existing direct API writes
and session-local inverse callbacks during the cutover.
Covered operations include: Covered operations include:
@@ -132,5 +139,6 @@ Covered operations include:
Current limitations: Current limitations:
- session-local only - the visible editor stack is still session-local and is empty after reload
- no persisted history across browser reload - structural and destructive editor actions are not all connected to the
persistent command boundary yet
@@ -1,9 +1,12 @@
# Circuit List Editor Known Limitations # Circuit List Editor Known Limitations
- The circuit-list editor still exposes only its session-local undo/redo UI. - Existing Circuit and CircuitDeviceRow cell edits use persistent project
commands and project-wide toolbar undo/redo. The editor's visible stack is
still session-local, is empty after reload and still contains direct
structural/destructive operations during the cutover.
- Server-side project revisions and persistent undo/redo eligibility exist for - Server-side project revisions and persistent undo/redo eligibility exist for
the command types documented in `circuit-list-editor-api.md`; command coverage the command types documented in `circuit-list-editor-api.md`; complete editor
and the editor UI cutover are not complete. command coverage and history reconstruction are not complete.
- Named logical project snapshots and restore are not implemented yet. - Named logical project snapshots and restore are not implemented yet.
- No Revit/CSV/IFCGUID import and export workflow is implemented yet. - No Revit/CSV/IFCGUID import and export workflow is implemented yet.
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime. - Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
+12 -5
View File
@@ -41,7 +41,7 @@ liegt über einen Host-Mount außerhalb des Containers.
- `src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx` - `src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx`
unterstützte Editorroute unterstützte Editorroute
- `src/frontend/components/circuit-tree-editor.tsx` Editorzustand, - `src/frontend/components/circuit-tree-editor.tsx` Editorzustand,
Befehlsausführung, Drag-and-drop und sitzungslokales Undo/Redo Befehlsausführung, Drag-and-drop und der schrittweise Historien-Cutover
- `src/frontend/components/circuit-grid-*.ts` reine Grid-Projektion, - `src/frontend/components/circuit-grid-*.ts` reine Grid-Projektion,
Zellbesitz, Einfügen und Sicherheitsregeln Zellbesitz, Einfügen und Sicherheitsregeln
- `src/frontend/utils/api.ts` typisierte Frontend-API-Aufrufe - `src/frontend/utils/api.ts` typisierte Frontend-API-Aufrufe
@@ -62,7 +62,8 @@ liegt über einen Host-Mount außerhalb des Containers.
6. Das Frontend lädt den Circuit-Tree neu und stellt Auswahl beziehungsweise 6. Das Frontend lädt den Circuit-Tree neu und stellt Auswahl beziehungsweise
Viewport soweit möglich wieder her. Viewport soweit möglich wieder her.
Die React-Historie ist derzeit sitzungslokal. Das Datenmodell besitzt bereits Der sichtbare React-Historiestapel ist während des schrittweisen Cutovers noch
sitzungslokal. Das Datenmodell besitzt
einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set- einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set-
Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch
und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen
@@ -85,8 +86,13 @@ inversen Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte
wiederherstellt. `circuit.insert` und `circuit.delete` behandeln einen wiederherstellt. `circuit.insert` und `circuit.delete` behandeln einen
Stromkreis mit null, einer oder mehreren Gerätezeilen als vollständigen Block. Stromkreis mit null, einer oder mehreren Gerätezeilen als vollständigen Block.
Undo bewahrt dabei sämtliche Circuit-/Row-UUIDs und ändert keine Undo bewahrt dabei sämtliche Circuit-/Row-UUIDs und ändert keine
Betriebsmittelkennzeichen. Das Grid verwendet diese Endpunkte noch nicht; seine Betriebsmittelkennzeichen. Bestehende Circuit- und Gerätezeilen-Zelländerungen
sichtbare Historie bleibt daher sitzungslokal. verwendet das Grid bereits über die öffentliche Command-Grenze. Der Tree liefert
dazu `currentRevision`; Undo/Redo für diese Zellaktionen läuft über die
projektweite Serverhistorie. Die direkten PATCH-Endpunkte für diese Felder sind
entfernt. Andere Editoraktionen verbleiben vorerst im sichtbaren
sitzungslokalen Stack; nach einem Reload wird dieser noch nicht aus der
Serverhistorie rekonstruiert.
`circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen `circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen
zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue
Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch; Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch;
@@ -159,7 +165,8 @@ bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
## Noch nicht unterstützt ## Noch nicht unterstützt
- persistentes Undo/Redo und Projektversionen - vollständiges persistentes Undo/Redo, sichtbare Historie nach Reload und
benannte Projektstände
- Mehrbenutzerbetrieb und Konfliktauflösung - Mehrbenutzerbetrieb und Konfliktauflösung
- Revit-/CSV-/IFCGUID-Round-trip - Revit-/CSV-/IFCGUID-Round-trip
- vollständige elektrische Dimensionierung - vollständige elektrische Dimensionierung
@@ -173,6 +173,11 @@ Completed foundation:
to SQLite to SQLite
- public command, undo and redo endpoints require an expected revision; undo - public command, undo and redo endpoints require an expected revision; undo
and redo reconstruct the eligible persisted inverse or forward command and redo reconstruct the eligible persisted inverse or forward command
- the circuit tree returns the project's current revision; existing Circuit
and CircuitDeviceRow cell edits use the public command endpoint, while their
toolbar undo/redo actions use project-wide persistent history
- obsolete direct Circuit and CircuitDeviceRow field-update PATCH routes are
removed so these editor writes cannot bypass revision history
- `circuit-device-row.insert` and `circuit-device-row.delete` preserve stable - `circuit-device-row.insert` and `circuit-device-row.delete` preserve stable
row ids, complete deleted-row snapshots and circuit reserve state through row ids, complete deleted-row snapshots and circuit reserve state through
atomic insert/delete, revision and stack transitions atomic insert/delete, revision and stack transitions
@@ -210,10 +215,10 @@ Remaining constraints before completing persistent history:
is complete is complete
- route the remaining structural and destructive domain - route the remaining structural and destructive domain
writes through the shared command, revision and stack transaction boundary writes through the shared command, revision and stack transaction boundary
- replace the session-local frontend command stack only after server-side - finish routing structural and destructive circuit-editor operations through
command coverage is complete for structural and destructive circuit-editor persistent commands, then reconstruct the visible frontend history state
operations; the ProjectDevice synchronization UI already uses its persistent after reload; Circuit/CircuitDeviceRow cell edits and ProjectDevice
command and project-wide undo endpoint synchronization already use persistent commands and project-wide undo
- do not model IFCGUID as an overloaded circuit equipment identifier - do not model IFCGUID as an overloaded circuit equipment identifier
- keep database backup/restore checks separate from project history tests - keep database backup/restore checks separate from project history tests
+8 -4
View File
@@ -390,6 +390,9 @@ Implemented foundation:
- a central application dispatcher reconstructs persisted commands and exposes - a central application dispatcher reconstructs persisted commands and exposes
optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow
field updates field updates
- the circuit tree exposes `currentRevision`; existing Circuit and
CircuitDeviceRow cell edits use those optimistic commands and project-wide
undo/redo instead of direct field-update PATCH routes
- typed CircuitDeviceRow insert/delete commands preserve complete row snapshots - typed CircuitDeviceRow insert/delete commands preserve complete row snapshots
and stable ids; row mutation, circuit reserve state, revision and history and stable ids; row mutation, circuit reserve state, revision and history
stack transition commit or roll back together stack transition commit or roll back together
@@ -424,10 +427,11 @@ Implemented foundation:
- integration coverage verifies sequential revisions, stale-command rejection - integration coverage verifies sequential revisions, stale-command rejection
and rollback after a forced late persistence failure and rollback after a forced late persistence failure
ProjectDevice CRUD, synchronization and disconnect on the project page are ProjectDevice CRUD, synchronization and disconnect on the project page and
connected to this history boundary. The circuit-list editor and the remaining existing Circuit/CircuitDeviceRow cell edits in the circuit-list editor are
domain writes are not fully connected; its visible undo/redo therefore remains connected to this history boundary. Remaining structural editor writes are not
session-local until the remaining tasks are implemented. fully connected; the editor's visible undo/redo stack therefore remains
session-local and is not reconstructed after reload.
Acceptance criteria: Acceptance criteria:
@@ -7,15 +7,12 @@ import { circuits } from "../schema/circuits.js";
import { distributionBoards } from "../schema/distribution-boards.js"; import { distributionBoards } from "../schema/distribution-boards.js";
import { import {
toCircuitDeviceRowCreateValues, toCircuitDeviceRowCreateValues,
toCircuitDeviceRowPatchValues,
toCircuitDeviceRowUpdateValues, toCircuitDeviceRowUpdateValues,
type CircuitDeviceRowCreateInput, type CircuitDeviceRowCreateInput,
type CircuitDeviceRowPatchInput,
type CircuitDeviceRowUpdateInput, type CircuitDeviceRowUpdateInput,
} from "./circuit-device-row.persistence.js"; } from "./circuit-device-row.persistence.js";
export type { export type {
CircuitDeviceRowCreateInput, CircuitDeviceRowCreateInput,
CircuitDeviceRowPatchInput,
CircuitDeviceRowUpdateInput, CircuitDeviceRowUpdateInput,
} from "./circuit-device-row.persistence.js"; } from "./circuit-device-row.persistence.js";
export { toCircuitDeviceRowCreateValues } from "./circuit-device-row.persistence.js"; export { toCircuitDeviceRowCreateValues } from "./circuit-device-row.persistence.js";
@@ -102,14 +99,6 @@ export class CircuitDeviceRowRepository {
.where(eq(circuitDeviceRows.id, rowId)); .where(eq(circuitDeviceRows.id, rowId));
} }
async updateFields(rowId: string, input: CircuitDeviceRowPatchInput) {
const values = toCircuitDeviceRowPatchValues(input);
if (Object.keys(values).length === 0) {
return;
}
await db.update(circuitDeviceRows).set(values).where(eq(circuitDeviceRows.id, rowId));
}
async delete(rowId: string) { async delete(rowId: string) {
await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)); await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId));
} }
-12
View File
@@ -4,18 +4,14 @@ import { db } from "../client.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
import { import {
toCircuitCreateValues, toCircuitCreateValues,
toCircuitPatchValues,
type CircuitCreatePersistenceInput, type CircuitCreatePersistenceInput,
type CircuitPatchPersistenceInput,
} from "./circuit.persistence.js"; } from "./circuit.persistence.js";
export type { export type {
CircuitCreatePersistenceInput, CircuitCreatePersistenceInput,
CircuitPatchPersistenceInput,
} from "./circuit.persistence.js"; } from "./circuit.persistence.js";
export { export {
toCircuitCreateValues, toCircuitCreateValues,
toCircuitPatchValues,
} from "./circuit.persistence.js"; } from "./circuit.persistence.js";
export class CircuitRepository { export class CircuitRepository {
@@ -84,14 +80,6 @@ export class CircuitRepository {
.where(eq(circuits.id, circuitId)); .where(eq(circuits.id, circuitId));
} }
async updateFields(circuitId: string, input: CircuitPatchPersistenceInput) {
const values = toCircuitPatchValues(input);
if (Object.keys(values).length === 0) {
return;
}
await db.update(circuits).set(values).where(eq(circuits.id, circuitId));
}
async delete(circuitId: string) { async delete(circuitId: string) {
await db.delete(circuits).where(eq(circuits.id, circuitId)); await db.delete(circuits).where(eq(circuits.id, circuitId));
} }
+1
View File
@@ -57,6 +57,7 @@ export interface CircuitTreeSectionBlock {
export interface CircuitTreeResponse { export interface CircuitTreeResponse {
circuitListId: string; circuitListId: string;
currentRevision: number;
sections: CircuitTreeSectionBlock[]; sections: CircuitTreeSectionBlock[];
} }
@@ -13,11 +13,8 @@ import type {
MoveCircuitDeviceRowsBulkInput, MoveCircuitDeviceRowsBulkInput,
ReorderSectionCircuitsInput, ReorderSectionCircuitsInput,
UpdateSectionEquipmentIdentifiersInput, UpdateSectionEquipmentIdentifiersInput,
UpdateCircuitDeviceRowInput,
UpdateCircuitInput,
} from "../../shared/validation/circuit.schemas.js"; } from "../../shared/validation/circuit.schemas.js";
import { CircuitNumberingService } from "./circuit-numbering.service.js"; import { CircuitNumberingService } from "./circuit-numbering.service.js";
import { deriveOverriddenFieldsForLocalEdit } from "./project-device-overrides.js";
export class CircuitWriteService { export class CircuitWriteService {
private readonly circuitRepository: CircuitRepository; private readonly circuitRepository: CircuitRepository;
@@ -191,21 +188,6 @@ export class CircuitWriteService {
}; };
} }
async updateCircuit(circuitId: string, input: UpdateCircuitInput) {
const current = await this.circuitRepository.findById(circuitId);
if (!current) {
throw new Error("Invalid circuit id.");
}
const sectionId = input.sectionId ?? current.sectionId;
const equipmentIdentifier = input.equipmentIdentifier ?? current.equipmentIdentifier;
await this.assertSectionInList(sectionId, current.circuitListId);
await this.assertUniqueEquipmentIdentifier(current.circuitListId, equipmentIdentifier, circuitId);
await this.circuitRepository.updateFields(circuitId, input);
return this.circuitRepository.findById(circuitId);
}
async deleteCircuit(circuitId: string) { async deleteCircuit(circuitId: string) {
const current = await this.circuitRepository.findById(circuitId); const current = await this.circuitRepository.findById(circuitId);
if (!current) { if (!current) {
@@ -246,23 +228,6 @@ export class CircuitWriteService {
return this.deviceRowRepository.findById(rowId); return this.deviceRowRepository.findById(rowId);
} }
async updateDeviceRow(rowId: string, input: UpdateCircuitDeviceRowInput) {
const current = await this.deviceRowRepository.findById(rowId);
if (!current) {
throw new Error("Invalid device row id.");
}
await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId);
const overriddenFields = deriveOverriddenFieldsForLocalEdit(
current,
input
);
await this.deviceRowRepository.updateFields(rowId, {
...input,
...(overriddenFields !== undefined ? { overriddenFields } : {}),
});
return this.deviceRowRepository.findById(rowId);
}
async deleteDeviceRow(rowId: string) { async deleteDeviceRow(rowId: string) {
const current = await this.deviceRowRepository.findById(rowId); const current = await this.deviceRowRepository.findById(rowId);
if (!current) { if (!current) {
+117 -64
View File
@@ -17,12 +17,11 @@ import {
} from "../utils/circuit-grid-safety"; } from "../utils/circuit-grid-safety";
import { import {
allColumns, allColumns,
buildCircuitEditPatch,
buildDeviceRowEditPatch,
defaultVisibleColumnKeys, defaultVisibleColumnKeys,
deviceFieldKeys, deviceFieldKeys,
formatValue, formatValue,
getCircuitValue,
getDeviceValue,
parseNumeric,
} from "../utils/circuit-grid-model"; } from "../utils/circuit-grid-model";
import type { import type {
CellKey, CellKey,
@@ -49,14 +48,17 @@ import {
moveCircuitDeviceRowById, moveCircuitDeviceRowById,
reorderSectionCircuits, reorderSectionCircuits,
renumberCircuitSection, renumberCircuitSection,
redoProjectCommand,
updateSectionEquipmentIdentifiers, updateSectionEquipmentIdentifiers,
updateCircuitById, updateCircuitById,
updateCircuitDeviceRowById, updateCircuitDeviceRowById,
undoProjectCommand,
} from "../utils/api"; } from "../utils/api";
import type { import type {
CircuitTreeCircuitDto, CircuitTreeCircuitDto,
CircuitTreeDeviceRowDto, CircuitTreeDeviceRowDto,
CircuitTreeResponseDto, CircuitTreeResponseDto,
ProjectCommandResultDto,
ProjectDeviceDto, ProjectDeviceDto,
} from "../types"; } from "../types";
@@ -88,6 +90,10 @@ interface HistoryCommand {
label: string; label: string;
redo: () => Promise<SelectionIntent | null | void>; redo: () => Promise<SelectionIntent | null | void>;
undo: () => Promise<SelectionIntent | null | void>; undo: () => Promise<SelectionIntent | null | void>;
persistent?: {
undoIntent: () => SelectionIntent | null;
redoIntent: () => SelectionIntent | null;
};
} }
interface CircuitSnapshot { interface CircuitSnapshot {
@@ -163,6 +169,16 @@ function isPrintableKey(event: KeyboardEvent<HTMLElement>) {
return event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey; 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 }) { export function CircuitTreeEditor(props: { projectId: string; circuitListId: string }) {
/* /*
Circuit-tree editor invariant overview: 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 [draggingCircuitId, setDraggingCircuitId] = useState<string | null>(null);
const [draggingCircuitIds, setDraggingCircuitIds] = useState<string[]>([]); const [draggingCircuitIds, setDraggingCircuitIds] = useState<string[]>([]);
const [circuitReorderIntent, setCircuitReorderIntent] = useState<CircuitReorderDropIntent | null>(null); 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 [undoStack, setUndoStack] = useState<HistoryCommand[]>([]);
const [redoStack, setRedoStack] = useState<HistoryCommand[]>([]); const [redoStack, setRedoStack] = useState<HistoryCommand[]>([]);
const [historyBusy, setHistoryBusy] = useState(false); const [historyBusy, setHistoryBusy] = useState(false);
@@ -223,6 +240,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const focusTokenRef = useRef(1); const focusTokenRef = useRef(1);
const projectRevisionRef = useRef<number | null>(null);
const orderedColumns = useMemo( const orderedColumns = useMemo(
() => columnOrder.map((key) => allColumns.find((column) => column.key === key)).filter(Boolean) as ColumnDef[], () => 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); setError(null);
try { try {
const tree = await getCircuitTree(projectId, circuitListId); const tree = await getCircuitTree(projectId, circuitListId);
projectRevisionRef.current = tree.currentRevision;
setData(tree); setData(tree);
} catch (err) { } catch (err) {
setError(normalizeUiError(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. // Runs a normal command and records it in session-local history.
async function runCommand(command: HistoryCommand) { async function runCommand(command: HistoryCommand) {
try { try {
@@ -790,7 +828,29 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError(null); setError(null);
setHistoryBusy(true); setHistoryBusy(true);
setIsSaving(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); await reloadWithIntent(intent ?? null);
if (mode === "undo") { if (mode === "undo") {
setUndoStack((current) => current.slice(0, -1)); setUndoStack((current) => current.slice(0, -1));
@@ -1145,46 +1205,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setSelectedCell({ rowKey: targetRowKey, cellKey: best }); 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) { async function patchCircuit(circuitId: string, key: CellKey, draft: string) {
const payload: Record<string, unknown> = {}; const result = await updateCircuitById(
if (key === "protectionSummary" || key === "cableSummary") { projectId,
payload.remark = draft.trim() === "" ? undefined : draft; getExpectedProjectRevision(),
} else if (["protectionRatedCurrent", "cableLength", "voltage"].includes(key)) { circuitId,
payload[key] = parseNumeric(key, draft); buildCircuitEditPatch(key, draft)
} else if (key === "isReserve") { );
const normalized = draft.trim().toLowerCase(); applyProjectCommandResult(result);
payload.isReserve = ["1", "true", "yes", "ja"].includes(normalized);
} else {
payload[key] = draft.trim() === "" ? undefined : draft;
}
await updateCircuitById(circuitId, payload);
} }
// 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) { async function patchDeviceRow(rowId: string, key: CellKey, draft: string) {
const payload: Record<string, unknown> = {}; const result = await updateCircuitDeviceRowById(
if (["quantity", "powerPerUnit", "simultaneityFactor", "cosPhi"].includes(key)) { projectId,
payload[key] = parseNumeric(key, draft); getExpectedProjectRevision(),
} else if (key === "roomSummary") { rowId,
const trimmed = draft.trim(); buildDeviceRowEditPatch(key, draft)
if (!trimmed) { );
payload.roomNumberSnapshot = undefined; applyProjectCommandResult(result);
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);
} }
// Creates a real circuit from virtual placeholder row. // 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); const isDeviceField = deviceFieldKeys.has(key);
if (isDeviceField) { if (isDeviceField) {
const editValues = createValuesFromEditPatch(
buildDeviceRowEditPatch(key, draft)
);
const created = await createCircuitWithDeviceRows(projectId, circuitListId, { const created = await createCircuitWithDeviceRows(projectId, circuitListId, {
circuit: { circuit: {
sectionId, sectionId,
@@ -1221,37 +1264,28 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
powerPerUnit: 0, powerPerUnit: 0,
simultaneityFactor: 1, simultaneityFactor: 1,
cosPhi: 1, cosPhi: 1,
...editValues,
}, },
], ],
}); });
const createdCircuit = created.circuit; const createdCircuit = created.circuit;
const createdRow = created.deviceRows[0];
await patchDeviceRow(createdRow.id, key, draft);
return { rowKey: `circuitCompact:${createdCircuit.id}`, cellKey: key }; return { rowKey: `circuitCompact:${createdCircuit.id}`, cellKey: key };
} }
const editValues = createValuesFromEditPatch(
buildCircuitEditPatch(key, draft)
);
const createdCircuit = (await createCircuit(projectId, circuitListId, { const createdCircuit = (await createCircuit(projectId, circuitListId, {
sectionId, sectionId,
equipmentIdentifier: next.nextIdentifier, equipmentIdentifier: next.nextIdentifier,
displayName: "Neuer Stromkreis", displayName: "Neuer Stromkreis",
sortOrder, sortOrder,
isReserve: true, isReserve: true,
...editValues,
})) as CircuitTreeCircuitDto; })) as CircuitTreeCircuitDto;
await patchCircuit(createdCircuit.id, key, draft);
return { rowKey: `reserveCircuit:${createdCircuit.id}`, cellKey: key }; 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. // Commits the active editor input through command history so edits remain undoable.
async function commitEdit(direction: SaveDirection = "stay") { async function commitEdit(direction: SaveDirection = "stay") {
if (!editingCell) { if (!editingCell) {
@@ -1316,7 +1350,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
if (cell.kind === "circuitField" && row.circuit) { if (cell.kind === "circuitField" && row.circuit) {
const previous = getCurrentDraftForCell(row, editingCell.cellKey, cell.kind);
const next = editingCell.draft; const next = editingCell.draft;
const command: HistoryCommand = { const command: HistoryCommand = {
label: "Stromkreisfeld bearbeiten", label: "Stromkreisfeld bearbeiten",
@@ -1324,9 +1357,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await patchCircuit(row.circuit!.id, editingCell.cellKey, next); await patchCircuit(row.circuit!.id, editingCell.cellKey, next);
return nextSelectionIntent(); return nextSelectionIntent();
}, },
undo: async () => { undo: async () =>
await patchCircuit(row.circuit!.id, editingCell.cellKey, previous); buildSelectionIntent({
return buildSelectionIntent({ rowKey: row.rowKey, cellKey: editingCell.cellKey }); rowKey: row.rowKey,
cellKey: editingCell.cellKey,
}),
persistent: {
undoIntent: () =>
buildSelectionIntent({
rowKey: row.rowKey,
cellKey: editingCell.cellKey,
}),
redoIntent: nextSelectionIntent,
}, },
}; };
setEditingCell(null); setEditingCell(null);
@@ -1335,7 +1377,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
if (cell.kind === "deviceField" && row.device) { if (cell.kind === "deviceField" && row.device) {
const previous = getCurrentDraftForCell(row, editingCell.cellKey, cell.kind);
const next = editingCell.draft; const next = editingCell.draft;
const command: HistoryCommand = { const command: HistoryCommand = {
label: "Gerätefeld bearbeiten", label: "Gerätefeld bearbeiten",
@@ -1343,9 +1384,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await patchDeviceRow(row.device!.id, editingCell.cellKey, next); await patchDeviceRow(row.device!.id, editingCell.cellKey, next);
return nextSelectionIntent(); return nextSelectionIntent();
}, },
undo: async () => { undo: async () =>
await patchDeviceRow(row.device!.id, editingCell.cellKey, previous); buildSelectionIntent({
return buildSelectionIntent({ rowKey: `device:${row.device!.id}`, cellKey: editingCell.cellKey }); rowKey: `device:${row.device!.id}`,
cellKey: editingCell.cellKey,
}),
persistent: {
undoIntent: () =>
buildSelectionIntent({
rowKey: `device:${row.device!.id}`,
cellKey: editingCell.cellKey,
}),
redoIntent: nextSelectionIntent,
}, },
}; };
setEditingCell(null); setEditingCell(null);
@@ -1359,6 +1409,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const command: HistoryCommand = { const command: HistoryCommand = {
label: "Gerätezeile im Reservestromkreis erstellen", label: "Gerätezeile im Reservestromkreis erstellen",
redo: async () => { redo: async () => {
const editValues = createValuesFromEditPatch(
buildDeviceRowEditPatch(editingCell.cellKey, next)
);
const created = (await createCircuitDeviceRow(row.circuit!.id, { const created = (await createCircuitDeviceRow(row.circuit!.id, {
name: "Reserveverbraucher", name: "Reserveverbraucher",
displayName: "Reserveverbraucher", displayName: "Reserveverbraucher",
@@ -1367,9 +1420,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
powerPerUnit: 0, powerPerUnit: 0,
simultaneityFactor: 1, simultaneityFactor: 1,
cosPhi: 1, cosPhi: 1,
...editValues,
})) as { id: string }; })) as { id: string };
createdRowId = created.id; createdRowId = created.id;
await patchDeviceRow(created.id, editingCell.cellKey, next);
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey }; targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey };
return nextSelectionIntent(); return nextSelectionIntent();
}, },
+1 -3
View File
@@ -240,6 +240,7 @@ export interface CircuitTreeMigrationReportDto {
export interface CircuitTreeResponseDto { export interface CircuitTreeResponseDto {
circuitListId: string; circuitListId: string;
currentRevision: number;
sections: CircuitTreeSectionDto[]; sections: CircuitTreeSectionDto[];
migrationReport?: CircuitTreeMigrationReportDto; migrationReport?: CircuitTreeMigrationReportDto;
} }
@@ -264,8 +265,6 @@ export interface CreateCircuitInputDto {
remark?: string; remark?: string;
} }
export type UpdateCircuitInputDto = Partial<CreateCircuitInputDto>;
export interface CreateCircuitDeviceRowInputDto { export interface CreateCircuitDeviceRowInputDto {
linkedProjectDeviceId?: string; linkedProjectDeviceId?: string;
name: string; name: string;
@@ -287,5 +286,4 @@ export interface CreateCircuitDeviceRowInputDto {
sortOrder?: number; sortOrder?: number;
} }
export type UpdateCircuitDeviceRowInputDto = Partial<CreateCircuitDeviceRowInputDto>;
import type { ProjectDeviceSyncField } from "../shared/constants/project-device-sync-fields"; import type { ProjectDeviceSyncField } from "../shared/constants/project-device-sync-fields";
+32 -12
View File
@@ -20,11 +20,17 @@ import type {
CircuitTreeCircuitDto, CircuitTreeCircuitDto,
CircuitTreeDeviceRowDto, CircuitTreeDeviceRowDto,
CreateCircuitInputDto, CreateCircuitInputDto,
UpdateCircuitInputDto,
CreateCircuitDeviceRowInputDto, CreateCircuitDeviceRowInputDto,
UpdateCircuitDeviceRowInputDto,
} from "../types"; } from "../types";
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields"; 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> { async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, { const response = await fetch(url, {
@@ -180,11 +186,18 @@ export function createCircuitWithDeviceRows(
); );
} }
export function updateCircuitById(circuitId: string, input: UpdateCircuitInputDto) { export function updateCircuitById(
return request(`/api/circuits/${circuitId}`, { projectId: string,
method: "PATCH", expectedRevision: number,
body: JSON.stringify(input), circuitId: string,
}); input: CircuitUpdatePatch
) {
return executeProjectCommand(
projectId,
expectedRevision,
createCircuitUpdateProjectCommand(circuitId, input),
"Stromkreisfeld bearbeiten"
);
} }
export function deleteCircuitById(circuitId: string) { export function deleteCircuitById(circuitId: string) {
@@ -206,11 +219,18 @@ export function createCircuitDeviceRow(circuitId: string, input: CreateCircuitDe
}); });
} }
export function updateCircuitDeviceRowById(rowId: string, input: UpdateCircuitDeviceRowInputDto) { export function updateCircuitDeviceRowById(
return request(`/api/circuit-device-rows/${rowId}`, { projectId: string,
method: "PATCH", expectedRevision: number,
body: JSON.stringify(input), rowId: string,
}); input: CircuitDeviceRowUpdatePatch
) {
return executeProjectCommand(
projectId,
expectedRevision,
createCircuitDeviceRowUpdateProjectCommand(rowId, input),
"Gerätezeilenfeld bearbeiten"
);
} }
export function deleteCircuitDeviceRowById(rowId: string) { export function deleteCircuitDeviceRowById(rowId: string) {
+74
View File
@@ -1,4 +1,6 @@
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../types.js"; 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 = export type CellKey =
| "equipmentIdentifier" | "equipmentIdentifier"
@@ -189,6 +191,78 @@ export function parseNumeric(cellKey: CellKey, draft: string): number | undefine
return parsed; 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 { export function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): GridValue {
switch (key) { switch (key) {
case "technicalName": return device.name; case "technicalName": return device.name;
@@ -4,7 +4,6 @@ import {
createCircuitDeviceRowSchema, createCircuitDeviceRowSchema,
moveCircuitDeviceRowsBulkSchema, moveCircuitDeviceRowsBulkSchema,
moveCircuitDeviceRowSchema, moveCircuitDeviceRowSchema,
updateCircuitDeviceRowSchema,
} from "../../shared/validation/circuit.schemas.js"; } from "../../shared/validation/circuit.schemas.js";
export async function createCircuitDeviceRow(req: Request, res: Response) { export async function createCircuitDeviceRow(req: Request, res: Response) {
@@ -26,25 +25,6 @@ export async function createCircuitDeviceRow(req: Request, res: Response) {
} }
} }
export async function updateCircuitDeviceRow(req: Request, res: Response) {
const { rowId } = req.params;
if (typeof rowId !== "string") {
return res.status(400).json({ error: "Invalid rowId" });
}
const parsed = updateCircuitDeviceRowSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const updated = await circuitWriteService.updateDeviceRow(rowId, parsed.data);
return res.json(updated);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to update device row." });
}
}
export async function deleteCircuitDeviceRow(req: Request, res: Response) { export async function deleteCircuitDeviceRow(req: Request, res: Response) {
const { rowId } = req.params; const { rowId } = req.params;
if (typeof rowId !== "string") { if (typeof rowId !== "string") {
@@ -3,6 +3,7 @@ import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js"; import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js"; import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js"; import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
import { ProjectRepository } from "../../db/repositories/project.repository.js";
import { import {
calculateCircuitTotalPower, calculateCircuitTotalPower,
calculateRowTotalPower, calculateRowTotalPower,
@@ -13,6 +14,7 @@ const circuitListRepository = new CircuitListRepository();
const circuitSectionRepository = new CircuitSectionRepository(); const circuitSectionRepository = new CircuitSectionRepository();
const circuitRepository = new CircuitRepository(); const circuitRepository = new CircuitRepository();
const circuitDeviceRowRepository = new CircuitDeviceRowRepository(); const circuitDeviceRowRepository = new CircuitDeviceRowRepository();
const projectRepository = new ProjectRepository();
export function isMissingCircuitTreeSchemaError(error: unknown): boolean { export function isMissingCircuitTreeSchemaError(error: unknown): boolean {
if (!(error instanceof Error)) { if (!(error instanceof Error)) {
@@ -35,6 +37,10 @@ export async function getCircuitTree(req: Request, res: Response) {
if (!list) { if (!list) {
return res.status(404).json({ error: "Circuit list not found" }); return res.status(404).json({ error: "Circuit list not found" });
} }
const project = await projectRepository.findById(projectId);
if (!project) {
return res.status(404).json({ error: "Project not found" });
}
try { try {
const sections = await circuitSectionRepository.listByCircuitList(circuitListId); const sections = await circuitSectionRepository.listByCircuitList(circuitListId);
@@ -52,6 +58,7 @@ export async function getCircuitTree(req: Request, res: Response) {
const tree: CircuitTreeResponse = { const tree: CircuitTreeResponse = {
circuitListId, circuitListId,
currentRevision: project.currentRevision,
sections: sections.map((section) => ({ sections: sections.map((section) => ({
id: section.id, id: section.id,
key: section.key, key: section.key,
@@ -123,6 +130,7 @@ export async function getCircuitTree(req: Request, res: Response) {
if (isMissingCircuitTreeSchemaError(error)) { if (isMissingCircuitTreeSchemaError(error)) {
return res.json({ return res.json({
circuitListId, circuitListId,
currentRevision: project.currentRevision,
sections: [], sections: [],
warning: warning:
"Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).", "Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).",
@@ -3,7 +3,6 @@ import { circuitWriteService } from "../composition/circuit-write-service.js";
import { import {
createCircuitSchema, createCircuitSchema,
createCircuitWithDeviceRowsSchema, createCircuitWithDeviceRowsSchema,
updateCircuitSchema,
} from "../../shared/validation/circuit.schemas.js"; } from "../../shared/validation/circuit.schemas.js";
export async function createCircuit(req: Request, res: Response) { export async function createCircuit(req: Request, res: Response) {
@@ -53,25 +52,6 @@ export async function createCircuitWithDeviceRows(req: Request, res: Response) {
} }
} }
export async function updateCircuit(req: Request, res: Response) {
const { circuitId } = req.params;
if (typeof circuitId !== "string") {
return res.status(400).json({ error: "Invalid circuitId" });
}
const parsed = updateCircuitSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const updated = await circuitWriteService.updateCircuit(circuitId, parsed.data);
return res.json(updated);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to update circuit." });
}
}
export async function deleteCircuit(req: Request, res: Response) { export async function deleteCircuit(req: Request, res: Response) {
const { circuitId } = req.params; const { circuitId } = req.params;
if (typeof circuitId !== "string") { if (typeof circuitId !== "string") {
@@ -3,12 +3,10 @@ import {
deleteCircuitDeviceRow, deleteCircuitDeviceRow,
moveCircuitDeviceRowsBulk, moveCircuitDeviceRowsBulk,
moveCircuitDeviceRow, moveCircuitDeviceRow,
updateCircuitDeviceRow,
} from "../controllers/circuit-device-row.controller.js"; } from "../controllers/circuit-device-row.controller.js";
export const circuitDeviceRowRouter = Router(); export const circuitDeviceRowRouter = Router();
circuitDeviceRowRouter.patch("/circuit-device-rows/move-bulk", moveCircuitDeviceRowsBulk); circuitDeviceRowRouter.patch("/circuit-device-rows/move-bulk", moveCircuitDeviceRowsBulk);
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId", updateCircuitDeviceRow);
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId/move", moveCircuitDeviceRow); circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId/move", moveCircuitDeviceRow);
circuitDeviceRowRouter.delete("/circuit-device-rows/:rowId", deleteCircuitDeviceRow); circuitDeviceRowRouter.delete("/circuit-device-rows/:rowId", deleteCircuitDeviceRow);
-2
View File
@@ -4,7 +4,6 @@ import {
createCircuitWithDeviceRows, createCircuitWithDeviceRows,
deleteCircuit, deleteCircuit,
getNextCircuitIdentifier, getNextCircuitIdentifier,
updateCircuit,
} from "../controllers/circuit.controller.js"; } from "../controllers/circuit.controller.js";
import { createCircuitDeviceRow } from "../controllers/circuit-device-row.controller.js"; import { createCircuitDeviceRow } from "../controllers/circuit-device-row.controller.js";
@@ -15,7 +14,6 @@ circuitRouter.post(
"/projects/:projectId/circuit-lists/:circuitListId/circuits-with-device-rows", "/projects/:projectId/circuit-lists/:circuitListId/circuits-with-device-rows",
createCircuitWithDeviceRows createCircuitWithDeviceRows
); );
circuitRouter.patch("/circuits/:circuitId", updateCircuit);
circuitRouter.delete("/circuits/:circuitId", deleteCircuit); circuitRouter.delete("/circuits/:circuitId", deleteCircuit);
circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier); circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier);
circuitRouter.post("/circuits/:circuitId/device-rows", createCircuitDeviceRow); circuitRouter.post("/circuits/:circuitId/device-rows", createCircuitDeviceRow);
-11
View File
@@ -20,13 +20,6 @@ export const createCircuitSchema = z.object({
remark: z.string().optional(), remark: z.string().optional(),
}); });
export const updateCircuitSchema = createCircuitSchema.partial().extend({
sectionId: z.string().min(1).optional(),
equipmentIdentifier: z.string().min(1).optional(),
sortOrder: z.number().optional(),
isReserve: z.boolean().optional(),
});
export const createCircuitDeviceRowSchema = z.object({ export const createCircuitDeviceRowSchema = z.object({
linkedProjectDeviceId: z.string().min(1).optional(), linkedProjectDeviceId: z.string().min(1).optional(),
name: z.string().min(1), name: z.string().min(1),
@@ -53,8 +46,6 @@ export const createCircuitWithDeviceRowsSchema = z.object({
deviceRows: z.array(createCircuitDeviceRowSchema).min(1), deviceRows: z.array(createCircuitDeviceRowSchema).min(1),
}); });
export const updateCircuitDeviceRowSchema = createCircuitDeviceRowSchema.partial();
export const moveCircuitDeviceRowSchema = z export const moveCircuitDeviceRowSchema = z
.object({ .object({
targetCircuitId: z.string().min(1).optional(), targetCircuitId: z.string().min(1).optional(),
@@ -98,10 +89,8 @@ export const updateSectionEquipmentIdentifiersSchema = z.object({
}); });
export type CreateCircuitInput = z.infer<typeof createCircuitSchema>; export type CreateCircuitInput = z.infer<typeof createCircuitSchema>;
export type UpdateCircuitInput = z.infer<typeof updateCircuitSchema>;
export type CreateCircuitDeviceRowInput = z.infer<typeof createCircuitDeviceRowSchema>; export type CreateCircuitDeviceRowInput = z.infer<typeof createCircuitDeviceRowSchema>;
export type CreateCircuitWithDeviceRowsInput = z.infer<typeof createCircuitWithDeviceRowsSchema>; export type CreateCircuitWithDeviceRowsInput = z.infer<typeof createCircuitWithDeviceRowsSchema>;
export type UpdateCircuitDeviceRowInput = z.infer<typeof updateCircuitDeviceRowSchema>;
export type MoveCircuitDeviceRowInput = z.infer<typeof moveCircuitDeviceRowSchema>; export type MoveCircuitDeviceRowInput = z.infer<typeof moveCircuitDeviceRowSchema>;
export type MoveCircuitDeviceRowsBulkInput = z.infer<typeof moveCircuitDeviceRowsBulkSchema>; export type MoveCircuitDeviceRowsBulkInput = z.infer<typeof moveCircuitDeviceRowsBulkSchema>;
export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>; export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>;
+36
View File
@@ -2,6 +2,8 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import { import {
allColumns, allColumns,
buildCircuitEditPatch,
buildDeviceRowEditPatch,
getBlockSortValue, getBlockSortValue,
getCellKind, getCellKind,
getCircuitValue, getCircuitValue,
@@ -85,4 +87,38 @@ describe("circuit grid model", () => {
assert.equal(parseNumeric("quantity", ""), undefined); assert.equal(parseNumeric("quantity", ""), undefined);
assert.throws(() => parseNumeric("quantity", "two"), /Ungültiger Zahlenwert/); assert.throws(() => parseNumeric("quantity", "two"), /Ungültiger Zahlenwert/);
}); });
it("builds nullable circuit command patches from grid drafts", () => {
assert.deepEqual(buildCircuitEditPatch("voltage", ""), {
voltage: null,
});
assert.deepEqual(
buildCircuitEditPatch("controlRequirement", " DALI "),
{ controlRequirement: "DALI" }
);
assert.deepEqual(buildCircuitEditPatch("isReserve", "ja"), {
isReserve: true,
});
});
it("builds device command patches with explicit clearing semantics", () => {
assert.deepEqual(buildDeviceRowEditPatch("roomSummary", ""), {
roomNumberSnapshot: null,
roomNameSnapshot: null,
});
assert.deepEqual(
buildDeviceRowEditPatch("roomSummary", "1.01 Büro"),
{
roomNumberSnapshot: "1.01",
roomNameSnapshot: "Büro",
}
);
assert.deepEqual(buildDeviceRowEditPatch("technicalName", " Leuchte "), {
name: "Leuchte",
});
assert.throws(
() => buildDeviceRowEditPatch("quantity", ""),
/darf nicht leer sein/
);
});
}); });
@@ -71,6 +71,8 @@ function createTestFixture(): TestFixture {
displayName: "Licht Bestand", displayName: "Licht Bestand",
sortOrder: 10, sortOrder: 10,
protectionRatedCurrent: 10, protectionRatedCurrent: 10,
voltage: 230,
controlRequirement: "none",
isReserve: 0, isReserve: 0,
}, },
{ {
@@ -111,6 +113,8 @@ describe("circuit project-command repository", () => {
const command = createCircuitUpdateProjectCommand("circuit-1", { const command = createCircuitUpdateProjectCommand("circuit-1", {
displayName: null, displayName: null,
protectionRatedCurrent: 16, protectionRatedCurrent: 16,
voltage: 400,
controlRequirement: "DALI",
isReserve: true, isReserve: true,
}); });
@@ -125,6 +129,8 @@ describe("circuit project-command repository", () => {
const circuit = getCircuit(fixture.context); const circuit = getCircuit(fixture.context);
assert.equal(circuit.displayName, null); assert.equal(circuit.displayName, null);
assert.equal(circuit.protectionRatedCurrent, 16); assert.equal(circuit.protectionRatedCurrent, 16);
assert.equal(circuit.voltage, 400);
assert.equal(circuit.controlRequirement, "DALI");
assert.equal(circuit.isReserve, 1); assert.equal(circuit.isReserve, 1);
assert.equal(executed.revision.revisionNumber, 1); assert.equal(executed.revision.revisionNumber, 1);
assert.deepEqual(executed.inverse.payload, { assert.deepEqual(executed.inverse.payload, {
@@ -132,6 +138,8 @@ describe("circuit project-command repository", () => {
changes: [ changes: [
{ field: "displayName", value: "Licht Bestand" }, { field: "displayName", value: "Licht Bestand" },
{ field: "protectionRatedCurrent", value: 10 }, { field: "protectionRatedCurrent", value: 10 },
{ field: "voltage", value: 230 },
{ field: "controlRequirement", value: "none" },
{ field: "isReserve", value: false }, { field: "isReserve", value: false },
], ],
}); });
-71
View File
@@ -16,45 +16,6 @@ describe("circuit write service rules", () => {
assert.equal(parsed.success, true); assert.equal(parsed.success, true);
}); });
it("passes voltage and control requirements through circuit updates", async () => {
let updatePayload: Record<string, unknown> = {};
const circuit = {
id: "c1",
circuitListId: "l1",
sectionId: "s1",
equipmentIdentifier: "-1F1",
sortOrder: 10,
isReserve: 0,
voltage: 230,
controlRequirement: "none",
};
const service = new CircuitWriteService({
circuitSectionRepository: {
async findById() {
return { id: "s1", circuitListId: "l1" } as never;
},
} as never,
circuitRepository: {
async findById() {
return circuit as never;
},
async existsByEquipmentIdentifier() {
return false;
},
async updateFields(_id: string, payload: Record<string, unknown>) {
updatePayload = payload;
},
} as never,
});
await service.updateCircuit("c1", { voltage: 400, controlRequirement: "DALI" });
assert.deepEqual(updatePayload, {
voltage: 400,
controlRequirement: "DALI",
});
});
it("rejects duplicate equipment identifiers in same circuit list", async () => { it("rejects duplicate equipment identifiers in same circuit list", async () => {
const service = new CircuitWriteService({ const service = new CircuitWriteService({
circuitListRepository: { circuitListRepository: {
@@ -741,36 +702,4 @@ describe("circuit write service rules", () => {
assert.equal(safeCalled, true); assert.equal(safeCalled, true);
}); });
it("tracks local edits on linked device rows as overridden fields", async () => {
let updatePayload: Record<string, unknown> = {};
const current = {
id: "r1",
circuitId: "c1",
linkedProjectDeviceId: "pd1",
name: "Luminaire",
displayName: "Local name",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
sortOrder: 10,
overriddenFields: null,
};
const service = new CircuitWriteService({
deviceRowRepository: {
async findById() {
return current as never;
},
async updateFields(_rowId: string, input: Record<string, unknown>) {
updatePayload = input;
},
} as never,
});
await service.updateDeviceRow("r1", { quantity: 2 });
assert.deepEqual(updatePayload, {
quantity: 2,
overriddenFields: "[\"quantity\"]",
});
});
}); });