Validate project device drop targets
This commit is contained in:
@@ -57,6 +57,9 @@ Intent is separated by drag source type:
|
|||||||
- project device drag:
|
- project device drag:
|
||||||
- drop to section/placeholder -> create new circuit with linked row
|
- drop to section/placeholder -> create new circuit with linked row
|
||||||
- drop to existing circuit row -> append row to that circuit
|
- 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:
|
- device row drag:
|
||||||
- drop to existing circuit -> move row(s) into that circuit
|
- drop to existing circuit -> move row(s) into that circuit
|
||||||
- drop to placeholder -> create new target circuit and move row(s)
|
- 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:
|
- multi-circuit move:
|
||||||
- supported for same-section selected circuit blocks
|
- 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
|
## Filtering and Sorting
|
||||||
|
|
||||||
- Per-column filtering works on normalized displayed values.
|
- Per-column filtering works on normalized displayed values.
|
||||||
|
|||||||
@@ -8,4 +8,5 @@
|
|||||||
- Bulk device-row move flow is command-based but not fully transaction-hardened end-to-end across all affected circuits.
|
- 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.
|
- Sorting is view-only until users explicitly apply sorted order.
|
||||||
- Cross-section circuit drag-reorder is intentionally blocked.
|
- 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.
|
- Legacy consumer and circuit-first paths coexist; migration is transitional and still requires operational discipline.
|
||||||
|
|||||||
+2
-2
@@ -11,8 +11,8 @@
|
|||||||
"build:api": "tsc -p tsconfig.json",
|
"build:api": "tsc -p tsconfig.json",
|
||||||
"build:web": "next build",
|
"build:web": "next build",
|
||||||
"start": "node dist/server/index.js",
|
"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": "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-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:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:backup": "node scripts/db-backup.js",
|
"db:backup": "node scripts/db-backup.js",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
|
import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
inferProjectDeviceSectionKey,
|
||||||
|
isProjectDevicePlacementValid,
|
||||||
|
} from "../../domain/services/project-device-placement.service";
|
||||||
import {
|
import {
|
||||||
createCircuit,
|
createCircuit,
|
||||||
createCircuitDeviceRow,
|
createCircuitDeviceRow,
|
||||||
@@ -126,8 +130,8 @@ interface CircuitSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProjectDeviceDropIntent =
|
type ProjectDeviceDropIntent =
|
||||||
| { kind: "new-circuit"; sectionId: string }
|
| { kind: "new-circuit"; sectionId: string; valid: boolean }
|
||||||
| { kind: "add-to-circuit"; circuitId: string; sectionId: string };
|
| { kind: "add-to-circuit"; circuitId: string; sectionId: string; valid: boolean };
|
||||||
|
|
||||||
type DeviceRowMoveDropIntent =
|
type DeviceRowMoveDropIntent =
|
||||||
| { kind: "move-to-circuit"; circuitId: string; sectionId: string }
|
| { 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;
|
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() {
|
async function handleAddProjectDeviceAsNewCircuit() {
|
||||||
const device = resolveSelectedProjectDevice();
|
const device = resolveSelectedProjectDevice();
|
||||||
if (!device) {
|
if (!device) {
|
||||||
@@ -1716,6 +1735,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
setError("Please select a target section.");
|
setError("Please select a target section.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!isProjectDeviceTargetValid(device, targetSectionId)) {
|
||||||
|
setError(getProjectDeviceTargetError(device, targetSectionId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
let createdCircuitId: string | null = null;
|
let createdCircuitId: string | null = null;
|
||||||
let createdRowId: string | null = null;
|
let createdRowId: string | null = null;
|
||||||
await runCommand({
|
await runCommand({
|
||||||
@@ -1764,6 +1787,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
setError("Please select a target circuit.");
|
setError("Please select a target circuit.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const sectionId = findCircuitSectionId(circuitId);
|
||||||
|
if (!sectionId || !isProjectDeviceTargetValid(device, sectionId)) {
|
||||||
|
setError(getProjectDeviceTargetError(device, sectionId ?? ""));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let createdRowId: string | null = null;
|
let createdRowId: string | null = null;
|
||||||
await runCommand({
|
await runCommand({
|
||||||
@@ -2076,6 +2104,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
setError("Invalid project device drop source.");
|
setError("Invalid project device drop source.");
|
||||||
return;
|
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
|
// Project-device drop logic is isolated from row/circuit move logic because
|
||||||
// it can create new rows/circuits instead of moving existing ones.
|
// it can create new rows/circuits instead of moving existing ones.
|
||||||
if (intent.kind === "new-circuit") {
|
if (intent.kind === "new-circuit") {
|
||||||
@@ -2580,6 +2612,25 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
const activeDraggedCircuitIds =
|
const activeDraggedCircuitIds =
|
||||||
draggingCircuitIds.length > 0 ? draggingCircuitIds : draggingCircuitId ? [draggingCircuitId] : [];
|
draggingCircuitIds.length > 0 ? draggingCircuitIds : draggingCircuitId ? [draggingCircuitId] : [];
|
||||||
const draggingCircuitCount = activeDraggedCircuitIds.length;
|
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 (
|
return (
|
||||||
<div className="tree-editor-shell">
|
<div className="tree-editor-shell">
|
||||||
@@ -2722,14 +2773,24 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
onChange={(event) => setTargetSectionId(event.target.value || null)}
|
onChange={(event) => setTargetSectionId(event.target.value || null)}
|
||||||
>
|
>
|
||||||
<option value="">Select section...</option>
|
<option value="">Select section...</option>
|
||||||
{data.sections.map((section) => (
|
{data.sections.map((section) => {
|
||||||
<option key={section.id} value={section.id}>
|
const valid = !selectedProjectDevice || isProjectDevicePlacementValid(selectedProjectDevice, section);
|
||||||
{section.displayName}
|
return (
|
||||||
</option>
|
<option key={section.id} value={section.id} disabled={!valid}>
|
||||||
))}
|
{section.displayName}{valid ? "" : " (not valid)"}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button type="button" onClick={() => void handleAddProjectDeviceAsNewCircuit()}>
|
{selectedProjectDevice && suggestedSection ? (
|
||||||
|
<p className="notice muted">Suggested section: {suggestedSection.displayName}</p>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canAddToSelectedSection}
|
||||||
|
onClick={() => void handleAddProjectDeviceAsNewCircuit()}
|
||||||
|
>
|
||||||
Add as new circuit
|
Add as new circuit
|
||||||
</button>
|
</button>
|
||||||
<label>
|
<label>
|
||||||
@@ -2739,14 +2800,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
onChange={(event) => setTargetCircuitId(event.target.value || null)}
|
onChange={(event) => setTargetCircuitId(event.target.value || null)}
|
||||||
>
|
>
|
||||||
<option value="">Use selected row circuit...</option>
|
<option value="">Use selected row circuit...</option>
|
||||||
{circuitOptions.map((option) => (
|
{circuitOptions.map((option) => {
|
||||||
<option key={option.id} value={option.id}>
|
const valid = !selectedProjectDevice || isProjectDeviceTargetValid(selectedProjectDevice, option.sectionId);
|
||||||
{option.label}
|
return (
|
||||||
</option>
|
<option key={option.id} value={option.id} disabled={!valid}>
|
||||||
))}
|
{option.label}{valid ? "" : " (not valid)"}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button type="button" onClick={() => void handleAddProjectDeviceToCircuit()}>
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canAddToSelectedCircuit}
|
||||||
|
onClick={() => void handleAddProjectDeviceToCircuit()}
|
||||||
|
>
|
||||||
Add to selected circuit
|
Add to selected circuit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2849,7 +2917,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
<tr
|
<tr
|
||||||
key={row.rowKey}
|
key={row.rowKey}
|
||||||
className={`section-row ${
|
className={`section-row ${
|
||||||
dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? "drop-target-active" : ""
|
dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id && dropIntent.valid
|
||||||
|
? "drop-target-active"
|
||||||
|
: ""
|
||||||
|
} ${
|
||||||
|
dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id && !dropIntent.valid
|
||||||
|
? "drop-target-invalid"
|
||||||
|
: ""
|
||||||
}`}
|
}`}
|
||||||
onDragOver={(event) => {
|
onDragOver={(event) => {
|
||||||
if (draggingCircuitCount > 0) {
|
if (draggingCircuitCount > 0) {
|
||||||
@@ -2865,9 +2939,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
if (!draggingProjectDeviceId) {
|
if (!draggingProjectDeviceId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const device = projectDevices.find((entry) => entry.id === draggingProjectDeviceId);
|
||||||
|
const valid = Boolean(device && isProjectDevicePlacementValid(device, section));
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.dataTransfer.dropEffect = "copy";
|
event.dataTransfer.dropEffect = valid ? "copy" : "none";
|
||||||
setDropIntent({ kind: "new-circuit", sectionId: section.id });
|
setDropIntent({ kind: "new-circuit", sectionId: section.id, valid });
|
||||||
}}
|
}}
|
||||||
onDragLeave={() => {
|
onDragLeave={() => {
|
||||||
if (dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id) {
|
if (dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id) {
|
||||||
@@ -2879,7 +2955,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
}}
|
}}
|
||||||
onDrop={(event) => {
|
onDrop={(event) => {
|
||||||
if (draggingProjectDeviceId) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (draggingCircuitCount > 0) {
|
if (draggingCircuitCount > 0) {
|
||||||
@@ -2910,7 +2991,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? (
|
{dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? (
|
||||||
<span className="drop-hint">drop here to create new circuit</span>
|
<span className="drop-hint">
|
||||||
|
{dropIntent.valid ? "drop here to create new circuit" : "device does not match this section"}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -2932,11 +3015,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
: ""
|
: ""
|
||||||
} ${
|
} ${
|
||||||
row.rowType === "placeholder" &&
|
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) ||
|
(deviceMoveIntent?.kind === "move-to-new-circuit" && deviceMoveIntent.sectionId === row.sectionId) ||
|
||||||
(circuitReorderIntent?.kind === "section-end" && circuitReorderIntent.sectionId === row.sectionId))
|
(circuitReorderIntent?.kind === "section-end" && circuitReorderIntent.sectionId === row.sectionId))
|
||||||
? "drop-target-active"
|
? "drop-target-active"
|
||||||
: ""
|
: ""
|
||||||
|
} ${
|
||||||
|
row.rowType === "placeholder" &&
|
||||||
|
dropIntent?.kind === "new-circuit" &&
|
||||||
|
dropIntent.sectionId === row.sectionId &&
|
||||||
|
!dropIntent.valid
|
||||||
|
? "drop-target-invalid"
|
||||||
|
: ""
|
||||||
} ${
|
} ${
|
||||||
row.rowType === "placeholder" &&
|
row.rowType === "placeholder" &&
|
||||||
circuitReorderIntent?.kind === "section-end" &&
|
circuitReorderIntent?.kind === "section-end" &&
|
||||||
@@ -2944,6 +3034,20 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
!circuitReorderIntent.valid
|
!circuitReorderIntent.valid
|
||||||
? "drop-target-invalid"
|
? "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 &&
|
row.circuit &&
|
||||||
circuitReorderIntent &&
|
circuitReorderIntent &&
|
||||||
@@ -3014,16 +3118,24 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (draggingProjectDeviceId) {
|
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") {
|
if (row.rowType === "placeholder") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.dataTransfer.dropEffect = "copy";
|
event.dataTransfer.dropEffect = valid ? "copy" : "none";
|
||||||
setDropIntent({ kind: "new-circuit", sectionId: row.sectionId });
|
setDropIntent({ kind: "new-circuit", sectionId: row.sectionId, valid });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (row.circuit && row.rowType !== "deviceRow") {
|
if (row.circuit && row.rowType !== "deviceRow") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.dataTransfer.dropEffect = "copy";
|
event.dataTransfer.dropEffect = valid ? "copy" : "none";
|
||||||
setDropIntent({ kind: "add-to-circuit", circuitId: row.circuit.id, sectionId: row.sectionId });
|
setDropIntent({
|
||||||
|
kind: "add-to-circuit",
|
||||||
|
circuitId: row.circuit.id,
|
||||||
|
sectionId: row.sectionId,
|
||||||
|
valid,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3119,8 +3231,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (draggingProjectDeviceId) {
|
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") {
|
if (row.rowType === "placeholder") {
|
||||||
void handleDropWithIntent(event, { kind: "new-circuit", sectionId: row.sectionId });
|
void handleDropWithIntent(event, { kind: "new-circuit", sectionId: row.sectionId, valid });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (row.circuit && row.rowType !== "deviceRow") {
|
if (row.circuit && row.rowType !== "deviceRow") {
|
||||||
@@ -3128,6 +3243,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
kind: "add-to-circuit",
|
kind: "add-to-circuit",
|
||||||
circuitId: row.circuit.id,
|
circuitId: row.circuit.id,
|
||||||
sectionId: row.sectionId,
|
sectionId: row.sectionId,
|
||||||
|
valid,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -3287,10 +3403,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
})}
|
})}
|
||||||
<td
|
<td
|
||||||
className={`action-cell ${
|
className={`action-cell ${
|
||||||
(dropIntent?.kind === "add-to-circuit" && dropIntent.circuitId === row.circuit?.id) ||
|
(dropIntent?.kind === "add-to-circuit" &&
|
||||||
|
dropIntent.circuitId === row.circuit?.id &&
|
||||||
|
dropIntent.valid) ||
|
||||||
(deviceMoveIntent?.kind === "move-to-circuit" && deviceMoveIntent.circuitId === row.circuit?.id)
|
(deviceMoveIntent?.kind === "move-to-circuit" && deviceMoveIntent.circuitId === row.circuit?.id)
|
||||||
? "drop-target-active"
|
? "drop-target-active"
|
||||||
: ""
|
: ""
|
||||||
|
} ${
|
||||||
|
dropIntent?.kind === "add-to-circuit" &&
|
||||||
|
dropIntent.circuitId === row.circuit?.id &&
|
||||||
|
!dropIntent.valid
|
||||||
|
? "drop-target-invalid"
|
||||||
|
: ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{row.circuit && row.rowType !== "deviceRow" ? (
|
{row.circuit && row.rowType !== "deviceRow" ? (
|
||||||
@@ -3317,10 +3441,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? (
|
{dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? (
|
||||||
<span className="drop-hint">new circuit in this section</span>
|
<span className="drop-hint">
|
||||||
|
{dropIntent.valid ? "new circuit in this section" : "device does not match this section"}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{dropIntent?.kind === "add-to-circuit" && dropIntent.circuitId === row.circuit?.id ? (
|
{dropIntent?.kind === "add-to-circuit" && dropIntent.circuitId === row.circuit?.id ? (
|
||||||
<span className="drop-hint">add to this circuit</span>
|
<span className="drop-hint">
|
||||||
|
{dropIntent.valid ? "add to this circuit" : "device does not match this circuit section"}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{deviceMoveIntent?.kind === "move-to-new-circuit" &&
|
{deviceMoveIntent?.kind === "move-to-new-circuit" &&
|
||||||
row.rowType === "placeholder" &&
|
row.rowType === "placeholder" &&
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user