Persist circuit cell edits
This commit is contained in:
@@ -220,15 +220,18 @@ After saving, the row becomes linked to the new project device.
|
||||
|
||||
## Undo / Redo
|
||||
|
||||
Session-local UI undo/redo exists for structural and destructive operations.
|
||||
The server already persists immutable revisions and project-wide undo/redo
|
||||
eligibility for the first commands. Public command, undo and redo endpoints
|
||||
exist for Circuit and CircuitDeviceRow field updates as well as
|
||||
Circuit/CircuitDeviceRow insertion and deletion. Complete command coverage and
|
||||
the UI cutover remain future work. CircuitDeviceRow moves between existing
|
||||
circuits and moves that create one new placeholder target circuit are also
|
||||
persisted. The latter stores the complete empty target snapshot so undo can
|
||||
restore the rows and remove only the unchanged generated circuit. Complete
|
||||
The editor's visible undo/redo stack remains session-local while structural and
|
||||
destructive operations are being migrated. Existing Circuit and
|
||||
CircuitDeviceRow cell edits already execute persistent project commands and
|
||||
their toolbar undo/redo actions use the project-wide history endpoints. The
|
||||
tree response supplies the optimistic `currentRevision`; the obsolete direct
|
||||
PATCH routes for these fields are removed. Reloading the page does not yet
|
||||
reconstruct the visible editor stack. Public command, undo and redo endpoints
|
||||
also exist for Circuit/CircuitDeviceRow insertion and deletion.
|
||||
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
|
||||
without changing equipment identifiers. Explicit complete-section renumbering
|
||||
is persisted through a separate collision-safe command and is never triggered
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
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.
|
||||
|
||||
Project responses from `GET /projects` and `GET /projects/:projectId` include
|
||||
`currentRevision`. Versioned project commands use this value for optimistic
|
||||
concurrency checks.
|
||||
Project responses from `GET /projects`, `GET /projects/:projectId` and the
|
||||
circuit-tree endpoint include `currentRevision`. Versioned project commands use
|
||||
this value for optimistic concurrency checks.
|
||||
|
||||
### 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
|
||||
contain each row's expected and target circuit plus its exact expected and
|
||||
target sort order. This makes deletion and moves undoable without generating
|
||||
replacement identities or renumbering circuits. The editor does not consume
|
||||
these endpoints yet.
|
||||
replacement identities or renumbering circuits. Existing Circuit and
|
||||
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-with-new-circuit` atomically creates exactly one
|
||||
@@ -137,6 +138,7 @@ Response sketch:
|
||||
```json
|
||||
{
|
||||
"circuitListId": "cl_1",
|
||||
"currentRevision": 12,
|
||||
"sections": [
|
||||
{
|
||||
"id": "sec_1",
|
||||
@@ -167,8 +169,6 @@ Response sketch:
|
||||
- create circuit in list/section
|
||||
- `POST /projects/:projectId/circuit-lists/:circuitListId/circuits-with-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 circuit (and related rows via DB relations)
|
||||
- `GET /circuit-sections/:sectionId/next-identifier`
|
||||
@@ -189,11 +189,14 @@ Request sketch (`POST .../circuits`):
|
||||
|
||||
- `POST /circuits/:circuitId/device-rows`
|
||||
- create row inside a circuit
|
||||
- `PATCH /circuit-device-rows/:rowId`
|
||||
- update row values
|
||||
- `DELETE /circuit-device-rows/:rowId`
|
||||
- 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
|
||||
|
||||
- `PATCH /circuit-device-rows/:rowId/move`
|
||||
|
||||
@@ -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.
|
||||
|
||||
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`
|
||||
|
||||
@@ -119,7 +122,11 @@ Cross-section device moves show confirmation-required feedback before drop. The
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -132,5 +139,6 @@ Covered operations include:
|
||||
|
||||
Current limitations:
|
||||
|
||||
- session-local only
|
||||
- no persisted history across browser reload
|
||||
- the visible editor stack is still session-local and is empty after 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
|
||||
|
||||
- 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
|
||||
the command types documented in `circuit-list-editor-api.md`; command coverage
|
||||
and the editor UI cutover are not complete.
|
||||
the command types documented in `circuit-list-editor-api.md`; complete editor
|
||||
command coverage and history reconstruction are not complete.
|
||||
- Named logical project snapshots and restore are not 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.
|
||||
|
||||
@@ -41,7 +41,7 @@ liegt über einen Host-Mount außerhalb des Containers.
|
||||
- `src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx`
|
||||
– unterstützte Editorroute
|
||||
- `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,
|
||||
Zellbesitz, Einfügen und Sicherheitsregeln
|
||||
- `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
|
||||
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-
|
||||
Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch
|
||||
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
|
||||
Stromkreis mit null, einer oder mehreren Gerätezeilen als vollständigen Block.
|
||||
Undo bewahrt dabei sämtliche Circuit-/Row-UUIDs und ändert keine
|
||||
Betriebsmittelkennzeichen. Das Grid verwendet diese Endpunkte noch nicht; seine
|
||||
sichtbare Historie bleibt daher sitzungslokal.
|
||||
Betriebsmittelkennzeichen. Bestehende Circuit- und Gerätezeilen-Zelländerungen
|
||||
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
|
||||
zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue
|
||||
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
|
||||
|
||||
- persistentes Undo/Redo und Projektversionen
|
||||
- vollständiges persistentes Undo/Redo, sichtbare Historie nach Reload und
|
||||
benannte Projektstände
|
||||
- Mehrbenutzerbetrieb und Konfliktauflösung
|
||||
- Revit-/CSV-/IFCGUID-Round-trip
|
||||
- vollständige elektrische Dimensionierung
|
||||
|
||||
@@ -173,6 +173,11 @@ Completed foundation:
|
||||
to SQLite
|
||||
- public command, undo and redo endpoints require an expected revision; undo
|
||||
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
|
||||
row ids, complete deleted-row snapshots and circuit reserve state through
|
||||
atomic insert/delete, revision and stack transitions
|
||||
@@ -210,10 +215,10 @@ Remaining constraints before completing persistent history:
|
||||
is complete
|
||||
- route the remaining structural and destructive domain
|
||||
writes through the shared command, revision and stack transaction boundary
|
||||
- replace the session-local frontend command stack only after server-side
|
||||
command coverage is complete for structural and destructive circuit-editor
|
||||
operations; the ProjectDevice synchronization UI already uses its persistent
|
||||
command and project-wide undo endpoint
|
||||
- finish routing structural and destructive circuit-editor operations through
|
||||
persistent commands, then reconstruct the visible frontend history state
|
||||
after reload; Circuit/CircuitDeviceRow cell edits and ProjectDevice
|
||||
synchronization already use persistent commands and project-wide undo
|
||||
- do not model IFCGUID as an overloaded circuit equipment identifier
|
||||
- keep database backup/restore checks separate from project history tests
|
||||
|
||||
|
||||
@@ -390,6 +390,9 @@ Implemented foundation:
|
||||
- a central application dispatcher reconstructs persisted commands and exposes
|
||||
optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow
|
||||
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
|
||||
and stable ids; row mutation, circuit reserve state, revision and history
|
||||
stack transition commit or roll back together
|
||||
@@ -424,10 +427,11 @@ Implemented foundation:
|
||||
- integration coverage verifies sequential revisions, stale-command rejection
|
||||
and rollback after a forced late persistence failure
|
||||
|
||||
ProjectDevice CRUD, synchronization and disconnect on the project page are
|
||||
connected to this history boundary. The circuit-list editor and the remaining
|
||||
domain writes are not fully connected; its visible undo/redo therefore remains
|
||||
session-local until the remaining tasks are implemented.
|
||||
ProjectDevice CRUD, synchronization and disconnect on the project page and
|
||||
existing Circuit/CircuitDeviceRow cell edits in the circuit-list editor are
|
||||
connected to this history boundary. Remaining structural editor writes are not
|
||||
fully connected; the editor's visible undo/redo stack therefore remains
|
||||
session-local and is not reconstructed after reload.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
|
||||
@@ -7,15 +7,12 @@ import { circuits } from "../schema/circuits.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import {
|
||||
toCircuitDeviceRowCreateValues,
|
||||
toCircuitDeviceRowPatchValues,
|
||||
toCircuitDeviceRowUpdateValues,
|
||||
type CircuitDeviceRowCreateInput,
|
||||
type CircuitDeviceRowPatchInput,
|
||||
type CircuitDeviceRowUpdateInput,
|
||||
} from "./circuit-device-row.persistence.js";
|
||||
export type {
|
||||
CircuitDeviceRowCreateInput,
|
||||
CircuitDeviceRowPatchInput,
|
||||
CircuitDeviceRowUpdateInput,
|
||||
} 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));
|
||||
}
|
||||
|
||||
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) {
|
||||
await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId));
|
||||
}
|
||||
|
||||
@@ -4,18 +4,14 @@ import { db } from "../client.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import {
|
||||
toCircuitCreateValues,
|
||||
toCircuitPatchValues,
|
||||
type CircuitCreatePersistenceInput,
|
||||
type CircuitPatchPersistenceInput,
|
||||
} from "./circuit.persistence.js";
|
||||
|
||||
export type {
|
||||
CircuitCreatePersistenceInput,
|
||||
CircuitPatchPersistenceInput,
|
||||
} from "./circuit.persistence.js";
|
||||
export {
|
||||
toCircuitCreateValues,
|
||||
toCircuitPatchValues,
|
||||
} from "./circuit.persistence.js";
|
||||
|
||||
export class CircuitRepository {
|
||||
@@ -84,14 +80,6 @@ export class CircuitRepository {
|
||||
.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) {
|
||||
await db.delete(circuits).where(eq(circuits.id, circuitId));
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface CircuitTreeSectionBlock {
|
||||
|
||||
export interface CircuitTreeResponse {
|
||||
circuitListId: string;
|
||||
currentRevision: number;
|
||||
sections: CircuitTreeSectionBlock[];
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,8 @@ import type {
|
||||
MoveCircuitDeviceRowsBulkInput,
|
||||
ReorderSectionCircuitsInput,
|
||||
UpdateSectionEquipmentIdentifiersInput,
|
||||
UpdateCircuitDeviceRowInput,
|
||||
UpdateCircuitInput,
|
||||
} from "../../shared/validation/circuit.schemas.js";
|
||||
import { CircuitNumberingService } from "./circuit-numbering.service.js";
|
||||
import { deriveOverriddenFieldsForLocalEdit } from "./project-device-overrides.js";
|
||||
|
||||
export class CircuitWriteService {
|
||||
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) {
|
||||
const current = await this.circuitRepository.findById(circuitId);
|
||||
if (!current) {
|
||||
@@ -246,23 +228,6 @@ export class CircuitWriteService {
|
||||
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) {
|
||||
const current = await this.deviceRowRepository.findById(rowId);
|
||||
if (!current) {
|
||||
|
||||
@@ -17,12 +17,11 @@ import {
|
||||
} from "../utils/circuit-grid-safety";
|
||||
import {
|
||||
allColumns,
|
||||
buildCircuitEditPatch,
|
||||
buildDeviceRowEditPatch,
|
||||
defaultVisibleColumnKeys,
|
||||
deviceFieldKeys,
|
||||
formatValue,
|
||||
getCircuitValue,
|
||||
getDeviceValue,
|
||||
parseNumeric,
|
||||
} from "../utils/circuit-grid-model";
|
||||
import type {
|
||||
CellKey,
|
||||
@@ -49,14 +48,17 @@ import {
|
||||
moveCircuitDeviceRowById,
|
||||
reorderSectionCircuits,
|
||||
renumberCircuitSection,
|
||||
redoProjectCommand,
|
||||
updateSectionEquipmentIdentifiers,
|
||||
updateCircuitById,
|
||||
updateCircuitDeviceRowById,
|
||||
undoProjectCommand,
|
||||
} from "../utils/api";
|
||||
import type {
|
||||
CircuitTreeCircuitDto,
|
||||
CircuitTreeDeviceRowDto,
|
||||
CircuitTreeResponseDto,
|
||||
ProjectCommandResultDto,
|
||||
ProjectDeviceDto,
|
||||
} from "../types";
|
||||
|
||||
@@ -88,6 +90,10 @@ interface HistoryCommand {
|
||||
label: string;
|
||||
redo: () => Promise<SelectionIntent | null | void>;
|
||||
undo: () => Promise<SelectionIntent | null | void>;
|
||||
persistent?: {
|
||||
undoIntent: () => SelectionIntent | null;
|
||||
redoIntent: () => SelectionIntent | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface CircuitSnapshot {
|
||||
@@ -163,6 +169,16 @@ function isPrintableKey(event: KeyboardEvent<HTMLElement>) {
|
||||
return event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey;
|
||||
}
|
||||
|
||||
function createValuesFromEditPatch(
|
||||
patch: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(patch).filter(
|
||||
([, value]) => value !== null && value !== ""
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function CircuitTreeEditor(props: { projectId: string; circuitListId: string }) {
|
||||
/*
|
||||
Circuit-tree editor invariant overview:
|
||||
@@ -201,7 +217,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
const [draggingCircuitId, setDraggingCircuitId] = useState<string | null>(null);
|
||||
const [draggingCircuitIds, setDraggingCircuitIds] = useState<string[]>([]);
|
||||
const [circuitReorderIntent, setCircuitReorderIntent] = useState<CircuitReorderDropIntent | null>(null);
|
||||
// Undo/redo history is session-local UI state (not persisted server-side).
|
||||
// The visible stack is session-local during the cutover. Circuit/device field
|
||||
// commands already use the persistent project-wide server history.
|
||||
const [undoStack, setUndoStack] = useState<HistoryCommand[]>([]);
|
||||
const [redoStack, setRedoStack] = useState<HistoryCommand[]>([]);
|
||||
const [historyBusy, setHistoryBusy] = useState(false);
|
||||
@@ -223,6 +240,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const focusTokenRef = useRef(1);
|
||||
const projectRevisionRef = useRef<number | null>(null);
|
||||
|
||||
const orderedColumns = useMemo(
|
||||
() => columnOrder.map((key) => allColumns.find((column) => column.key === key)).filter(Boolean) as ColumnDef[],
|
||||
@@ -241,6 +259,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
setError(null);
|
||||
try {
|
||||
const tree = await getCircuitTree(projectId, circuitListId);
|
||||
projectRevisionRef.current = tree.currentRevision;
|
||||
setData(tree);
|
||||
} catch (err) {
|
||||
setError(normalizeUiError(err));
|
||||
@@ -767,6 +786,25 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
}
|
||||
}
|
||||
|
||||
function getExpectedProjectRevision() {
|
||||
if (projectRevisionRef.current === null) {
|
||||
throw new Error("Der Projektstand ist noch nicht geladen.");
|
||||
}
|
||||
return projectRevisionRef.current;
|
||||
}
|
||||
|
||||
function applyProjectCommandResult(result: ProjectCommandResultDto) {
|
||||
projectRevisionRef.current = result.history.currentRevision;
|
||||
setData((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
currentRevision: result.history.currentRevision,
|
||||
}
|
||||
: current
|
||||
);
|
||||
}
|
||||
|
||||
// Runs a normal command and records it in session-local history.
|
||||
async function runCommand(command: HistoryCommand) {
|
||||
try {
|
||||
@@ -790,7 +828,29 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
setError(null);
|
||||
setHistoryBusy(true);
|
||||
setIsSaving(true);
|
||||
const intent = mode === "undo" ? await command.undo() : await command.redo();
|
||||
let intent: SelectionIntent | null | void;
|
||||
if (command.persistent) {
|
||||
const result =
|
||||
mode === "undo"
|
||||
? await undoProjectCommand(
|
||||
projectId,
|
||||
getExpectedProjectRevision()
|
||||
)
|
||||
: await redoProjectCommand(
|
||||
projectId,
|
||||
getExpectedProjectRevision()
|
||||
);
|
||||
applyProjectCommandResult(result);
|
||||
intent =
|
||||
mode === "undo"
|
||||
? command.persistent.undoIntent()
|
||||
: command.persistent.redoIntent();
|
||||
} else {
|
||||
intent =
|
||||
mode === "undo"
|
||||
? await command.undo()
|
||||
: await command.redo();
|
||||
}
|
||||
await reloadWithIntent(intent ?? null);
|
||||
if (mode === "undo") {
|
||||
setUndoStack((current) => current.slice(0, -1));
|
||||
@@ -1145,46 +1205,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
setSelectedCell({ rowKey: targetRowKey, cellKey: best });
|
||||
}
|
||||
|
||||
// Maps generic grid cell edits to circuit PATCH payload fields.
|
||||
// Maps grid edits to typed persistent project commands.
|
||||
async function patchCircuit(circuitId: string, key: CellKey, draft: string) {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (key === "protectionSummary" || key === "cableSummary") {
|
||||
payload.remark = draft.trim() === "" ? undefined : draft;
|
||||
} else if (["protectionRatedCurrent", "cableLength", "voltage"].includes(key)) {
|
||||
payload[key] = parseNumeric(key, draft);
|
||||
} else if (key === "isReserve") {
|
||||
const normalized = draft.trim().toLowerCase();
|
||||
payload.isReserve = ["1", "true", "yes", "ja"].includes(normalized);
|
||||
} else {
|
||||
payload[key] = draft.trim() === "" ? undefined : draft;
|
||||
}
|
||||
await updateCircuitById(circuitId, payload);
|
||||
const result = await updateCircuitById(
|
||||
projectId,
|
||||
getExpectedProjectRevision(),
|
||||
circuitId,
|
||||
buildCircuitEditPatch(key, draft)
|
||||
);
|
||||
applyProjectCommandResult(result);
|
||||
}
|
||||
|
||||
// Maps generic grid cell edits to device-row PATCH payload fields.
|
||||
// Device-row commands derive local ProjectDevice override metadata server-side.
|
||||
async function patchDeviceRow(rowId: string, key: CellKey, draft: string) {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (["quantity", "powerPerUnit", "simultaneityFactor", "cosPhi"].includes(key)) {
|
||||
payload[key] = parseNumeric(key, draft);
|
||||
} else if (key === "roomSummary") {
|
||||
const trimmed = draft.trim();
|
||||
if (!trimmed) {
|
||||
payload.roomNumberSnapshot = undefined;
|
||||
payload.roomNameSnapshot = undefined;
|
||||
} else {
|
||||
const firstSpace = trimmed.indexOf(" ");
|
||||
if (firstSpace > 0) {
|
||||
payload.roomNumberSnapshot = trimmed.slice(0, firstSpace).trim();
|
||||
payload.roomNameSnapshot = trimmed.slice(firstSpace + 1).trim();
|
||||
} else {
|
||||
payload.roomNumberSnapshot = trimmed;
|
||||
payload.roomNameSnapshot = undefined;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
payload[key] = draft.trim() === "" ? undefined : draft;
|
||||
}
|
||||
await updateCircuitDeviceRowById(rowId, payload);
|
||||
const result = await updateCircuitDeviceRowById(
|
||||
projectId,
|
||||
getExpectedProjectRevision(),
|
||||
rowId,
|
||||
buildDeviceRowEditPatch(key, draft)
|
||||
);
|
||||
applyProjectCommandResult(result);
|
||||
}
|
||||
|
||||
// Creates a real circuit from virtual placeholder row.
|
||||
@@ -1204,6 +1244,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
const isDeviceField = deviceFieldKeys.has(key);
|
||||
|
||||
if (isDeviceField) {
|
||||
const editValues = createValuesFromEditPatch(
|
||||
buildDeviceRowEditPatch(key, draft)
|
||||
);
|
||||
const created = await createCircuitWithDeviceRows(projectId, circuitListId, {
|
||||
circuit: {
|
||||
sectionId,
|
||||
@@ -1221,37 +1264,28 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
powerPerUnit: 0,
|
||||
simultaneityFactor: 1,
|
||||
cosPhi: 1,
|
||||
...editValues,
|
||||
},
|
||||
],
|
||||
});
|
||||
const createdCircuit = created.circuit;
|
||||
const createdRow = created.deviceRows[0];
|
||||
await patchDeviceRow(createdRow.id, key, draft);
|
||||
return { rowKey: `circuitCompact:${createdCircuit.id}`, cellKey: key };
|
||||
}
|
||||
|
||||
const editValues = createValuesFromEditPatch(
|
||||
buildCircuitEditPatch(key, draft)
|
||||
);
|
||||
const createdCircuit = (await createCircuit(projectId, circuitListId, {
|
||||
sectionId,
|
||||
equipmentIdentifier: next.nextIdentifier,
|
||||
displayName: "Neuer Stromkreis",
|
||||
sortOrder,
|
||||
isReserve: true,
|
||||
...editValues,
|
||||
})) as CircuitTreeCircuitDto;
|
||||
await patchCircuit(createdCircuit.id, key, draft);
|
||||
return { rowKey: `reserveCircuit:${createdCircuit.id}`, cellKey: key };
|
||||
}
|
||||
|
||||
// Reads current persisted value into editable draft text for reversible edit commands.
|
||||
function getCurrentDraftForCell(row: VisibleGridRow, cellKey: CellKey, kind: CellKind) {
|
||||
if (kind === "deviceField" && row.device) {
|
||||
return String(getDeviceValue(row.device, cellKey) ?? "");
|
||||
}
|
||||
if (kind === "circuitField" && row.circuit) {
|
||||
return String(getCircuitValue(row.circuit, cellKey) ?? "");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Commits the active editor input through command history so edits remain undoable.
|
||||
async function commitEdit(direction: SaveDirection = "stay") {
|
||||
if (!editingCell) {
|
||||
@@ -1316,7 +1350,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
}
|
||||
|
||||
if (cell.kind === "circuitField" && row.circuit) {
|
||||
const previous = getCurrentDraftForCell(row, editingCell.cellKey, cell.kind);
|
||||
const next = editingCell.draft;
|
||||
const command: HistoryCommand = {
|
||||
label: "Stromkreisfeld bearbeiten",
|
||||
@@ -1324,9 +1357,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
await patchCircuit(row.circuit!.id, editingCell.cellKey, next);
|
||||
return nextSelectionIntent();
|
||||
},
|
||||
undo: async () => {
|
||||
await patchCircuit(row.circuit!.id, editingCell.cellKey, previous);
|
||||
return buildSelectionIntent({ rowKey: row.rowKey, cellKey: editingCell.cellKey });
|
||||
undo: async () =>
|
||||
buildSelectionIntent({
|
||||
rowKey: row.rowKey,
|
||||
cellKey: editingCell.cellKey,
|
||||
}),
|
||||
persistent: {
|
||||
undoIntent: () =>
|
||||
buildSelectionIntent({
|
||||
rowKey: row.rowKey,
|
||||
cellKey: editingCell.cellKey,
|
||||
}),
|
||||
redoIntent: nextSelectionIntent,
|
||||
},
|
||||
};
|
||||
setEditingCell(null);
|
||||
@@ -1335,7 +1377,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
}
|
||||
|
||||
if (cell.kind === "deviceField" && row.device) {
|
||||
const previous = getCurrentDraftForCell(row, editingCell.cellKey, cell.kind);
|
||||
const next = editingCell.draft;
|
||||
const command: HistoryCommand = {
|
||||
label: "Gerätefeld bearbeiten",
|
||||
@@ -1343,9 +1384,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
await patchDeviceRow(row.device!.id, editingCell.cellKey, next);
|
||||
return nextSelectionIntent();
|
||||
},
|
||||
undo: async () => {
|
||||
await patchDeviceRow(row.device!.id, editingCell.cellKey, previous);
|
||||
return buildSelectionIntent({ rowKey: `device:${row.device!.id}`, cellKey: editingCell.cellKey });
|
||||
undo: async () =>
|
||||
buildSelectionIntent({
|
||||
rowKey: `device:${row.device!.id}`,
|
||||
cellKey: editingCell.cellKey,
|
||||
}),
|
||||
persistent: {
|
||||
undoIntent: () =>
|
||||
buildSelectionIntent({
|
||||
rowKey: `device:${row.device!.id}`,
|
||||
cellKey: editingCell.cellKey,
|
||||
}),
|
||||
redoIntent: nextSelectionIntent,
|
||||
},
|
||||
};
|
||||
setEditingCell(null);
|
||||
@@ -1359,6 +1409,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
const command: HistoryCommand = {
|
||||
label: "Gerätezeile im Reservestromkreis erstellen",
|
||||
redo: async () => {
|
||||
const editValues = createValuesFromEditPatch(
|
||||
buildDeviceRowEditPatch(editingCell.cellKey, next)
|
||||
);
|
||||
const created = (await createCircuitDeviceRow(row.circuit!.id, {
|
||||
name: "Reserveverbraucher",
|
||||
displayName: "Reserveverbraucher",
|
||||
@@ -1367,9 +1420,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
powerPerUnit: 0,
|
||||
simultaneityFactor: 1,
|
||||
cosPhi: 1,
|
||||
...editValues,
|
||||
})) as { id: string };
|
||||
createdRowId = created.id;
|
||||
await patchDeviceRow(created.id, editingCell.cellKey, next);
|
||||
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey };
|
||||
return nextSelectionIntent();
|
||||
},
|
||||
|
||||
@@ -240,6 +240,7 @@ export interface CircuitTreeMigrationReportDto {
|
||||
|
||||
export interface CircuitTreeResponseDto {
|
||||
circuitListId: string;
|
||||
currentRevision: number;
|
||||
sections: CircuitTreeSectionDto[];
|
||||
migrationReport?: CircuitTreeMigrationReportDto;
|
||||
}
|
||||
@@ -264,8 +265,6 @@ export interface CreateCircuitInputDto {
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export type UpdateCircuitInputDto = Partial<CreateCircuitInputDto>;
|
||||
|
||||
export interface CreateCircuitDeviceRowInputDto {
|
||||
linkedProjectDeviceId?: string;
|
||||
name: string;
|
||||
@@ -287,5 +286,4 @@ export interface CreateCircuitDeviceRowInputDto {
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export type UpdateCircuitDeviceRowInputDto = Partial<CreateCircuitDeviceRowInputDto>;
|
||||
import type { ProjectDeviceSyncField } from "../shared/constants/project-device-sync-fields";
|
||||
|
||||
+32
-12
@@ -20,11 +20,17 @@ import type {
|
||||
CircuitTreeCircuitDto,
|
||||
CircuitTreeDeviceRowDto,
|
||||
CreateCircuitInputDto,
|
||||
UpdateCircuitInputDto,
|
||||
CreateCircuitDeviceRowInputDto,
|
||||
UpdateCircuitDeviceRowInputDto,
|
||||
} from "../types";
|
||||
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
|
||||
import {
|
||||
createCircuitUpdateProjectCommand,
|
||||
type CircuitUpdatePatch,
|
||||
} from "../../domain/models/circuit-project-command.model";
|
||||
import {
|
||||
createCircuitDeviceRowUpdateProjectCommand,
|
||||
type CircuitDeviceRowUpdatePatch,
|
||||
} from "../../domain/models/circuit-device-row-project-command.model";
|
||||
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
@@ -180,11 +186,18 @@ export function createCircuitWithDeviceRows(
|
||||
);
|
||||
}
|
||||
|
||||
export function updateCircuitById(circuitId: string, input: UpdateCircuitInputDto) {
|
||||
return request(`/api/circuits/${circuitId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
export function updateCircuitById(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
circuitId: string,
|
||||
input: CircuitUpdatePatch
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
createCircuitUpdateProjectCommand(circuitId, input),
|
||||
"Stromkreisfeld bearbeiten"
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteCircuitById(circuitId: string) {
|
||||
@@ -206,11 +219,18 @@ export function createCircuitDeviceRow(circuitId: string, input: CreateCircuitDe
|
||||
});
|
||||
}
|
||||
|
||||
export function updateCircuitDeviceRowById(rowId: string, input: UpdateCircuitDeviceRowInputDto) {
|
||||
return request(`/api/circuit-device-rows/${rowId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
export function updateCircuitDeviceRowById(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
rowId: string,
|
||||
input: CircuitDeviceRowUpdatePatch
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
createCircuitDeviceRowUpdateProjectCommand(rowId, input),
|
||||
"Gerätezeilenfeld bearbeiten"
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteCircuitDeviceRowById(rowId: string) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../types.js";
|
||||
import type { CircuitUpdatePatch } from "../../domain/models/circuit-project-command.model.js";
|
||||
import type { CircuitDeviceRowUpdatePatch } from "../../domain/models/circuit-device-row-project-command.model.js";
|
||||
|
||||
export type CellKey =
|
||||
| "equipmentIdentifier"
|
||||
@@ -189,6 +191,78 @@ export function parseNumeric(cellKey: CellKey, draft: string): number | undefine
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function buildCircuitEditPatch(
|
||||
key: CellKey,
|
||||
draft: string
|
||||
): CircuitUpdatePatch {
|
||||
if (key === "protectionSummary" || key === "cableSummary") {
|
||||
return { remark: draft.trim() || null };
|
||||
}
|
||||
if (
|
||||
key === "protectionRatedCurrent" ||
|
||||
key === "cableLength" ||
|
||||
key === "voltage"
|
||||
) {
|
||||
return { [key]: parseNumeric(key, draft) ?? null };
|
||||
}
|
||||
if (key === "isReserve") {
|
||||
const normalized = draft.trim().toLowerCase();
|
||||
return {
|
||||
isReserve: ["1", "true", "yes", "ja"].includes(normalized),
|
||||
};
|
||||
}
|
||||
if (key === "equipmentIdentifier") {
|
||||
return { equipmentIdentifier: draft.trim() };
|
||||
}
|
||||
return { [key]: draft.trim() || null } as CircuitUpdatePatch;
|
||||
}
|
||||
|
||||
export function buildDeviceRowEditPatch(
|
||||
key: CellKey,
|
||||
draft: string
|
||||
): CircuitDeviceRowUpdatePatch {
|
||||
if (
|
||||
key === "quantity" ||
|
||||
key === "powerPerUnit" ||
|
||||
key === "simultaneityFactor"
|
||||
) {
|
||||
const value = parseNumeric(key, draft);
|
||||
if (value === undefined) {
|
||||
throw new Error("Dieses Zahlenfeld darf nicht leer sein.");
|
||||
}
|
||||
return { [key]: value };
|
||||
}
|
||||
if (key === "cosPhi") {
|
||||
return { cosPhi: parseNumeric(key, draft) ?? null };
|
||||
}
|
||||
if (key === "roomSummary") {
|
||||
const trimmed = draft.trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
roomNumberSnapshot: null,
|
||||
roomNameSnapshot: null,
|
||||
};
|
||||
}
|
||||
const firstSpace = trimmed.indexOf(" ");
|
||||
return firstSpace > 0
|
||||
? {
|
||||
roomNumberSnapshot: trimmed.slice(0, firstSpace).trim(),
|
||||
roomNameSnapshot: trimmed.slice(firstSpace + 1).trim() || null,
|
||||
}
|
||||
: {
|
||||
roomNumberSnapshot: trimmed,
|
||||
roomNameSnapshot: null,
|
||||
};
|
||||
}
|
||||
if (key === "technicalName") {
|
||||
return { name: draft.trim() };
|
||||
}
|
||||
if (key === "displayName") {
|
||||
return { displayName: draft.trim() };
|
||||
}
|
||||
return { [key]: draft.trim() || null } as CircuitDeviceRowUpdatePatch;
|
||||
}
|
||||
|
||||
export function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): GridValue {
|
||||
switch (key) {
|
||||
case "technicalName": return device.name;
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
createCircuitDeviceRowSchema,
|
||||
moveCircuitDeviceRowsBulkSchema,
|
||||
moveCircuitDeviceRowSchema,
|
||||
updateCircuitDeviceRowSchema,
|
||||
} from "../../shared/validation/circuit.schemas.js";
|
||||
|
||||
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) {
|
||||
const { rowId } = req.params;
|
||||
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 { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
|
||||
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
|
||||
import { ProjectRepository } from "../../db/repositories/project.repository.js";
|
||||
import {
|
||||
calculateCircuitTotalPower,
|
||||
calculateRowTotalPower,
|
||||
@@ -13,6 +14,7 @@ const circuitListRepository = new CircuitListRepository();
|
||||
const circuitSectionRepository = new CircuitSectionRepository();
|
||||
const circuitRepository = new CircuitRepository();
|
||||
const circuitDeviceRowRepository = new CircuitDeviceRowRepository();
|
||||
const projectRepository = new ProjectRepository();
|
||||
|
||||
export function isMissingCircuitTreeSchemaError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
@@ -35,6 +37,10 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||
if (!list) {
|
||||
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 {
|
||||
const sections = await circuitSectionRepository.listByCircuitList(circuitListId);
|
||||
@@ -52,6 +58,7 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||
|
||||
const tree: CircuitTreeResponse = {
|
||||
circuitListId,
|
||||
currentRevision: project.currentRevision,
|
||||
sections: sections.map((section) => ({
|
||||
id: section.id,
|
||||
key: section.key,
|
||||
@@ -123,6 +130,7 @@ export async function getCircuitTree(req: Request, res: Response) {
|
||||
if (isMissingCircuitTreeSchemaError(error)) {
|
||||
return res.json({
|
||||
circuitListId,
|
||||
currentRevision: project.currentRevision,
|
||||
sections: [],
|
||||
warning:
|
||||
"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 {
|
||||
createCircuitSchema,
|
||||
createCircuitWithDeviceRowsSchema,
|
||||
updateCircuitSchema,
|
||||
} from "../../shared/validation/circuit.schemas.js";
|
||||
|
||||
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) {
|
||||
const { circuitId } = req.params;
|
||||
if (typeof circuitId !== "string") {
|
||||
|
||||
@@ -3,12 +3,10 @@ import {
|
||||
deleteCircuitDeviceRow,
|
||||
moveCircuitDeviceRowsBulk,
|
||||
moveCircuitDeviceRow,
|
||||
updateCircuitDeviceRow,
|
||||
} 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", updateCircuitDeviceRow);
|
||||
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId/move", moveCircuitDeviceRow);
|
||||
circuitDeviceRowRouter.delete("/circuit-device-rows/:rowId", deleteCircuitDeviceRow);
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
createCircuitWithDeviceRows,
|
||||
deleteCircuit,
|
||||
getNextCircuitIdentifier,
|
||||
updateCircuit,
|
||||
} from "../controllers/circuit.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",
|
||||
createCircuitWithDeviceRows
|
||||
);
|
||||
circuitRouter.patch("/circuits/:circuitId", updateCircuit);
|
||||
circuitRouter.delete("/circuits/:circuitId", deleteCircuit);
|
||||
circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier);
|
||||
circuitRouter.post("/circuits/:circuitId/device-rows", createCircuitDeviceRow);
|
||||
|
||||
@@ -20,13 +20,6 @@ export const createCircuitSchema = z.object({
|
||||
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({
|
||||
linkedProjectDeviceId: z.string().min(1).optional(),
|
||||
name: z.string().min(1),
|
||||
@@ -53,8 +46,6 @@ export const createCircuitWithDeviceRowsSchema = z.object({
|
||||
deviceRows: z.array(createCircuitDeviceRowSchema).min(1),
|
||||
});
|
||||
|
||||
export const updateCircuitDeviceRowSchema = createCircuitDeviceRowSchema.partial();
|
||||
|
||||
export const moveCircuitDeviceRowSchema = z
|
||||
.object({
|
||||
targetCircuitId: z.string().min(1).optional(),
|
||||
@@ -98,10 +89,8 @@ export const updateSectionEquipmentIdentifiersSchema = z.object({
|
||||
});
|
||||
|
||||
export type CreateCircuitInput = z.infer<typeof createCircuitSchema>;
|
||||
export type UpdateCircuitInput = z.infer<typeof updateCircuitSchema>;
|
||||
export type CreateCircuitDeviceRowInput = z.infer<typeof createCircuitDeviceRowSchema>;
|
||||
export type CreateCircuitWithDeviceRowsInput = z.infer<typeof createCircuitWithDeviceRowsSchema>;
|
||||
export type UpdateCircuitDeviceRowInput = z.infer<typeof updateCircuitDeviceRowSchema>;
|
||||
export type MoveCircuitDeviceRowInput = z.infer<typeof moveCircuitDeviceRowSchema>;
|
||||
export type MoveCircuitDeviceRowsBulkInput = z.infer<typeof moveCircuitDeviceRowsBulkSchema>;
|
||||
export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>;
|
||||
|
||||
@@ -2,6 +2,8 @@ import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
allColumns,
|
||||
buildCircuitEditPatch,
|
||||
buildDeviceRowEditPatch,
|
||||
getBlockSortValue,
|
||||
getCellKind,
|
||||
getCircuitValue,
|
||||
@@ -85,4 +87,38 @@ describe("circuit grid model", () => {
|
||||
assert.equal(parseNumeric("quantity", ""), undefined);
|
||||
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",
|
||||
sortOrder: 10,
|
||||
protectionRatedCurrent: 10,
|
||||
voltage: 230,
|
||||
controlRequirement: "none",
|
||||
isReserve: 0,
|
||||
},
|
||||
{
|
||||
@@ -111,6 +113,8 @@ describe("circuit project-command repository", () => {
|
||||
const command = createCircuitUpdateProjectCommand("circuit-1", {
|
||||
displayName: null,
|
||||
protectionRatedCurrent: 16,
|
||||
voltage: 400,
|
||||
controlRequirement: "DALI",
|
||||
isReserve: true,
|
||||
});
|
||||
|
||||
@@ -125,6 +129,8 @@ describe("circuit project-command repository", () => {
|
||||
const circuit = getCircuit(fixture.context);
|
||||
assert.equal(circuit.displayName, null);
|
||||
assert.equal(circuit.protectionRatedCurrent, 16);
|
||||
assert.equal(circuit.voltage, 400);
|
||||
assert.equal(circuit.controlRequirement, "DALI");
|
||||
assert.equal(circuit.isReserve, 1);
|
||||
assert.equal(executed.revision.revisionNumber, 1);
|
||||
assert.deepEqual(executed.inverse.payload, {
|
||||
@@ -132,6 +138,8 @@ describe("circuit project-command repository", () => {
|
||||
changes: [
|
||||
{ field: "displayName", value: "Licht Bestand" },
|
||||
{ field: "protectionRatedCurrent", value: 10 },
|
||||
{ field: "voltage", value: 230 },
|
||||
{ field: "controlRequirement", value: "none" },
|
||||
{ field: "isReserve", value: false },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -16,45 +16,6 @@ describe("circuit write service rules", () => {
|
||||
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 () => {
|
||||
const service = new CircuitWriteService({
|
||||
circuitListRepository: {
|
||||
@@ -741,36 +702,4 @@ describe("circuit write service rules", () => {
|
||||
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\"]",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user