diff --git a/docs/circuit-list-editor-interactions.md b/docs/circuit-list-editor-interactions.md index e4b2cef..8b799db 100644 --- a/docs/circuit-list-editor-interactions.md +++ b/docs/circuit-list-editor-interactions.md @@ -57,6 +57,9 @@ Intent is separated by drag source type: - project device drag: - drop to section/placeholder -> create new circuit with linked row - drop to existing circuit row -> append row to that circuit + - lighting devices are accepted only by the lighting section + - other devices are accepted only by the section matching their phase type + - invalid targets show rejection feedback and do not create data - device row drag: - drop to existing circuit -> move row(s) into that circuit - drop to placeholder -> create new target circuit and move row(s) @@ -68,6 +71,8 @@ Intent is separated by drag source type: - multi-circuit move: - supported for same-section selected circuit blocks +The sidebar insertion controls use the same project-device placement rules as drag-and-drop. Invalid section and circuit options are disabled after selecting a project device. + ## Filtering and Sorting - Per-column filtering works on normalized displayed values. diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index 70f289b..82a7a5d 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -8,4 +8,5 @@ - Bulk device-row move flow is command-based but not fully transaction-hardened end-to-end across all affected circuits. - Sorting is view-only until users explicitly apply sorted order. - Cross-section circuit drag-reorder is intentionally blocked. +- Existing device rows can currently be moved across sections without the planned classification confirmation dialog. - Legacy consumer and circuit-first paths coexist; migration is transitional and still requires operational discipline. diff --git a/package.json b/package.json index 80b5112..0fa23fd 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "start": "node dist/server/index.js", - "test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts", - "test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts", + "test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts", + "test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "node scripts/db-backup.js", diff --git a/src/domain/services/project-device-placement.service.ts b/src/domain/services/project-device-placement.service.ts new file mode 100644 index 0000000..3bf2123 --- /dev/null +++ b/src/domain/services/project-device-placement.service.ts @@ -0,0 +1,29 @@ +export interface ProjectDevicePlacementSource { + category?: string | null; + phaseType: "single_phase" | "three_phase"; +} + +export interface ProjectDevicePlacementSection { + key: string; +} + +export type DefaultCircuitSectionKey = "lighting" | "single_phase" | "three_phase"; + +// Lighting classification takes precedence over the electrical phase because +// lighting devices use their dedicated numbering section by default. +export function inferProjectDeviceSectionKey( + device: ProjectDevicePlacementSource +): DefaultCircuitSectionKey { + const category = (device.category ?? "").trim().toLowerCase(); + if (category.includes("light") || category.includes("beleuchtung")) { + return "lighting"; + } + return device.phaseType; +} + +export function isProjectDevicePlacementValid( + device: ProjectDevicePlacementSource, + section: ProjectDevicePlacementSection +): boolean { + return section.key === inferProjectDeviceSectionKey(device); +} diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index f21e9dc..6f23b6b 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -1,6 +1,10 @@ "use client"; import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"; +import { + inferProjectDeviceSectionKey, + isProjectDevicePlacementValid, +} from "../../domain/services/project-device-placement.service"; import { createCircuit, createCircuitDeviceRow, @@ -126,8 +130,8 @@ interface CircuitSnapshot { } type ProjectDeviceDropIntent = - | { kind: "new-circuit"; sectionId: string } - | { kind: "add-to-circuit"; circuitId: string; sectionId: string }; + | { kind: "new-circuit"; sectionId: string; valid: boolean } + | { kind: "add-to-circuit"; circuitId: string; sectionId: string; valid: boolean }; type DeviceRowMoveDropIntent = | { kind: "move-to-circuit"; circuitId: string; sectionId: string } @@ -1706,6 +1710,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return projectDevices.find((device) => device.id === selectedProjectDeviceId) ?? null; } + function isProjectDeviceTargetValid(device: ProjectDeviceDto, sectionId: string) { + const section = data?.sections.find((entry) => entry.id === sectionId); + return Boolean(section && isProjectDevicePlacementValid(device, section)); + } + + function getProjectDeviceTargetError(device: ProjectDeviceDto, sectionId: string) { + const target = data?.sections.find((entry) => entry.id === sectionId); + const expectedKey = inferProjectDeviceSectionKey(device); + const expected = data?.sections.find((entry) => entry.key === expectedKey); + if (!target) { + return "Invalid target section."; + } + return `${device.displayName || device.name} belongs in ${expected?.displayName ?? expectedKey}, not ${target.displayName}.`; + } + async function handleAddProjectDeviceAsNewCircuit() { const device = resolveSelectedProjectDevice(); if (!device) { @@ -1716,6 +1735,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str setError("Please select a target section."); return; } + if (!isProjectDeviceTargetValid(device, targetSectionId)) { + setError(getProjectDeviceTargetError(device, targetSectionId)); + return; + } let createdCircuitId: string | null = null; let createdRowId: string | null = null; await runCommand({ @@ -1764,6 +1787,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str setError("Please select a target circuit."); return; } + const sectionId = findCircuitSectionId(circuitId); + if (!sectionId || !isProjectDeviceTargetValid(device, sectionId)) { + setError(getProjectDeviceTargetError(device, sectionId ?? "")); + return; + } let createdRowId: string | null = null; await runCommand({ @@ -2076,6 +2104,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str setError("Invalid project device drop source."); return; } + if (!intent.valid || !isProjectDeviceTargetValid(device, intent.sectionId)) { + setError(getProjectDeviceTargetError(device, intent.sectionId)); + return; + } // Project-device drop logic is isolated from row/circuit move logic because // it can create new rows/circuits instead of moving existing ones. if (intent.kind === "new-circuit") { @@ -2580,6 +2612,25 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const activeDraggedCircuitIds = draggingCircuitIds.length > 0 ? draggingCircuitIds : draggingCircuitId ? [draggingCircuitId] : []; const draggingCircuitCount = activeDraggedCircuitIds.length; + const selectedProjectDevice = resolveSelectedProjectDevice(); + const suggestedSection = selectedProjectDevice + ? data.sections.find((section) => section.key === inferProjectDeviceSectionKey(selectedProjectDevice)) + : null; + const selectedRowCircuitId = selectedCell ? findRow(selectedCell.rowKey)?.circuit?.id ?? null : null; + const resolvedSidebarTargetCircuitId = targetCircuitId ?? selectedRowCircuitId; + const resolvedSidebarTargetSectionId = resolvedSidebarTargetCircuitId + ? findCircuitSectionId(resolvedSidebarTargetCircuitId) + : null; + const canAddToSelectedSection = Boolean( + selectedProjectDevice && + targetSectionId && + isProjectDeviceTargetValid(selectedProjectDevice, targetSectionId) + ); + const canAddToSelectedCircuit = Boolean( + selectedProjectDevice && + resolvedSidebarTargetSectionId && + isProjectDeviceTargetValid(selectedProjectDevice, resolvedSidebarTargetSectionId) + ); return (
@@ -2722,14 +2773,24 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str onChange={(event) => setTargetSectionId(event.target.value || null)} > - {data.sections.map((section) => ( - - ))} + {data.sections.map((section) => { + const valid = !selectedProjectDevice || isProjectDevicePlacementValid(selectedProjectDevice, section); + return ( + + ); + })} - -
@@ -2849,7 +2917,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str { if (draggingCircuitCount > 0) { @@ -2865,9 +2939,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str if (!draggingProjectDeviceId) { return; } + const device = projectDevices.find((entry) => entry.id === draggingProjectDeviceId); + const valid = Boolean(device && isProjectDevicePlacementValid(device, section)); event.preventDefault(); - event.dataTransfer.dropEffect = "copy"; - setDropIntent({ kind: "new-circuit", sectionId: section.id }); + event.dataTransfer.dropEffect = valid ? "copy" : "none"; + setDropIntent({ kind: "new-circuit", sectionId: section.id, valid }); }} onDragLeave={() => { if (dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id) { @@ -2879,7 +2955,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }} onDrop={(event) => { if (draggingProjectDeviceId) { - void handleDropWithIntent(event, { kind: "new-circuit", sectionId: section.id }); + const device = projectDevices.find((entry) => entry.id === draggingProjectDeviceId); + void handleDropWithIntent(event, { + kind: "new-circuit", + sectionId: section.id, + valid: Boolean(device && isProjectDevicePlacementValid(device, section)), + }); return; } if (draggingCircuitCount > 0) { @@ -2910,7 +2991,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str {dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? ( - drop here to create new circuit + + {dropIntent.valid ? "drop here to create new circuit" : "device does not match this section"} + ) : null} @@ -2932,11 +3015,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str : "" } ${ row.rowType === "placeholder" && - ((dropIntent?.kind === "new-circuit" && dropIntent.sectionId === row.sectionId) || + ((dropIntent?.kind === "new-circuit" && dropIntent.sectionId === row.sectionId && dropIntent.valid) || (deviceMoveIntent?.kind === "move-to-new-circuit" && deviceMoveIntent.sectionId === row.sectionId) || (circuitReorderIntent?.kind === "section-end" && circuitReorderIntent.sectionId === row.sectionId)) ? "drop-target-active" : "" + } ${ + row.rowType === "placeholder" && + dropIntent?.kind === "new-circuit" && + dropIntent.sectionId === row.sectionId && + !dropIntent.valid + ? "drop-target-invalid" + : "" } ${ row.rowType === "placeholder" && circuitReorderIntent?.kind === "section-end" && @@ -2944,6 +3034,20 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str !circuitReorderIntent.valid ? "drop-target-invalid" : "" + } ${ + row.circuit && + dropIntent?.kind === "add-to-circuit" && + dropIntent.circuitId === row.circuit.id && + dropIntent.valid + ? "drop-target-active" + : "" + } ${ + row.circuit && + dropIntent?.kind === "add-to-circuit" && + dropIntent.circuitId === row.circuit.id && + !dropIntent.valid + ? "drop-target-invalid" + : "" } ${ row.circuit && circuitReorderIntent && @@ -3014,16 +3118,24 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return; } if (draggingProjectDeviceId) { + const device = projectDevices.find((entry) => entry.id === draggingProjectDeviceId); + const section = data.sections.find((entry) => entry.id === row.sectionId); + const valid = Boolean(device && section && isProjectDevicePlacementValid(device, section)); if (row.rowType === "placeholder") { event.preventDefault(); - event.dataTransfer.dropEffect = "copy"; - setDropIntent({ kind: "new-circuit", sectionId: row.sectionId }); + event.dataTransfer.dropEffect = valid ? "copy" : "none"; + setDropIntent({ kind: "new-circuit", sectionId: row.sectionId, valid }); return; } if (row.circuit && row.rowType !== "deviceRow") { event.preventDefault(); - event.dataTransfer.dropEffect = "copy"; - setDropIntent({ kind: "add-to-circuit", circuitId: row.circuit.id, sectionId: row.sectionId }); + event.dataTransfer.dropEffect = valid ? "copy" : "none"; + setDropIntent({ + kind: "add-to-circuit", + circuitId: row.circuit.id, + sectionId: row.sectionId, + valid, + }); } return; } @@ -3119,8 +3231,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return; } if (draggingProjectDeviceId) { + const device = projectDevices.find((entry) => entry.id === draggingProjectDeviceId); + const section = data.sections.find((entry) => entry.id === row.sectionId); + const valid = Boolean(device && section && isProjectDevicePlacementValid(device, section)); if (row.rowType === "placeholder") { - void handleDropWithIntent(event, { kind: "new-circuit", sectionId: row.sectionId }); + void handleDropWithIntent(event, { kind: "new-circuit", sectionId: row.sectionId, valid }); return; } if (row.circuit && row.rowType !== "deviceRow") { @@ -3128,6 +3243,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str kind: "add-to-circuit", circuitId: row.circuit.id, sectionId: row.sectionId, + valid, }); } return; @@ -3287,10 +3403,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str })} {row.circuit && row.rowType !== "deviceRow" ? ( @@ -3317,10 +3441,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str ) : null} {dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? ( - new circuit in this section + + {dropIntent.valid ? "new circuit in this section" : "device does not match this section"} + ) : null} {dropIntent?.kind === "add-to-circuit" && dropIntent.circuitId === row.circuit?.id ? ( - add to this circuit + + {dropIntent.valid ? "add to this circuit" : "device does not match this circuit section"} + ) : null} {deviceMoveIntent?.kind === "move-to-new-circuit" && row.rowType === "placeholder" && diff --git a/tests/project-device-placement.service.test.ts b/tests/project-device-placement.service.test.ts new file mode 100644 index 0000000..fef7ee6 --- /dev/null +++ b/tests/project-device-placement.service.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + inferProjectDeviceSectionKey, + isProjectDevicePlacementValid, +} from "../src/domain/services/project-device-placement.service.js"; + +describe("project device placement", () => { + it("routes lighting categories to the lighting section before phase classification", () => { + assert.equal( + inferProjectDeviceSectionKey({ category: "Lighting", phaseType: "three_phase" }), + "lighting" + ); + assert.equal( + inferProjectDeviceSectionKey({ category: "Beleuchtung", phaseType: "single_phase" }), + "lighting" + ); + }); + + it("routes non-lighting devices by phase type", () => { + assert.equal( + inferProjectDeviceSectionKey({ category: "Socket", phaseType: "single_phase" }), + "single_phase" + ); + assert.equal( + inferProjectDeviceSectionKey({ category: "Motor", phaseType: "three_phase" }), + "three_phase" + ); + }); + + it("accepts only the inferred default section", () => { + const device = { category: "Motor", phaseType: "three_phase" as const }; + + assert.equal(isProjectDevicePlacementValid(device, { key: "three_phase" }), true); + assert.equal(isProjectDevicePlacementValid(device, { key: "single_phase" }), false); + assert.equal(isProjectDevicePlacementValid(device, { key: "unassigned" }), false); + }); +});