1816 lines
67 KiB
TypeScript
1816 lines
67 KiB
TypeScript
"use client";
|
|
|
|
import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
createCircuit,
|
|
createCircuitDeviceRow,
|
|
deleteCircuitById,
|
|
deleteCircuitDeviceRowById,
|
|
getCircuitTree,
|
|
getNextCircuitIdentifier,
|
|
listProjectDevices,
|
|
moveCircuitDeviceRowById,
|
|
reorderSectionCircuits,
|
|
renumberCircuitSection,
|
|
updateCircuitById,
|
|
updateCircuitDeviceRowById,
|
|
} from "../utils/api";
|
|
import type {
|
|
CircuitTreeCircuitDto,
|
|
CircuitTreeDeviceRowDto,
|
|
CircuitTreeResponseDto,
|
|
ProjectDeviceDto,
|
|
} from "../types";
|
|
|
|
type CellKey =
|
|
| "equipmentIdentifier"
|
|
| "displayName"
|
|
| "quantity"
|
|
| "powerPerUnit"
|
|
| "simultaneityFactor"
|
|
| "rowTotalPower"
|
|
| "circuitTotalPower"
|
|
| "protectionSummary"
|
|
| "cableSummary"
|
|
| "roomSummary"
|
|
| "remark";
|
|
|
|
type SaveDirection = "stay" | "next" | "prev";
|
|
type StartEditMode = "selectExisting" | "replaceWithTypedChar";
|
|
type RowType = "section" | "circuitCompact" | "circuitSummary" | "deviceRow" | "reserveCircuit" | "placeholder";
|
|
type CellKind = "circuitField" | "deviceField" | "computed" | "readonly";
|
|
|
|
interface SelectedCell {
|
|
rowKey: string;
|
|
cellKey: CellKey;
|
|
}
|
|
|
|
interface EditingCell extends SelectedCell {
|
|
draft: string;
|
|
mode: StartEditMode;
|
|
focusToken: number;
|
|
}
|
|
|
|
interface SelectionIntent {
|
|
rowKey: string;
|
|
cellKey: CellKey;
|
|
rowType: RowType;
|
|
sectionId: string;
|
|
circuitId?: string;
|
|
deviceId?: string;
|
|
}
|
|
|
|
interface VisibleGridCell {
|
|
cellKey: CellKey;
|
|
editable: boolean;
|
|
kind: CellKind;
|
|
value: string | number | boolean | undefined;
|
|
}
|
|
|
|
interface VisibleGridRow {
|
|
rowKey: string;
|
|
rowType: RowType;
|
|
sectionId: string;
|
|
circuit?: CircuitTreeCircuitDto;
|
|
device?: CircuitTreeDeviceRowDto;
|
|
cells: VisibleGridCell[];
|
|
}
|
|
|
|
type ProjectDeviceDropIntent =
|
|
| { kind: "new-circuit"; sectionId: string }
|
|
| { kind: "add-to-circuit"; circuitId: string; sectionId: string };
|
|
|
|
type DeviceRowMoveDropIntent =
|
|
| { kind: "move-to-circuit"; circuitId: string; sectionId: string }
|
|
| { kind: "move-to-new-circuit"; sectionId: string };
|
|
|
|
type CircuitReorderDropIntent =
|
|
| { kind: "before-circuit"; sectionId: string; targetCircuitId: string; valid: boolean }
|
|
| { kind: "after-circuit"; sectionId: string; targetCircuitId: string; valid: boolean }
|
|
| { kind: "section-end"; sectionId: string; valid: boolean };
|
|
|
|
const columns: Array<{ key: CellKey; label: string; numeric?: boolean }> = [
|
|
{ key: "equipmentIdentifier", label: "Equipment identifier" },
|
|
{ key: "displayName", label: "Display name" },
|
|
{ key: "quantity", label: "Quantity", numeric: true },
|
|
{ key: "powerPerUnit", label: "Power / unit", numeric: true },
|
|
{ key: "simultaneityFactor", label: "Simultaneity", numeric: true },
|
|
{ key: "rowTotalPower", label: "Row total", numeric: true },
|
|
{ key: "circuitTotalPower", label: "Circuit total", numeric: true },
|
|
{ key: "protectionSummary", label: "Protection summary" },
|
|
{ key: "cableSummary", label: "Cable summary" },
|
|
{ key: "roomSummary", label: "Room" },
|
|
{ key: "remark", label: "Remark" },
|
|
];
|
|
|
|
const deviceFieldKeys = new Set<CellKey>([
|
|
"displayName",
|
|
"roomSummary",
|
|
"quantity",
|
|
"powerPerUnit",
|
|
"simultaneityFactor",
|
|
"remark",
|
|
]);
|
|
|
|
const circuitFieldKeys = new Set<CellKey>([
|
|
"equipmentIdentifier",
|
|
"displayName",
|
|
"protectionSummary",
|
|
"cableSummary",
|
|
"remark",
|
|
]);
|
|
|
|
function formatValue(value: string | number | boolean | undefined) {
|
|
if (value === undefined || value === null || value === "") {
|
|
return "-";
|
|
}
|
|
if (typeof value === "boolean") {
|
|
return value ? "Yes" : "No";
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
function normalizeUiError(err: unknown): string {
|
|
const message = err instanceof Error ? err.message : "Operation failed.";
|
|
try {
|
|
const parsed = JSON.parse(message) as { error?: string };
|
|
if (parsed.error?.includes("Duplicate equipmentIdentifier")) {
|
|
return "Das Betriebsmittelkennzeichen ist in dieser Stromkreisliste bereits vorhanden.";
|
|
}
|
|
if (parsed.error) {
|
|
return parsed.error;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
if (message.includes("Duplicate equipmentIdentifier")) {
|
|
return "Das Betriebsmittelkennzeichen ist in dieser Stromkreisliste bereits vorhanden.";
|
|
}
|
|
if (message.includes("Invalid number")) {
|
|
return "Bitte einen gültigen Zahlenwert eingeben.";
|
|
}
|
|
return message;
|
|
}
|
|
|
|
function parseNumeric(cellKey: CellKey, draft: string): number | undefined {
|
|
const trimmed = draft.trim();
|
|
if (trimmed === "") {
|
|
return undefined;
|
|
}
|
|
const parsed = Number(trimmed);
|
|
if (Number.isNaN(parsed)) {
|
|
throw new Error(`Invalid number in ${cellKey}`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): string | number | boolean | undefined {
|
|
switch (key) {
|
|
case "displayName":
|
|
return device.displayName || device.name;
|
|
case "roomSummary":
|
|
return [device.roomNumberSnapshot, device.roomNameSnapshot].filter(Boolean).join(" ").trim() || undefined;
|
|
case "quantity":
|
|
return device.quantity;
|
|
case "powerPerUnit":
|
|
return device.powerPerUnit;
|
|
case "simultaneityFactor":
|
|
return device.simultaneityFactor;
|
|
case "rowTotalPower":
|
|
return device.rowTotalPower;
|
|
case "remark":
|
|
return device.remark;
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): string | number | boolean | undefined {
|
|
switch (key) {
|
|
case "equipmentIdentifier":
|
|
return circuit.equipmentIdentifier;
|
|
case "displayName":
|
|
return circuit.displayName;
|
|
case "circuitTotalPower":
|
|
return circuit.circuitTotalPower;
|
|
case "protectionSummary": {
|
|
const current =
|
|
circuit.protectionRatedCurrent !== undefined && circuit.protectionRatedCurrent !== null
|
|
? `${circuit.protectionRatedCurrent}A`
|
|
: "";
|
|
return [circuit.protectionType, current, circuit.protectionCharacteristic]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.trim() || undefined;
|
|
}
|
|
case "cableSummary": {
|
|
const length =
|
|
circuit.cableLength !== undefined && circuit.cableLength !== null ? `${circuit.cableLength} m` : "";
|
|
return [circuit.cableType, circuit.cableCrossSection, length].filter(Boolean).join(", ").trim() || undefined;
|
|
}
|
|
case "remark":
|
|
return circuit.remark;
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function getCellKind(rowType: RowType, key: CellKey): CellKind {
|
|
if (key === "rowTotalPower" || key === "circuitTotalPower") {
|
|
return "computed";
|
|
}
|
|
if (rowType === "section") {
|
|
return "readonly";
|
|
}
|
|
if (rowType === "placeholder") {
|
|
if (deviceFieldKeys.has(key)) {
|
|
return "deviceField";
|
|
}
|
|
if (circuitFieldKeys.has(key)) {
|
|
return "circuitField";
|
|
}
|
|
return "readonly";
|
|
}
|
|
if (rowType === "deviceRow") {
|
|
return deviceFieldKeys.has(key) ? "deviceField" : "readonly";
|
|
}
|
|
if (rowType === "circuitSummary") {
|
|
return circuitFieldKeys.has(key) ? "circuitField" : "readonly";
|
|
}
|
|
if (rowType === "reserveCircuit") {
|
|
if (deviceFieldKeys.has(key)) {
|
|
return "deviceField";
|
|
}
|
|
if (circuitFieldKeys.has(key)) {
|
|
return "circuitField";
|
|
}
|
|
return "readonly";
|
|
}
|
|
if (rowType === "circuitCompact") {
|
|
if (deviceFieldKeys.has(key)) {
|
|
return "deviceField";
|
|
}
|
|
if (circuitFieldKeys.has(key)) {
|
|
return "circuitField";
|
|
}
|
|
return "readonly";
|
|
}
|
|
return "readonly";
|
|
}
|
|
|
|
function isPrintableKey(event: KeyboardEvent<HTMLElement>) {
|
|
return event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey;
|
|
}
|
|
|
|
export function CircuitTreeEditor(props: { projectId: string; circuitListId: string }) {
|
|
const { projectId, circuitListId } = props;
|
|
const [data, setData] = useState<CircuitTreeResponseDto | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [selectedCell, setSelectedCell] = useState<SelectedCell | null>(null);
|
|
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
|
const [activeSectionId, setActiveSectionId] = useState<string | null>(null);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
|
|
const [projectDeviceSearch, setProjectDeviceSearch] = useState("");
|
|
const [selectedProjectDeviceId, setSelectedProjectDeviceId] = useState<string | null>(null);
|
|
const [targetSectionId, setTargetSectionId] = useState<string | null>(null);
|
|
const [targetCircuitId, setTargetCircuitId] = useState<string | null>(null);
|
|
const [draggingProjectDeviceId, setDraggingProjectDeviceId] = useState<string | null>(null);
|
|
const [dropIntent, setDropIntent] = useState<ProjectDeviceDropIntent | null>(null);
|
|
const [draggingDeviceRowId, setDraggingDeviceRowId] = useState<string | null>(null);
|
|
const [deviceMoveIntent, setDeviceMoveIntent] = useState<DeviceRowMoveDropIntent | null>(null);
|
|
const [draggingCircuitId, setDraggingCircuitId] = useState<string | null>(null);
|
|
const [circuitReorderIntent, setCircuitReorderIntent] = useState<CircuitReorderDropIntent | null>(null);
|
|
const [pendingFocus, setPendingFocus] = useState<SelectedCell | null>(null);
|
|
const pendingSelectionAfterReload = useRef<SelectionIntent | null>(null);
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
const focusTokenRef = useRef(1);
|
|
|
|
async function loadTree(options?: { showLoading?: boolean }) {
|
|
const showLoading = options?.showLoading ?? false;
|
|
if (showLoading) {
|
|
setIsLoading(true);
|
|
}
|
|
setError(null);
|
|
try {
|
|
const tree = await getCircuitTree(projectId, circuitListId);
|
|
setData(tree);
|
|
} catch (err) {
|
|
setError(normalizeUiError(err));
|
|
} finally {
|
|
if (showLoading) {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void loadTree({ showLoading: true });
|
|
}, [projectId, circuitListId]);
|
|
|
|
useEffect(() => {
|
|
async function loadProjectDeviceList() {
|
|
try {
|
|
const list = await listProjectDevices(projectId);
|
|
setProjectDevices(list);
|
|
} catch (err) {
|
|
setError(normalizeUiError(err));
|
|
}
|
|
}
|
|
void loadProjectDeviceList();
|
|
}, [projectId]);
|
|
|
|
const visibleRows = useMemo(() => {
|
|
if (!data) {
|
|
return [] as VisibleGridRow[];
|
|
}
|
|
const rows: VisibleGridRow[] = [];
|
|
for (const section of data.sections) {
|
|
rows.push({
|
|
rowKey: `section:${section.id}`,
|
|
rowType: "section",
|
|
sectionId: section.id,
|
|
cells: columns.map((col) => ({ cellKey: col.key, editable: false, kind: "readonly", value: undefined })),
|
|
});
|
|
for (const circuit of section.circuits) {
|
|
if (circuit.deviceRows.length === 0) {
|
|
rows.push(makeRow("reserveCircuit", section.id, circuit));
|
|
continue;
|
|
}
|
|
if (circuit.deviceRows.length === 1) {
|
|
rows.push(makeRow("circuitCompact", section.id, circuit, circuit.deviceRows[0]));
|
|
continue;
|
|
}
|
|
rows.push(makeRow("circuitSummary", section.id, circuit));
|
|
for (const device of circuit.deviceRows) {
|
|
rows.push(makeRow("deviceRow", section.id, circuit, device));
|
|
}
|
|
}
|
|
rows.push(makeRow("placeholder", section.id));
|
|
}
|
|
return rows;
|
|
}, [data]);
|
|
|
|
const searchableProjectDevices = useMemo(() => {
|
|
const term = projectDeviceSearch.trim().toLowerCase();
|
|
if (!term) {
|
|
return projectDevices;
|
|
}
|
|
return projectDevices.filter((device) => {
|
|
const haystack = `${device.name} ${device.displayName}`.toLowerCase();
|
|
return haystack.includes(term);
|
|
});
|
|
}, [projectDeviceSearch, projectDevices]);
|
|
|
|
const circuitOptions = useMemo(() => {
|
|
if (!data) {
|
|
return [] as Array<{ id: string; label: string; sectionId: string }>;
|
|
}
|
|
return data.sections.flatMap((section) =>
|
|
section.circuits.map((circuit) => ({
|
|
id: circuit.id,
|
|
sectionId: section.id,
|
|
label: `${circuit.equipmentIdentifier} - ${circuit.displayName || "Circuit"}`,
|
|
}))
|
|
);
|
|
}, [data]);
|
|
|
|
function makeRow(
|
|
rowType: RowType,
|
|
sectionId: string,
|
|
circuit?: CircuitTreeCircuitDto,
|
|
device?: CircuitTreeDeviceRowDto
|
|
): VisibleGridRow {
|
|
const rowKey =
|
|
rowType === "placeholder"
|
|
? `placeholder:${sectionId}`
|
|
: rowType === "deviceRow" && device
|
|
? `device:${device.id}`
|
|
: `${rowType}:${circuit?.id ?? sectionId}`;
|
|
const cells = columns.map((col) => {
|
|
const kind = getCellKind(rowType, col.key);
|
|
const editable = kind === "circuitField" || kind === "deviceField";
|
|
let value: string | number | boolean | undefined;
|
|
if (rowType === "placeholder") {
|
|
value = col.key === "equipmentIdentifier" ? "-frei-" : undefined;
|
|
} else if (kind === "deviceField" && device) {
|
|
value = getDeviceValue(device, col.key);
|
|
} else if (kind === "circuitField" && circuit) {
|
|
value = getCircuitValue(circuit, col.key);
|
|
} else if (col.key === "circuitTotalPower" && circuit) {
|
|
value = circuit.circuitTotalPower;
|
|
} else if (col.key === "rowTotalPower" && device) {
|
|
value = device.rowTotalPower;
|
|
}
|
|
return { cellKey: col.key, editable, kind, value };
|
|
});
|
|
return { rowKey, rowType, sectionId, circuit, device, cells };
|
|
}
|
|
|
|
const editableCells = useMemo(
|
|
() =>
|
|
visibleRows.flatMap((row) =>
|
|
row.cells
|
|
.filter((cell) => cell.editable)
|
|
.map((cell) => ({ rowKey: row.rowKey, cellKey: cell.cellKey as CellKey }))
|
|
),
|
|
[visibleRows]
|
|
);
|
|
|
|
const rowCellMap = useMemo(() => {
|
|
const map = new Map<string, CellKey[]>();
|
|
for (const row of visibleRows) {
|
|
const keys = row.cells.filter((cell) => cell.editable).map((cell) => cell.cellKey as CellKey);
|
|
if (keys.length) {
|
|
map.set(row.rowKey, keys);
|
|
}
|
|
}
|
|
return map;
|
|
}, [visibleRows]);
|
|
|
|
const editableRowOrder = useMemo(
|
|
() => visibleRows.filter((row) => rowCellMap.has(row.rowKey)).map((row) => row.rowKey),
|
|
[visibleRows, rowCellMap]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!selectedCell && editableCells.length > 0) {
|
|
setSelectedCell(editableCells[0]);
|
|
return;
|
|
}
|
|
if (selectedCell) {
|
|
const valid = editableCells.some(
|
|
(cell) => cell.rowKey === selectedCell.rowKey && cell.cellKey === selectedCell.cellKey
|
|
);
|
|
if (!valid) {
|
|
setSelectedCell(editableCells[0] ?? null);
|
|
}
|
|
}
|
|
}, [editableCells, selectedCell]);
|
|
|
|
function buildSelectionIntent(cell: SelectedCell): SelectionIntent | null {
|
|
const row = findRow(cell.rowKey);
|
|
if (!row) {
|
|
return null;
|
|
}
|
|
return {
|
|
rowKey: cell.rowKey,
|
|
cellKey: cell.cellKey,
|
|
rowType: row.rowType,
|
|
sectionId: row.sectionId,
|
|
circuitId: row.circuit?.id,
|
|
deviceId: row.device?.id,
|
|
};
|
|
}
|
|
|
|
function resolveSelectionIntent(intent: SelectionIntent): SelectedCell | null {
|
|
const direct = editableCells.find(
|
|
(cell) => cell.rowKey === intent.rowKey && cell.cellKey === intent.cellKey
|
|
);
|
|
if (direct) {
|
|
return direct;
|
|
}
|
|
|
|
const rowById = visibleRows.find((row) => {
|
|
if (intent.deviceId && row.device?.id === intent.deviceId) {
|
|
return true;
|
|
}
|
|
if (intent.circuitId && row.circuit?.id === intent.circuitId && row.rowType === intent.rowType) {
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
if (rowById) {
|
|
const rowCells = rowCellMap.get(rowById.rowKey) ?? [];
|
|
if (rowCells.includes(intent.cellKey)) {
|
|
return { rowKey: rowById.rowKey, cellKey: intent.cellKey };
|
|
}
|
|
if (rowCells.length > 0) {
|
|
return { rowKey: rowById.rowKey, cellKey: rowCells[0] };
|
|
}
|
|
}
|
|
|
|
const inSameCircuit = visibleRows.find((row) => intent.circuitId && row.circuit?.id === intent.circuitId);
|
|
if (inSameCircuit) {
|
|
const cells = rowCellMap.get(inSameCircuit.rowKey) ?? [];
|
|
if (cells.length > 0) {
|
|
return { rowKey: inSameCircuit.rowKey, cellKey: cells[0] };
|
|
}
|
|
}
|
|
|
|
const inSameSection = visibleRows.find((row) => row.sectionId === intent.sectionId && rowCellMap.has(row.rowKey));
|
|
if (inSameSection) {
|
|
const cells = rowCellMap.get(inSameSection.rowKey) ?? [];
|
|
if (cells.length > 0) {
|
|
return { rowKey: inSameSection.rowKey, cellKey: cells[0] };
|
|
}
|
|
}
|
|
|
|
return editableCells[0] ?? null;
|
|
}
|
|
|
|
useEffect(() => {
|
|
const intent = pendingSelectionAfterReload.current;
|
|
if (!intent || !data) {
|
|
return;
|
|
}
|
|
const resolved = resolveSelectionIntent(intent);
|
|
pendingSelectionAfterReload.current = null;
|
|
if (resolved) {
|
|
setSelectedCell(resolved);
|
|
}
|
|
requestAnimationFrame(() => containerRef.current?.focus());
|
|
}, [data, editableCells, visibleRows]);
|
|
|
|
useEffect(() => {
|
|
if (!pendingFocus) {
|
|
return;
|
|
}
|
|
setSelectedCell(pendingFocus);
|
|
startEdit(pendingFocus, "selectExisting");
|
|
setPendingFocus(null);
|
|
}, [pendingFocus]);
|
|
|
|
useEffect(() => {
|
|
if (!editingCell) {
|
|
return;
|
|
}
|
|
requestAnimationFrame(() => {
|
|
if (!inputRef.current) {
|
|
return;
|
|
}
|
|
inputRef.current.focus();
|
|
if (editingCell.mode === "selectExisting") {
|
|
inputRef.current.select();
|
|
} else {
|
|
const len = inputRef.current.value.length;
|
|
inputRef.current.setSelectionRange(len, len);
|
|
}
|
|
});
|
|
}, [editingCell?.focusToken]);
|
|
|
|
function findRow(rowKey: string) {
|
|
return visibleRows.find((row) => row.rowKey === rowKey);
|
|
}
|
|
|
|
function findCell(rowKey: string, cellKey: CellKey) {
|
|
const row = findRow(rowKey);
|
|
return row?.cells.find((cell) => cell.cellKey === cellKey);
|
|
}
|
|
|
|
function startEdit(cell: SelectedCell, mode: StartEditMode, typedChar?: string) {
|
|
const visibleCell = findCell(cell.rowKey, cell.cellKey);
|
|
if (!visibleCell || !visibleCell.editable) {
|
|
return;
|
|
}
|
|
const currentDisplay = mode === "replaceWithTypedChar" ? typedChar ?? "" : String(visibleCell.value ?? "");
|
|
focusTokenRef.current += 1;
|
|
setEditingCell({
|
|
...cell,
|
|
mode,
|
|
draft: currentDisplay === "-" ? "" : currentDisplay,
|
|
focusToken: focusTokenRef.current,
|
|
});
|
|
}
|
|
|
|
function moveHorizontal(direction: 1 | -1) {
|
|
if (!selectedCell) {
|
|
return;
|
|
}
|
|
const rowCells = rowCellMap.get(selectedCell.rowKey) ?? [];
|
|
const idx = rowCells.indexOf(selectedCell.cellKey);
|
|
if (idx < 0) {
|
|
return;
|
|
}
|
|
const next = rowCells[Math.min(rowCells.length - 1, Math.max(0, idx + direction))];
|
|
setSelectedCell({ rowKey: selectedCell.rowKey, cellKey: next });
|
|
}
|
|
|
|
function moveVertical(direction: 1 | -1) {
|
|
if (!selectedCell) {
|
|
return;
|
|
}
|
|
const rowIndex = editableRowOrder.indexOf(selectedCell.rowKey);
|
|
if (rowIndex < 0) {
|
|
return;
|
|
}
|
|
const targetIndex = rowIndex + direction;
|
|
if (targetIndex < 0 || targetIndex >= editableRowOrder.length) {
|
|
return;
|
|
}
|
|
const targetRowKey = editableRowOrder[targetIndex];
|
|
const targetCells = rowCellMap.get(targetRowKey) ?? [];
|
|
if (!targetCells.length) {
|
|
return;
|
|
}
|
|
const preferred = columns.findIndex((col) => col.key === selectedCell.cellKey);
|
|
let best = targetCells[0];
|
|
let bestDistance = Number.POSITIVE_INFINITY;
|
|
for (const key of targetCells) {
|
|
const idx = columns.findIndex((col) => col.key === key);
|
|
const dist = Math.abs(idx - preferred);
|
|
if (dist < bestDistance) {
|
|
bestDistance = dist;
|
|
best = key;
|
|
}
|
|
}
|
|
setSelectedCell({ rowKey: targetRowKey, cellKey: best });
|
|
}
|
|
|
|
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 {
|
|
payload[key] = draft.trim() === "" ? undefined : draft;
|
|
}
|
|
await updateCircuitById(circuitId, payload);
|
|
}
|
|
|
|
async function patchDeviceRow(rowId: string, key: CellKey, draft: string) {
|
|
const payload: Record<string, unknown> = {};
|
|
if (["quantity", "powerPerUnit", "simultaneityFactor"].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);
|
|
}
|
|
|
|
async function createFromPlaceholder(
|
|
sectionId: string,
|
|
key: CellKey,
|
|
draft: string
|
|
): Promise<SelectedCell> {
|
|
const section = data?.sections.find((entry) => entry.id === sectionId);
|
|
if (!section) {
|
|
throw new Error("Section not found.");
|
|
}
|
|
const next = await getNextCircuitIdentifier(sectionId);
|
|
const sortOrder =
|
|
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
|
|
const isDeviceField = deviceFieldKeys.has(key);
|
|
const createdCircuit = (await createCircuit(projectId, circuitListId, {
|
|
sectionId,
|
|
equipmentIdentifier: next.nextIdentifier,
|
|
displayName: "New circuit",
|
|
sortOrder,
|
|
isReserve: !isDeviceField,
|
|
})) as CircuitTreeCircuitDto;
|
|
|
|
if (isDeviceField) {
|
|
const createdRow = (await createCircuitDeviceRow(createdCircuit.id, {
|
|
name: "Manual device",
|
|
displayName: "Manual device",
|
|
phaseType: "single_phase",
|
|
quantity: 1,
|
|
powerPerUnit: 0,
|
|
simultaneityFactor: 1,
|
|
cosPhi: 1,
|
|
})) as { id: string };
|
|
await patchDeviceRow(createdRow.id, key, draft);
|
|
return { rowKey: `circuitCompact:${createdCircuit.id}`, cellKey: key };
|
|
}
|
|
|
|
await patchCircuit(createdCircuit.id, key, draft);
|
|
return { rowKey: `reserveCircuit:${createdCircuit.id}`, cellKey: key };
|
|
}
|
|
|
|
async function commitEdit(direction: SaveDirection = "stay") {
|
|
if (!editingCell) {
|
|
return;
|
|
}
|
|
const row = findRow(editingCell.rowKey);
|
|
const cell = row?.cells.find((entry) => entry.cellKey === editingCell.cellKey);
|
|
if (!row || !cell || !cell.editable) {
|
|
setEditingCell(null);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsSaving(true);
|
|
setError(null);
|
|
let targetSelection: SelectedCell = { rowKey: editingCell.rowKey, cellKey: editingCell.cellKey };
|
|
|
|
if (row.rowType === "placeholder") {
|
|
targetSelection = await createFromPlaceholder(row.sectionId, editingCell.cellKey, editingCell.draft);
|
|
} else if (cell.kind === "circuitField" && row.circuit) {
|
|
await patchCircuit(row.circuit.id, editingCell.cellKey, editingCell.draft);
|
|
} else if (cell.kind === "deviceField") {
|
|
if (row.device) {
|
|
await patchDeviceRow(row.device.id, editingCell.cellKey, editingCell.draft);
|
|
} else if (row.rowType === "reserveCircuit" && row.circuit) {
|
|
const created = (await createCircuitDeviceRow(row.circuit.id, {
|
|
name: "Reserve load",
|
|
displayName: "Reserve load",
|
|
phaseType: "single_phase",
|
|
quantity: 1,
|
|
powerPerUnit: 0,
|
|
simultaneityFactor: 1,
|
|
cosPhi: 1,
|
|
})) as { id: string };
|
|
await patchDeviceRow(created.id, editingCell.cellKey, editingCell.draft);
|
|
targetSelection = { rowKey: `circuitCompact:${row.circuit.id}`, cellKey: editingCell.cellKey };
|
|
}
|
|
}
|
|
|
|
if (direction !== "stay") {
|
|
const idx = editableCells.findIndex(
|
|
(entry) => entry.rowKey === targetSelection.rowKey && entry.cellKey === targetSelection.cellKey
|
|
);
|
|
if (idx >= 0) {
|
|
const targetIdx =
|
|
direction === "next"
|
|
? Math.min(editableCells.length - 1, idx + 1)
|
|
: Math.max(0, idx - 1);
|
|
targetSelection = editableCells[targetIdx];
|
|
}
|
|
}
|
|
|
|
const intent = buildSelectionIntent(targetSelection);
|
|
setEditingCell(null);
|
|
if (intent) {
|
|
pendingSelectionAfterReload.current = intent;
|
|
}
|
|
await loadTree({ showLoading: false });
|
|
if (!intent) {
|
|
requestAnimationFrame(() => containerRef.current?.focus());
|
|
}
|
|
} catch (err) {
|
|
setError(normalizeUiError(err));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
function cancelEdit() {
|
|
if (!editingCell) {
|
|
return;
|
|
}
|
|
setEditingCell(null);
|
|
setSelectedCell({ rowKey: editingCell.rowKey, cellKey: editingCell.cellKey });
|
|
requestAnimationFrame(() => containerRef.current?.focus());
|
|
}
|
|
|
|
async function handleAddReserveCircuit(sectionId: string) {
|
|
try {
|
|
setError(null);
|
|
setIsSaving(true);
|
|
const section = data?.sections.find((entry) => entry.id === sectionId);
|
|
if (!section) {
|
|
return;
|
|
}
|
|
const next = await getNextCircuitIdentifier(sectionId);
|
|
const sortOrder =
|
|
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
|
|
await createCircuit(projectId, circuitListId, {
|
|
sectionId,
|
|
equipmentIdentifier: next.nextIdentifier,
|
|
displayName: "Reserve",
|
|
sortOrder,
|
|
isReserve: true,
|
|
});
|
|
await loadTree({ showLoading: false });
|
|
setActiveSectionId(sectionId);
|
|
} catch (err) {
|
|
setError(normalizeUiError(err));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleAddManualDevice(circuit: CircuitTreeCircuitDto, sectionId: string) {
|
|
try {
|
|
setError(null);
|
|
setIsSaving(true);
|
|
const created = (await createCircuitDeviceRow(circuit.id, {
|
|
name: "Manual device",
|
|
displayName: "Manual device",
|
|
phaseType: "single_phase",
|
|
quantity: 1,
|
|
powerPerUnit: 0,
|
|
simultaneityFactor: 1,
|
|
cosPhi: 1,
|
|
})) as { id: string };
|
|
await loadTree({ showLoading: false });
|
|
setActiveSectionId(sectionId);
|
|
setPendingFocus({
|
|
rowKey: circuit.deviceRows.length === 0 ? `circuitCompact:${circuit.id}` : `device:${created.id}`,
|
|
cellKey: "displayName",
|
|
});
|
|
} catch (err) {
|
|
setError(normalizeUiError(err));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
function resolvePhaseType(device: ProjectDeviceDto): string {
|
|
if (device.phaseCount === 3) {
|
|
return "three_phase";
|
|
}
|
|
return "single_phase";
|
|
}
|
|
|
|
function resolveSelectedProjectDevice() {
|
|
if (!selectedProjectDeviceId) {
|
|
return null;
|
|
}
|
|
return projectDevices.find((device) => device.id === selectedProjectDeviceId) ?? null;
|
|
}
|
|
|
|
async function handleAddProjectDeviceAsNewCircuit() {
|
|
const device = resolveSelectedProjectDevice();
|
|
if (!device) {
|
|
setError("Please select a project device.");
|
|
return;
|
|
}
|
|
if (!targetSectionId) {
|
|
setError("Please select a target section.");
|
|
return;
|
|
}
|
|
try {
|
|
setError(null);
|
|
setIsSaving(true);
|
|
await insertProjectDeviceAsNewCircuit(device, targetSectionId);
|
|
} catch (err) {
|
|
setError(normalizeUiError(err));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleAddProjectDeviceToCircuit() {
|
|
const device = resolveSelectedProjectDevice();
|
|
if (!device) {
|
|
setError("Please select a project device.");
|
|
return;
|
|
}
|
|
let circuitId = targetCircuitId;
|
|
if (!circuitId && selectedCell) {
|
|
const row = findRow(selectedCell.rowKey);
|
|
if (row?.circuit?.id) {
|
|
circuitId = row.circuit.id;
|
|
}
|
|
}
|
|
if (!circuitId) {
|
|
setError("Please select a target circuit.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setError(null);
|
|
setIsSaving(true);
|
|
await insertProjectDeviceToCircuit(device, circuitId);
|
|
} catch (err) {
|
|
setError(normalizeUiError(err));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
async function insertProjectDeviceAsNewCircuit(device: ProjectDeviceDto, sectionId: string) {
|
|
const section = data?.sections.find((entry) => entry.id === sectionId);
|
|
if (!section) {
|
|
throw new Error("Invalid target section.");
|
|
}
|
|
const next = await getNextCircuitIdentifier(sectionId);
|
|
const sortOrder =
|
|
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
|
|
const createdCircuit = (await createCircuit(projectId, circuitListId, {
|
|
sectionId,
|
|
equipmentIdentifier: next.nextIdentifier,
|
|
displayName: device.displayName || device.name,
|
|
sortOrder,
|
|
isReserve: false,
|
|
})) as CircuitTreeCircuitDto;
|
|
|
|
const createdRow = (await createCircuitDeviceRow(createdCircuit.id, {
|
|
linkedProjectDeviceId: device.id,
|
|
name: device.name,
|
|
displayName: device.displayName,
|
|
phaseType: resolvePhaseType(device),
|
|
quantity: device.quantity,
|
|
powerPerUnit: device.installedPowerPerUnitKw,
|
|
simultaneityFactor: device.demandFactor,
|
|
cosPhi: device.powerFactor ?? undefined,
|
|
category: device.category ?? undefined,
|
|
})) as { id: string };
|
|
|
|
await loadTree({ showLoading: false });
|
|
setActiveSectionId(sectionId);
|
|
setTargetCircuitId(createdCircuit.id);
|
|
setPendingFocus({ rowKey: `device:${createdRow.id}`, cellKey: "displayName" });
|
|
}
|
|
|
|
async function insertProjectDeviceToCircuit(device: ProjectDeviceDto, circuitId: string) {
|
|
const createdRow = (await createCircuitDeviceRow(circuitId, {
|
|
linkedProjectDeviceId: device.id,
|
|
name: device.name,
|
|
displayName: device.displayName,
|
|
phaseType: resolvePhaseType(device),
|
|
quantity: device.quantity,
|
|
powerPerUnit: device.installedPowerPerUnitKw,
|
|
simultaneityFactor: device.demandFactor,
|
|
cosPhi: device.powerFactor ?? undefined,
|
|
category: device.category ?? undefined,
|
|
})) as { id: string };
|
|
await loadTree({ showLoading: false });
|
|
setPendingFocus({ rowKey: `device:${createdRow.id}`, cellKey: "displayName" });
|
|
}
|
|
|
|
function parseDraggedProjectDeviceId(event: DragEvent<HTMLElement>) {
|
|
return event.dataTransfer.getData("application/x-project-device-id") || null;
|
|
}
|
|
|
|
function parseDraggedDeviceRowId(event: DragEvent<HTMLElement>) {
|
|
return event.dataTransfer.getData("application/x-circuit-device-row-id") || null;
|
|
}
|
|
|
|
function findDeviceRowCircuitId(deviceRowId: string) {
|
|
for (const section of data?.sections ?? []) {
|
|
for (const circuit of section.circuits) {
|
|
if (circuit.deviceRows.some((row) => row.id === deviceRowId)) {
|
|
return circuit.id;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function findCircuitSectionId(circuitId: string) {
|
|
for (const section of data?.sections ?? []) {
|
|
if (section.circuits.some((circuit) => circuit.id === circuitId)) {
|
|
return section.id;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function applyCircuitReorder(intent: CircuitReorderDropIntent, sourceCircuitId: string) {
|
|
const section = data?.sections.find((entry) => entry.id === intent.sectionId);
|
|
if (!section) {
|
|
throw new Error("Invalid section id.");
|
|
}
|
|
const ids = section.circuits.map((circuit) => circuit.id);
|
|
const fromIndex = ids.indexOf(sourceCircuitId);
|
|
if (fromIndex < 0) {
|
|
throw new Error("Invalid source circuit.");
|
|
}
|
|
const nextIds = [...ids];
|
|
nextIds.splice(fromIndex, 1);
|
|
|
|
if (intent.kind === "section-end") {
|
|
nextIds.push(sourceCircuitId);
|
|
} else {
|
|
const targetIndex = nextIds.indexOf(intent.targetCircuitId);
|
|
if (targetIndex < 0) {
|
|
throw new Error("Invalid target circuit.");
|
|
}
|
|
const insertIndex = intent.kind === "after-circuit" ? targetIndex + 1 : targetIndex;
|
|
nextIds.splice(insertIndex, 0, sourceCircuitId);
|
|
}
|
|
await reorderSectionCircuits(section.id, nextIds);
|
|
}
|
|
|
|
async function handleDropWithIntent(event: DragEvent<HTMLElement>, intent: ProjectDeviceDropIntent) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const draggedId = parseDraggedProjectDeviceId(event) ?? draggingProjectDeviceId;
|
|
setDropIntent(null);
|
|
setDraggingProjectDeviceId(null);
|
|
if (!draggedId) {
|
|
setError("Missing dragged project device.");
|
|
return;
|
|
}
|
|
const device = projectDevices.find((entry) => entry.id === draggedId);
|
|
if (!device) {
|
|
setError("Invalid project device drop source.");
|
|
return;
|
|
}
|
|
try {
|
|
setError(null);
|
|
setIsSaving(true);
|
|
if (intent.kind === "new-circuit") {
|
|
await insertProjectDeviceAsNewCircuit(device, intent.sectionId);
|
|
} else {
|
|
await insertProjectDeviceToCircuit(device, intent.circuitId);
|
|
}
|
|
} catch (err) {
|
|
setError(normalizeUiError(err));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleDeviceRowDropWithIntent(event: DragEvent<HTMLElement>, intent: DeviceRowMoveDropIntent) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const rowId = parseDraggedDeviceRowId(event) ?? draggingDeviceRowId;
|
|
setDeviceMoveIntent(null);
|
|
setDraggingDeviceRowId(null);
|
|
if (!rowId) {
|
|
setError("Missing dragged device row.");
|
|
return;
|
|
}
|
|
|
|
const sourceCircuitId = findDeviceRowCircuitId(rowId);
|
|
if (!sourceCircuitId) {
|
|
setError("Invalid dragged device row.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setError(null);
|
|
setIsSaving(true);
|
|
if (intent.kind === "move-to-circuit") {
|
|
if (intent.circuitId === sourceCircuitId) {
|
|
return;
|
|
}
|
|
await moveCircuitDeviceRowById(rowId, { targetCircuitId: intent.circuitId });
|
|
} else {
|
|
await moveCircuitDeviceRowById(rowId, {
|
|
targetSectionId: intent.sectionId,
|
|
createNewCircuit: true,
|
|
});
|
|
}
|
|
await loadTree({ showLoading: false });
|
|
setPendingFocus({ rowKey: `device:${rowId}`, cellKey: "displayName" });
|
|
} catch (err) {
|
|
setError(normalizeUiError(err));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleCircuitReorderDrop(event: DragEvent<HTMLElement>, intent: CircuitReorderDropIntent) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const sourceCircuitId = event.dataTransfer.getData("application/x-circuit-id") || draggingCircuitId;
|
|
setCircuitReorderIntent(null);
|
|
setDraggingCircuitId(null);
|
|
if (!sourceCircuitId) {
|
|
setError("Missing dragged circuit.");
|
|
return;
|
|
}
|
|
if (!intent.valid) {
|
|
setError("Cross-section circuit move is not allowed in this phase.");
|
|
return;
|
|
}
|
|
try {
|
|
setError(null);
|
|
setIsSaving(true);
|
|
await applyCircuitReorder(intent, sourceCircuitId);
|
|
pendingSelectionAfterReload.current = {
|
|
rowKey: `circuitSummary:${sourceCircuitId}`,
|
|
cellKey: "equipmentIdentifier",
|
|
rowType: "circuitSummary",
|
|
sectionId: intent.sectionId,
|
|
circuitId: sourceCircuitId,
|
|
};
|
|
await loadTree({ showLoading: false });
|
|
} catch (err) {
|
|
setError(normalizeUiError(err));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleDeleteDevice(rowId: string) {
|
|
if (!confirm("Delete this device row?")) {
|
|
return;
|
|
}
|
|
try {
|
|
setError(null);
|
|
setIsSaving(true);
|
|
await deleteCircuitDeviceRowById(rowId);
|
|
await loadTree({ showLoading: false });
|
|
} catch (err) {
|
|
setError(normalizeUiError(err));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleDeleteCircuit(circuitId: string) {
|
|
if (!confirm("Delete this circuit and all assigned device rows?")) {
|
|
return;
|
|
}
|
|
try {
|
|
setError(null);
|
|
setIsSaving(true);
|
|
await deleteCircuitById(circuitId);
|
|
await loadTree({ showLoading: false });
|
|
} catch (err) {
|
|
setError(normalizeUiError(err));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleRenumberSection(sectionId: string) {
|
|
if (!confirm("Renumber this section? Only circuits in this section will change.")) {
|
|
return;
|
|
}
|
|
try {
|
|
setError(null);
|
|
setIsSaving(true);
|
|
await renumberCircuitSection(sectionId);
|
|
await loadTree({ showLoading: false });
|
|
} catch (err) {
|
|
setError(normalizeUiError(err));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
function handleContainerFocus() {
|
|
if (editingCell) {
|
|
const row = findRow(editingCell.rowKey);
|
|
const cell = row?.cells.find((entry) => entry.cellKey === editingCell.cellKey);
|
|
if (!row || !cell || !cell.editable) {
|
|
setEditingCell(null);
|
|
}
|
|
return;
|
|
}
|
|
if (!selectedCell && editableCells.length) {
|
|
setSelectedCell(editableCells[0]);
|
|
}
|
|
}
|
|
|
|
function handleContainerKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
|
if (editingCell) {
|
|
if (event.key === "Tab") {
|
|
event.preventDefault();
|
|
}
|
|
return;
|
|
}
|
|
const isCtrlPlus =
|
|
event.ctrlKey &&
|
|
(event.key === "+" || (event.shiftKey && event.key === "=") || event.code === "NumpadAdd");
|
|
if (isCtrlPlus) {
|
|
event.preventDefault();
|
|
const sectionId = activeSectionId ?? data?.sections[0]?.id;
|
|
if (sectionId) {
|
|
void handleAddReserveCircuit(sectionId);
|
|
}
|
|
return;
|
|
}
|
|
if (event.key === "Tab" && selectedCell) {
|
|
event.preventDefault();
|
|
const idx = editableCells.findIndex(
|
|
(entry) => entry.rowKey === selectedCell.rowKey && entry.cellKey === selectedCell.cellKey
|
|
);
|
|
if (idx >= 0) {
|
|
const nextIdx =
|
|
event.shiftKey ? Math.max(0, idx - 1) : Math.min(editableCells.length - 1, idx + 1);
|
|
setSelectedCell(editableCells[nextIdx]);
|
|
}
|
|
return;
|
|
}
|
|
if (event.key === "ArrowRight") {
|
|
event.preventDefault();
|
|
moveHorizontal(1);
|
|
return;
|
|
}
|
|
if (event.key === "ArrowLeft") {
|
|
event.preventDefault();
|
|
moveHorizontal(-1);
|
|
return;
|
|
}
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
moveVertical(1);
|
|
return;
|
|
}
|
|
if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
moveVertical(-1);
|
|
return;
|
|
}
|
|
if ((event.key === "Enter" || event.key === "F2") && selectedCell) {
|
|
event.preventDefault();
|
|
startEdit(selectedCell, "selectExisting");
|
|
return;
|
|
}
|
|
if (isPrintableKey(event) && selectedCell) {
|
|
event.preventDefault();
|
|
startEdit(selectedCell, "replaceWithTypedChar", event.key);
|
|
}
|
|
}
|
|
|
|
if (isLoading && !data) {
|
|
return <div className="notice info">Loading circuit tree editor...</div>;
|
|
}
|
|
if (error) {
|
|
return <div className="notice error">{error}</div>;
|
|
}
|
|
if (!data || data.sections.length === 0) {
|
|
return <div className="notice muted">No sections/circuits available.</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="tree-editor-shell">
|
|
{isSaving ? <div className="notice info">Saving...</div> : null}
|
|
<div className="tree-editor-layout">
|
|
<aside className="project-device-sidebar">
|
|
<h3>Project devices</h3>
|
|
<input
|
|
type="text"
|
|
placeholder="Search name/displayName..."
|
|
value={projectDeviceSearch}
|
|
onChange={(event) => setProjectDeviceSearch(event.target.value)}
|
|
/>
|
|
<div className="project-device-list">
|
|
{searchableProjectDevices.map((device) => (
|
|
<button
|
|
key={device.id}
|
|
type="button"
|
|
className={`project-device-item ${selectedProjectDeviceId === device.id ? "selected" : ""} ${draggingProjectDeviceId === device.id ? "dragging" : ""}`}
|
|
onClick={() => setSelectedProjectDeviceId(device.id)}
|
|
draggable
|
|
onDragStart={(event) => {
|
|
setSelectedProjectDeviceId(device.id);
|
|
setDraggingProjectDeviceId(device.id);
|
|
setDraggingDeviceRowId(null);
|
|
setDraggingCircuitId(null);
|
|
setDeviceMoveIntent(null);
|
|
setCircuitReorderIntent(null);
|
|
event.dataTransfer.effectAllowed = "copy";
|
|
event.dataTransfer.setData("application/x-project-device-id", device.id);
|
|
}}
|
|
onDragEnd={() => {
|
|
setDraggingProjectDeviceId(null);
|
|
setDropIntent(null);
|
|
}}
|
|
>
|
|
<strong>{device.displayName || device.name}</strong>
|
|
<span>Name: {device.name}</span>
|
|
<span>Phase: {device.phaseCount === 3 ? "three_phase" : "single_phase"}</span>
|
|
<span>Qty: {device.quantity}</span>
|
|
<span>P/unit: {device.installedPowerPerUnitKw}</span>
|
|
<span>g: {device.demandFactor}</span>
|
|
<span>Cost group: -</span>
|
|
<span>Category: {device.category || "-"}</span>
|
|
</button>
|
|
))}
|
|
{searchableProjectDevices.length === 0 ? <p className="notice muted">No matching project devices.</p> : null}
|
|
</div>
|
|
<div className="sidebar-actions">
|
|
<label>
|
|
Target section
|
|
<select
|
|
value={targetSectionId ?? ""}
|
|
onChange={(event) => setTargetSectionId(event.target.value || null)}
|
|
>
|
|
<option value="">Select section...</option>
|
|
{data.sections.map((section) => (
|
|
<option key={section.id} value={section.id}>
|
|
{section.displayName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<button type="button" onClick={() => void handleAddProjectDeviceAsNewCircuit()}>
|
|
Add as new circuit
|
|
</button>
|
|
<label>
|
|
Target circuit
|
|
<select
|
|
value={targetCircuitId ?? ""}
|
|
onChange={(event) => setTargetCircuitId(event.target.value || null)}
|
|
>
|
|
<option value="">Use selected row circuit...</option>
|
|
{circuitOptions.map((option) => (
|
|
<option key={option.id} value={option.id}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<button type="button" onClick={() => void handleAddProjectDeviceToCircuit()}>
|
|
Add to selected circuit
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
<div
|
|
className="tree-grid-wrap"
|
|
ref={containerRef}
|
|
tabIndex={0}
|
|
onFocus={handleContainerFocus}
|
|
onKeyDown={handleContainerKeyDown}
|
|
>
|
|
<table className="tree-grid">
|
|
<thead>
|
|
<tr>
|
|
{columns.map((column) => (
|
|
<th key={column.key} className={column.numeric ? "num" : ""}>
|
|
{column.label}
|
|
</th>
|
|
))}
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{visibleRows.map((row) => {
|
|
if (row.rowType === "section") {
|
|
const section = data.sections.find((entry) => entry.id === row.sectionId)!;
|
|
return (
|
|
<tr
|
|
key={row.rowKey}
|
|
className={`section-row ${
|
|
dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? "drop-target-active" : ""
|
|
}`}
|
|
onDragOver={(event) => {
|
|
if (draggingCircuitId) {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "none";
|
|
setCircuitReorderIntent({
|
|
kind: "section-end",
|
|
sectionId: section.id,
|
|
valid: false,
|
|
});
|
|
return;
|
|
}
|
|
if (!draggingProjectDeviceId) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "copy";
|
|
setDropIntent({ kind: "new-circuit", sectionId: section.id });
|
|
}}
|
|
onDragLeave={() => {
|
|
if (dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id) {
|
|
setDropIntent(null);
|
|
}
|
|
if (circuitReorderIntent?.kind === "section-end" && circuitReorderIntent.sectionId === section.id) {
|
|
setCircuitReorderIntent(null);
|
|
}
|
|
}}
|
|
onDrop={(event) => {
|
|
if (draggingProjectDeviceId) {
|
|
void handleDropWithIntent(event, { kind: "new-circuit", sectionId: section.id });
|
|
return;
|
|
}
|
|
if (draggingCircuitId) {
|
|
void handleCircuitReorderDrop(event, {
|
|
kind: "section-end",
|
|
sectionId: section.id,
|
|
valid: false,
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
<td colSpan={columns.length + 1} className="section-drop-cell">
|
|
<div className="section-content">
|
|
<strong>{section.displayName}</strong>
|
|
<div className="section-actions">
|
|
<button type="button" tabIndex={-1} onClick={() => void handleAddReserveCircuit(section.id)}>
|
|
Add circuit
|
|
</button>
|
|
<button type="button" tabIndex={-1} onClick={() => void handleRenumberSection(section.id)}>
|
|
Renumber section
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? (
|
|
<span className="drop-hint">drop here to create new circuit</span>
|
|
) : null}
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<tr
|
|
key={row.rowKey}
|
|
className={`${
|
|
row.rowType === "circuitSummary"
|
|
? "summary-row"
|
|
: row.rowType === "deviceRow"
|
|
? "device-row"
|
|
: row.rowType === "reserveCircuit"
|
|
? "empty-circuit-row"
|
|
: row.rowType === "placeholder"
|
|
? "placeholder-row"
|
|
: ""
|
|
} ${
|
|
row.rowType === "placeholder" &&
|
|
((dropIntent?.kind === "new-circuit" && dropIntent.sectionId === row.sectionId) ||
|
|
(deviceMoveIntent?.kind === "move-to-new-circuit" && deviceMoveIntent.sectionId === row.sectionId) ||
|
|
(circuitReorderIntent?.kind === "section-end" && circuitReorderIntent.sectionId === row.sectionId))
|
|
? "drop-target-active"
|
|
: ""
|
|
} ${
|
|
row.rowType === "placeholder" &&
|
|
circuitReorderIntent?.kind === "section-end" &&
|
|
circuitReorderIntent.sectionId === row.sectionId &&
|
|
!circuitReorderIntent.valid
|
|
? "drop-target-invalid"
|
|
: ""
|
|
} ${
|
|
row.circuit &&
|
|
circuitReorderIntent &&
|
|
(circuitReorderIntent.kind === "before-circuit" || circuitReorderIntent.kind === "after-circuit") &&
|
|
circuitReorderIntent.targetCircuitId === row.circuit.id &&
|
|
circuitReorderIntent.valid
|
|
? "drop-target-active"
|
|
: ""
|
|
} ${
|
|
row.circuit &&
|
|
circuitReorderIntent?.kind === "before-circuit" &&
|
|
circuitReorderIntent.targetCircuitId === row.circuit.id &&
|
|
circuitReorderIntent.valid
|
|
? "circuit-insert-before"
|
|
: ""
|
|
} ${
|
|
row.circuit &&
|
|
circuitReorderIntent?.kind === "after-circuit" &&
|
|
circuitReorderIntent.targetCircuitId === row.circuit.id &&
|
|
circuitReorderIntent.valid
|
|
? "circuit-insert-after"
|
|
: ""
|
|
} ${
|
|
row.circuit &&
|
|
circuitReorderIntent &&
|
|
(circuitReorderIntent.kind === "before-circuit" || circuitReorderIntent.kind === "after-circuit") &&
|
|
circuitReorderIntent.targetCircuitId === row.circuit.id &&
|
|
!circuitReorderIntent.valid
|
|
? "drop-target-invalid"
|
|
: ""
|
|
} ${
|
|
row.circuit?.id && draggingCircuitId && row.circuit.id === draggingCircuitId ? "circuit-dragging-block" : ""
|
|
}`}
|
|
onClick={() => setActiveSectionId(row.sectionId)}
|
|
onDragOver={(event) => {
|
|
if (draggingCircuitId) {
|
|
if (row.rowType === "placeholder") {
|
|
const sourceSectionId = findCircuitSectionId(draggingCircuitId);
|
|
const valid = sourceSectionId === row.sectionId;
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = valid ? "move" : "none";
|
|
setCircuitReorderIntent({ kind: "section-end", sectionId: row.sectionId, valid });
|
|
return;
|
|
}
|
|
if (row.circuit && row.rowType !== "deviceRow") {
|
|
const sourceSectionId = findCircuitSectionId(draggingCircuitId);
|
|
if (row.circuit.id === draggingCircuitId) {
|
|
setCircuitReorderIntent(null);
|
|
return;
|
|
}
|
|
const valid = sourceSectionId === row.sectionId;
|
|
const rect = (event.currentTarget as HTMLTableRowElement).getBoundingClientRect();
|
|
const isAfter = event.clientY > rect.top + rect.height / 2;
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = valid ? "move" : "none";
|
|
setCircuitReorderIntent({
|
|
kind: isAfter ? "after-circuit" : "before-circuit",
|
|
sectionId: row.sectionId,
|
|
targetCircuitId: row.circuit.id,
|
|
valid,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
if (draggingProjectDeviceId) {
|
|
if (row.rowType === "placeholder") {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "copy";
|
|
setDropIntent({ kind: "new-circuit", sectionId: row.sectionId });
|
|
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 });
|
|
}
|
|
return;
|
|
}
|
|
if (draggingDeviceRowId) {
|
|
if (row.rowType === "placeholder") {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "move";
|
|
setDeviceMoveIntent({ kind: "move-to-new-circuit", sectionId: row.sectionId });
|
|
return;
|
|
}
|
|
if (row.circuit && row.rowType !== "deviceRow") {
|
|
const sourceCircuitId = findDeviceRowCircuitId(draggingDeviceRowId);
|
|
if (sourceCircuitId && sourceCircuitId !== row.circuit.id) {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "move";
|
|
setDeviceMoveIntent({ kind: "move-to-circuit", circuitId: row.circuit.id, sectionId: row.sectionId });
|
|
}
|
|
}
|
|
}
|
|
}}
|
|
onDragLeave={() => {
|
|
setDropIntent((current) => {
|
|
if (!current) {
|
|
return null;
|
|
}
|
|
if (current.kind === "new-circuit" && row.rowType === "placeholder" && current.sectionId === row.sectionId) {
|
|
return null;
|
|
}
|
|
if (current.kind === "add-to-circuit" && current.circuitId === row.circuit?.id) {
|
|
return null;
|
|
}
|
|
return current;
|
|
});
|
|
setDeviceMoveIntent((current) => {
|
|
if (!current) {
|
|
return null;
|
|
}
|
|
if (current.kind === "move-to-new-circuit" && row.rowType === "placeholder" && current.sectionId === row.sectionId) {
|
|
return null;
|
|
}
|
|
if (current.kind === "move-to-circuit" && current.circuitId === row.circuit?.id) {
|
|
return null;
|
|
}
|
|
return current;
|
|
});
|
|
setCircuitReorderIntent((current) => {
|
|
if (!current) {
|
|
return null;
|
|
}
|
|
if (current.kind === "section-end" && row.rowType === "placeholder" && current.sectionId === row.sectionId) {
|
|
return null;
|
|
}
|
|
if (
|
|
(current.kind === "before-circuit" || current.kind === "after-circuit") &&
|
|
current.targetCircuitId === row.circuit?.id
|
|
) {
|
|
return null;
|
|
}
|
|
return current;
|
|
});
|
|
}}
|
|
onDrop={(event) => {
|
|
if (draggingCircuitId) {
|
|
if (row.rowType === "placeholder") {
|
|
const sourceSectionId = findCircuitSectionId(draggingCircuitId);
|
|
void handleCircuitReorderDrop(event, {
|
|
kind: "section-end",
|
|
sectionId: row.sectionId,
|
|
valid: sourceSectionId === row.sectionId,
|
|
});
|
|
return;
|
|
}
|
|
if (row.circuit && row.rowType !== "deviceRow") {
|
|
if (row.circuit.id === draggingCircuitId) {
|
|
return;
|
|
}
|
|
const sourceSectionId = findCircuitSectionId(draggingCircuitId);
|
|
const rect = (event.currentTarget as HTMLTableRowElement).getBoundingClientRect();
|
|
const isAfter = event.clientY > rect.top + rect.height / 2;
|
|
void handleCircuitReorderDrop(event, {
|
|
kind: isAfter ? "after-circuit" : "before-circuit",
|
|
sectionId: row.sectionId,
|
|
targetCircuitId: row.circuit.id,
|
|
valid: sourceSectionId === row.sectionId,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
if (draggingProjectDeviceId) {
|
|
if (row.rowType === "placeholder") {
|
|
void handleDropWithIntent(event, { kind: "new-circuit", sectionId: row.sectionId });
|
|
return;
|
|
}
|
|
if (row.circuit && row.rowType !== "deviceRow") {
|
|
void handleDropWithIntent(event, {
|
|
kind: "add-to-circuit",
|
|
circuitId: row.circuit.id,
|
|
sectionId: row.sectionId,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
if (draggingDeviceRowId) {
|
|
if (row.rowType === "placeholder") {
|
|
void handleDeviceRowDropWithIntent(event, { kind: "move-to-new-circuit", sectionId: row.sectionId });
|
|
return;
|
|
}
|
|
if (row.circuit && row.rowType !== "deviceRow") {
|
|
void handleDeviceRowDropWithIntent(event, {
|
|
kind: "move-to-circuit",
|
|
circuitId: row.circuit.id,
|
|
sectionId: row.sectionId,
|
|
});
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
{columns.map((column) => {
|
|
const cell = row.cells.find((entry) => entry.cellKey === column.key)!;
|
|
const isSelected =
|
|
selectedCell?.rowKey === row.rowKey && selectedCell.cellKey === column.key;
|
|
const isEditing =
|
|
editingCell?.rowKey === row.rowKey && editingCell.cellKey === column.key;
|
|
return (
|
|
<td
|
|
key={column.key}
|
|
className={`${column.numeric ? "num" : ""} ${cell.editable ? "cell-editable" : ""} ${isSelected ? "cell-selected" : ""} ${
|
|
Boolean(row.device) && column.key === "displayName" && (row.rowType === "deviceRow" || row.rowType === "circuitCompact")
|
|
? "device-drag-handle"
|
|
: ""
|
|
} ${
|
|
column.key === "equipmentIdentifier" &&
|
|
Boolean(row.circuit) &&
|
|
(row.rowType === "circuitCompact" || row.rowType === "circuitSummary" || row.rowType === "reserveCircuit")
|
|
? "circuit-drag-handle"
|
|
: ""
|
|
} ${
|
|
draggingDeviceRowId && row.device?.id === draggingDeviceRowId ? "device-dragging" : ""
|
|
}`}
|
|
draggable={
|
|
!isEditing &&
|
|
((Boolean(row.device) &&
|
|
column.key === "displayName" &&
|
|
(row.rowType === "deviceRow" || row.rowType === "circuitCompact")) ||
|
|
(Boolean(row.circuit) &&
|
|
column.key === "equipmentIdentifier" &&
|
|
(row.rowType === "circuitCompact" ||
|
|
row.rowType === "circuitSummary" ||
|
|
row.rowType === "reserveCircuit")))
|
|
}
|
|
onDragStart={(event) => {
|
|
if (
|
|
row.circuit &&
|
|
column.key === "equipmentIdentifier" &&
|
|
(row.rowType === "circuitCompact" ||
|
|
row.rowType === "circuitSummary" ||
|
|
row.rowType === "reserveCircuit")
|
|
) {
|
|
setDraggingProjectDeviceId(null);
|
|
setDropIntent(null);
|
|
setDraggingDeviceRowId(null);
|
|
setDeviceMoveIntent(null);
|
|
setDraggingCircuitId(row.circuit.id);
|
|
setCircuitReorderIntent(null);
|
|
event.dataTransfer.effectAllowed = "move";
|
|
event.dataTransfer.setData("application/x-circuit-id", row.circuit.id);
|
|
return;
|
|
}
|
|
if (
|
|
row.device &&
|
|
column.key === "displayName" &&
|
|
(row.rowType === "deviceRow" || row.rowType === "circuitCompact")
|
|
) {
|
|
setDraggingProjectDeviceId(null);
|
|
setDropIntent(null);
|
|
setDraggingCircuitId(null);
|
|
setCircuitReorderIntent(null);
|
|
setDraggingDeviceRowId(row.device.id);
|
|
setDeviceMoveIntent(null);
|
|
event.dataTransfer.effectAllowed = "move";
|
|
event.dataTransfer.setData("application/x-circuit-device-row-id", row.device.id);
|
|
}
|
|
}}
|
|
onDragEnd={() => {
|
|
setDraggingDeviceRowId(null);
|
|
setDeviceMoveIntent(null);
|
|
setDraggingCircuitId(null);
|
|
setCircuitReorderIntent(null);
|
|
}}
|
|
onClick={() => {
|
|
if (cell.editable) {
|
|
setSelectedCell({ rowKey: row.rowKey, cellKey: column.key });
|
|
requestAnimationFrame(() => containerRef.current?.focus());
|
|
}
|
|
}}
|
|
onDoubleClick={() => {
|
|
if (cell.editable) {
|
|
startEdit({ rowKey: row.rowKey, cellKey: column.key }, "selectExisting");
|
|
}
|
|
}}
|
|
>
|
|
{isEditing ? (
|
|
<input
|
|
ref={inputRef}
|
|
value={editingCell.draft}
|
|
onChange={(event) =>
|
|
setEditingCell((current) =>
|
|
current ? { ...current, draft: event.target.value } : current
|
|
)
|
|
}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
void commitEdit("stay");
|
|
} else if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
cancelEdit();
|
|
} else if (event.key === "Tab") {
|
|
event.preventDefault();
|
|
void commitEdit(event.shiftKey ? "prev" : "next");
|
|
}
|
|
}}
|
|
onBlur={() => {
|
|
requestAnimationFrame(() => {
|
|
const active = document.activeElement as HTMLElement | null;
|
|
if (!active || !containerRef.current?.contains(active)) {
|
|
setEditingCell(null);
|
|
requestAnimationFrame(() => containerRef.current?.focus());
|
|
}
|
|
});
|
|
}}
|
|
/>
|
|
) : (
|
|
formatValue(cell.value)
|
|
)}
|
|
</td>
|
|
);
|
|
})}
|
|
<td
|
|
className={`action-cell ${
|
|
(dropIntent?.kind === "add-to-circuit" && dropIntent.circuitId === row.circuit?.id) ||
|
|
(deviceMoveIntent?.kind === "move-to-circuit" && deviceMoveIntent.circuitId === row.circuit?.id)
|
|
? "drop-target-active"
|
|
: ""
|
|
}`}
|
|
>
|
|
{row.circuit && row.rowType !== "deviceRow" ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
tabIndex={-1}
|
|
onClick={() => void handleAddManualDevice(row.circuit!, row.sectionId)}
|
|
>
|
|
Add manual device
|
|
</button>
|
|
<button
|
|
type="button"
|
|
tabIndex={-1}
|
|
onClick={() => void handleDeleteCircuit(row.circuit!.id)}
|
|
>
|
|
Delete circuit
|
|
</button>
|
|
</>
|
|
) : null}
|
|
{row.device ? (
|
|
<button type="button" tabIndex={-1} onClick={() => void handleDeleteDevice(row.device!.id)}>
|
|
Delete device
|
|
</button>
|
|
) : null}
|
|
{dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? (
|
|
<span className="drop-hint">new circuit in this section</span>
|
|
) : null}
|
|
{dropIntent?.kind === "add-to-circuit" && dropIntent.circuitId === row.circuit?.id ? (
|
|
<span className="drop-hint">add to this circuit</span>
|
|
) : null}
|
|
{deviceMoveIntent?.kind === "move-to-new-circuit" &&
|
|
row.rowType === "placeholder" &&
|
|
deviceMoveIntent.sectionId === row.sectionId ? (
|
|
<span className="drop-hint">move device to new circuit</span>
|
|
) : null}
|
|
{deviceMoveIntent?.kind === "move-to-circuit" && deviceMoveIntent.circuitId === row.circuit?.id ? (
|
|
<span className="drop-hint">move device to this circuit</span>
|
|
) : null}
|
|
{circuitReorderIntent?.kind === "section-end" &&
|
|
row.rowType === "placeholder" &&
|
|
circuitReorderIntent.sectionId === row.sectionId ? (
|
|
<span className="drop-hint">
|
|
{circuitReorderIntent.valid ? "move circuit to section end" : "cross-section move not allowed"}
|
|
</span>
|
|
) : null}
|
|
{circuitReorderIntent &&
|
|
(circuitReorderIntent.kind === "before-circuit" || circuitReorderIntent.kind === "after-circuit") &&
|
|
circuitReorderIntent.targetCircuitId === row.circuit?.id ? (
|
|
<span className="drop-hint">
|
|
{circuitReorderIntent.valid
|
|
? circuitReorderIntent.kind === "before-circuit"
|
|
? "move circuit before this circuit"
|
|
: "move circuit after this circuit"
|
|
: "cross-section move not allowed"}
|
|
</span>
|
|
) : null}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<p className="todo-hint">TODO Phase: Ctrl+Plus currently adds circuit at end of active section.</p>
|
|
</div>
|
|
);
|
|
}
|