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 (