3365 lines
127 KiB
TypeScript
3365 lines
127 KiB
TypeScript
"use client";
|
|
|
|
import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
createCircuit,
|
|
createCircuitDeviceRow,
|
|
deleteCircuitById,
|
|
deleteCircuitDeviceRowById,
|
|
getCircuitTree,
|
|
getNextCircuitIdentifier,
|
|
listProjectDevices,
|
|
moveCircuitDeviceRowsBulk,
|
|
moveCircuitDeviceRowById,
|
|
reorderSectionCircuits,
|
|
renumberCircuitSection,
|
|
updateSectionEquipmentIdentifiers,
|
|
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"
|
|
| "technicalName"
|
|
| "connectionKind"
|
|
| "phaseType"
|
|
| "costGroup"
|
|
| "category"
|
|
| "level"
|
|
| "roomNumberSnapshot"
|
|
| "roomNameSnapshot"
|
|
| "cosPhi"
|
|
| "protectionType"
|
|
| "protectionRatedCurrent"
|
|
| "protectionCharacteristic"
|
|
| "cableType"
|
|
| "cableCrossSection"
|
|
| "cableLength"
|
|
| "rcdAssignment"
|
|
| "terminalDesignation"
|
|
| "status"
|
|
| "isReserve";
|
|
|
|
type SaveDirection = "stay" | "next" | "prev";
|
|
type StartEditMode = "selectExisting" | "replaceWithTypedChar";
|
|
type RowType = "section" | "circuitCompact" | "circuitSummary" | "deviceRow" | "reserveCircuit" | "placeholder";
|
|
type CellKind = "circuitField" | "deviceField" | "computed" | "readonly";
|
|
type SortDirection = "asc" | "desc";
|
|
|
|
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[];
|
|
}
|
|
|
|
interface HistoryCommand {
|
|
label: string;
|
|
redo: () => Promise<SelectionIntent | null | void>;
|
|
undo: () => Promise<SelectionIntent | null | void>;
|
|
}
|
|
|
|
interface CircuitSnapshot {
|
|
id: string;
|
|
sectionId: string;
|
|
equipmentIdentifier: string;
|
|
displayName?: string;
|
|
sortOrder: number;
|
|
protectionType?: string;
|
|
protectionRatedCurrent?: number;
|
|
protectionCharacteristic?: string;
|
|
cableType?: string;
|
|
cableCrossSection?: string;
|
|
cableLength?: number;
|
|
rcdAssignment?: string;
|
|
terminalDesignation?: string;
|
|
voltage?: number;
|
|
status?: string;
|
|
isReserve: boolean;
|
|
remark?: string;
|
|
deviceRows: CircuitTreeDeviceRowDto[];
|
|
}
|
|
|
|
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 COLUMN_LAYOUT_STORAGE_KEY = "circuitTreeEditor.columnLayout.v1";
|
|
|
|
interface ColumnDef {
|
|
key: CellKey;
|
|
label: string;
|
|
numeric?: boolean;
|
|
defaultVisible?: boolean;
|
|
locked?: boolean;
|
|
}
|
|
|
|
// Centralized column metadata keeps render, filtering/sorting, keyboard traversal,
|
|
// and persisted layout in sync. BMK/equipmentIdentifier stays locked as first column
|
|
// because circuit identity and circuit drag handles depend on always-visible BMK context.
|
|
const allColumns: ColumnDef[] = [
|
|
{ key: "equipmentIdentifier", label: "Equipment identifier", defaultVisible: true, locked: true },
|
|
{ key: "displayName", label: "Display name", defaultVisible: true },
|
|
{ key: "quantity", label: "Quantity", numeric: true, defaultVisible: true },
|
|
{ key: "powerPerUnit", label: "Power / unit", numeric: true, defaultVisible: true },
|
|
{ key: "simultaneityFactor", label: "Simultaneity", numeric: true, defaultVisible: true },
|
|
{ key: "rowTotalPower", label: "Row total", numeric: true, defaultVisible: true },
|
|
{ key: "circuitTotalPower", label: "Circuit total", numeric: true, defaultVisible: true },
|
|
{ key: "protectionSummary", label: "Protection summary", defaultVisible: true },
|
|
{ key: "cableSummary", label: "Cable summary", defaultVisible: true },
|
|
{ key: "roomSummary", label: "Room", defaultVisible: true },
|
|
{ key: "remark", label: "Remark", defaultVisible: true },
|
|
{ key: "technicalName", label: "Technical name" },
|
|
{ key: "connectionKind", label: "Connection kind" },
|
|
{ key: "phaseType", label: "Phase type" },
|
|
{ key: "costGroup", label: "Cost group" },
|
|
{ key: "category", label: "Category" },
|
|
{ key: "level", label: "Level" },
|
|
{ key: "roomNumberSnapshot", label: "Room number" },
|
|
{ key: "roomNameSnapshot", label: "Room name" },
|
|
{ key: "cosPhi", label: "cosPhi", numeric: true },
|
|
{ key: "protectionType", label: "Protection type" },
|
|
{ key: "protectionRatedCurrent", label: "Protection rated current", numeric: true },
|
|
{ key: "protectionCharacteristic", label: "Protection characteristic" },
|
|
{ key: "cableType", label: "Cable type" },
|
|
{ key: "cableCrossSection", label: "Cable cross-section" },
|
|
{ key: "cableLength", label: "Cable length", numeric: true },
|
|
{ key: "rcdAssignment", label: "RCD assignment" },
|
|
{ key: "terminalDesignation", label: "Terminal designation" },
|
|
{ key: "status", label: "Status" },
|
|
{ key: "isReserve", label: "Reserve" },
|
|
];
|
|
|
|
const defaultVisibleColumnKeys = allColumns.filter((column) => column.defaultVisible).map((column) => column.key);
|
|
|
|
const deviceOnlyColumns = new Set<CellKey>([
|
|
"quantity",
|
|
"powerPerUnit",
|
|
"simultaneityFactor",
|
|
"rowTotalPower",
|
|
"roomSummary",
|
|
"technicalName",
|
|
"connectionKind",
|
|
"phaseType",
|
|
"costGroup",
|
|
"category",
|
|
"level",
|
|
"roomNumberSnapshot",
|
|
"roomNameSnapshot",
|
|
"cosPhi",
|
|
]);
|
|
const circuitOnlyColumns = new Set<CellKey>([
|
|
"equipmentIdentifier",
|
|
"protectionSummary",
|
|
"cableSummary",
|
|
"circuitTotalPower",
|
|
"protectionType",
|
|
"protectionRatedCurrent",
|
|
"protectionCharacteristic",
|
|
"cableType",
|
|
"cableCrossSection",
|
|
"cableLength",
|
|
"rcdAssignment",
|
|
"terminalDesignation",
|
|
"status",
|
|
"isReserve",
|
|
]);
|
|
|
|
const deviceFieldKeys = new Set<CellKey>([
|
|
"displayName",
|
|
"technicalName",
|
|
"connectionKind",
|
|
"phaseType",
|
|
"costGroup",
|
|
"category",
|
|
"level",
|
|
"roomSummary",
|
|
"roomNumberSnapshot",
|
|
"roomNameSnapshot",
|
|
"quantity",
|
|
"powerPerUnit",
|
|
"simultaneityFactor",
|
|
"cosPhi",
|
|
"remark",
|
|
]);
|
|
|
|
const circuitFieldKeys = new Set<CellKey>([
|
|
"equipmentIdentifier",
|
|
"displayName",
|
|
"protectionType",
|
|
"protectionRatedCurrent",
|
|
"protectionCharacteristic",
|
|
"protectionSummary",
|
|
"cableType",
|
|
"cableCrossSection",
|
|
"cableLength",
|
|
"cableSummary",
|
|
"rcdAssignment",
|
|
"terminalDesignation",
|
|
"status",
|
|
"isReserve",
|
|
"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 "technicalName":
|
|
return device.name;
|
|
case "displayName":
|
|
return device.displayName || device.name;
|
|
case "phaseType":
|
|
return device.phaseType;
|
|
case "connectionKind":
|
|
return device.connectionKind;
|
|
case "costGroup":
|
|
return device.costGroup;
|
|
case "category":
|
|
return device.category;
|
|
case "level":
|
|
return device.level;
|
|
case "roomSummary":
|
|
return [device.roomNumberSnapshot, device.roomNameSnapshot].filter(Boolean).join(" ").trim() || undefined;
|
|
case "roomNumberSnapshot":
|
|
return device.roomNumberSnapshot;
|
|
case "roomNameSnapshot":
|
|
return device.roomNameSnapshot;
|
|
case "quantity":
|
|
return device.quantity;
|
|
case "powerPerUnit":
|
|
return device.powerPerUnit;
|
|
case "simultaneityFactor":
|
|
return device.simultaneityFactor;
|
|
case "cosPhi":
|
|
return device.cosPhi;
|
|
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 "protectionType":
|
|
return circuit.protectionType;
|
|
case "protectionRatedCurrent":
|
|
return circuit.protectionRatedCurrent;
|
|
case "protectionCharacteristic":
|
|
return circuit.protectionCharacteristic;
|
|
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 "cableType":
|
|
return circuit.cableType;
|
|
case "cableCrossSection":
|
|
return circuit.cableCrossSection;
|
|
case "cableLength":
|
|
return circuit.cableLength;
|
|
case "rcdAssignment":
|
|
return circuit.rcdAssignment;
|
|
case "terminalDesignation":
|
|
return circuit.terminalDesignation;
|
|
case "status":
|
|
return circuit.status;
|
|
case "isReserve":
|
|
return circuit.isReserve;
|
|
case "remark":
|
|
return circuit.remark;
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function normalizeFilterValue(value: string | number | boolean | undefined) {
|
|
return formatValue(value);
|
|
}
|
|
|
|
function getBlockSortValue(circuit: CircuitTreeCircuitDto, key: CellKey) {
|
|
const firstRow = circuit.deviceRows[0];
|
|
if (circuitOnlyColumns.has(key)) {
|
|
return getCircuitValue(circuit, key);
|
|
}
|
|
if (deviceOnlyColumns.has(key)) {
|
|
return firstRow ? getDeviceValue(firstRow, key) : undefined;
|
|
}
|
|
if (key === "displayName" || key === "remark") {
|
|
if (circuit.displayName || circuit.remark) {
|
|
return getCircuitValue(circuit, key);
|
|
}
|
|
return firstRow ? getDeviceValue(firstRow, key) : undefined;
|
|
}
|
|
return getCircuitValue(circuit, key);
|
|
}
|
|
|
|
function compareSortValues(a: string | number | boolean | undefined, b: string | number | boolean | undefined) {
|
|
const av = a === undefined || a === null ? "" : a;
|
|
const bv = b === undefined || b === null ? "" : b;
|
|
if (typeof av === "number" && typeof bv === "number") {
|
|
return av - bv;
|
|
}
|
|
return String(av).localeCompare(String(bv), undefined, { sensitivity: "base", numeric: true });
|
|
}
|
|
|
|
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 }) {
|
|
/*
|
|
Circuit-tree editor invariant overview:
|
|
- Data model is circuit-first (section -> circuit -> device rows).
|
|
- Rendered table rows are virtual projection rows, not direct DB rows.
|
|
- A circuit may appear as compact row, summary+device rows, reserve row, or placeholder row.
|
|
- Keyboard navigation and drag/drop targeting must use normalized visible rows, not raw tree nesting.
|
|
*/
|
|
const { projectId, circuitListId } = props;
|
|
const [data, setData] = useState<CircuitTreeResponseDto | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
// selectedCell is spreadsheet-style keyboard focus target within normalized grid.
|
|
// It can exist without an active edit input.
|
|
const [selectedCell, setSelectedCell] = useState<SelectedCell | null>(null);
|
|
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
|
const [anchorRowKey, setAnchorRowKey] = useState<string | null>(null);
|
|
// editingCell is mounted input session state (draft + mode + focus token).
|
|
// It should only exist for currently editable grid cells.
|
|
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);
|
|
// Drag intent states stay separated by domain intent to avoid cross-type accidental operations.
|
|
// project-device drag => create/append linked rows, device-row drag => move rows,
|
|
// circuit drag => reorder circuit blocks, column drag => layout-only changes.
|
|
const [draggingProjectDeviceId, setDraggingProjectDeviceId] = useState<string | null>(null);
|
|
const [dropIntent, setDropIntent] = useState<ProjectDeviceDropIntent | null>(null);
|
|
const [draggingDeviceRowId, setDraggingDeviceRowId] = useState<string | null>(null);
|
|
const [draggingDeviceRowIds, setDraggingDeviceRowIds] = useState<string[]>([]);
|
|
const [deviceMoveIntent, setDeviceMoveIntent] = useState<DeviceRowMoveDropIntent | null>(null);
|
|
const [draggingCircuitId, setDraggingCircuitId] = useState<string | null>(null);
|
|
const [draggingCircuitIds, setDraggingCircuitIds] = useState<string[]>([]);
|
|
const [circuitReorderIntent, setCircuitReorderIntent] = useState<CircuitReorderDropIntent | null>(null);
|
|
// Undo/redo history is session-local UI state (not persisted server-side).
|
|
const [undoStack, setUndoStack] = useState<HistoryCommand[]>([]);
|
|
const [redoStack, setRedoStack] = useState<HistoryCommand[]>([]);
|
|
const [historyBusy, setHistoryBusy] = useState(false);
|
|
const [sortState, setSortState] = useState<{ key: CellKey; direction: SortDirection } | null>(null);
|
|
const [columnFilters, setColumnFilters] = useState<Partial<Record<CellKey, string[]>>>({});
|
|
const [openFilterColumn, setOpenFilterColumn] = useState<CellKey | null>(null);
|
|
const [visibleColumnKeys, setVisibleColumnKeys] = useState<CellKey[]>(defaultVisibleColumnKeys);
|
|
const [columnOrder, setColumnOrder] = useState<CellKey[]>(allColumns.map((column) => column.key));
|
|
const [isColumnMenuOpen, setIsColumnMenuOpen] = useState(false);
|
|
const [draggingColumnKey, setDraggingColumnKey] = useState<CellKey | null>(null);
|
|
const [columnDropTargetKey, setColumnDropTargetKey] = useState<CellKey | null>(null);
|
|
const [pendingFocus, setPendingFocus] = useState<SelectedCell | null>(null);
|
|
const pendingSelectionAfterReload = useRef<SelectionIntent | null>(null);
|
|
const pendingSelectedDeviceRowIdsAfterReload = useRef<string[] | null>(null);
|
|
const pendingSelectedCircuitIdsAfterReload = useRef<string[] | null>(null);
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
const focusTokenRef = useRef(1);
|
|
|
|
const orderedColumns = useMemo(
|
|
() => columnOrder.map((key) => allColumns.find((column) => column.key === key)).filter(Boolean) as ColumnDef[],
|
|
[columnOrder]
|
|
);
|
|
const visibleColumns = useMemo(
|
|
() => orderedColumns.filter((column) => visibleColumnKeys.includes(column.key)),
|
|
[orderedColumns, visibleColumnKeys]
|
|
);
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
function normalizeColumnOrder(keys: CellKey[]) {
|
|
const unique = [...new Set(keys)];
|
|
const allKeys = allColumns.map((column) => column.key);
|
|
const merged = [...unique, ...allKeys.filter((key) => !unique.includes(key))];
|
|
return ["equipmentIdentifier" as CellKey, ...merged.filter((key) => key !== "equipmentIdentifier")];
|
|
}
|
|
|
|
// Initial/identity-change tree load for current route context.
|
|
useEffect(() => {
|
|
void loadTree({ showLoading: true });
|
|
}, [projectId, circuitListId]);
|
|
|
|
// Loads project-device palette used for sidebar drag/drop and quick inserts.
|
|
useEffect(() => {
|
|
async function loadProjectDeviceList() {
|
|
try {
|
|
const list = await listProjectDevices(projectId);
|
|
setProjectDevices(list);
|
|
} catch (err) {
|
|
setError(normalizeUiError(err));
|
|
}
|
|
}
|
|
void loadProjectDeviceList();
|
|
}, [projectId]);
|
|
|
|
useEffect(() => {
|
|
try {
|
|
const raw = localStorage.getItem(COLUMN_LAYOUT_STORAGE_KEY);
|
|
if (!raw) {
|
|
return;
|
|
}
|
|
const parsed = JSON.parse(raw) as { order?: string[]; visible?: string[] };
|
|
const validKeys = new Set(allColumns.map((column) => column.key));
|
|
const parsedOrder = (parsed.order ?? []).filter((key): key is CellKey => validKeys.has(key as CellKey));
|
|
const parsedVisible = (parsed.visible ?? []).filter((key): key is CellKey => validKeys.has(key as CellKey));
|
|
if (!parsedOrder.length || !parsedVisible.length || !parsedVisible.includes("equipmentIdentifier")) {
|
|
return;
|
|
}
|
|
setColumnOrder(normalizeColumnOrder(parsedOrder));
|
|
setVisibleColumnKeys(parsedVisible);
|
|
} catch {
|
|
// ignore invalid local storage and use defaults
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const payload = {
|
|
order: columnOrder,
|
|
visible: visibleColumnKeys,
|
|
};
|
|
localStorage.setItem(COLUMN_LAYOUT_STORAGE_KEY, JSON.stringify(payload));
|
|
}, [columnOrder, visibleColumnKeys]);
|
|
|
|
useEffect(() => {
|
|
const visible = new Set(visibleColumnKeys);
|
|
setSortState((current) => {
|
|
if (!current) {
|
|
return current;
|
|
}
|
|
return visible.has(current.key) ? current : null;
|
|
});
|
|
setColumnFilters((current) => {
|
|
const next: Partial<Record<CellKey, string[]>> = {};
|
|
for (const [key, values] of Object.entries(current)) {
|
|
if (visible.has(key as CellKey) && values && values.length > 0) {
|
|
next[key as CellKey] = values;
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
setOpenFilterColumn((current) => (current && visible.has(current) ? current : null));
|
|
}, [visibleColumnKeys]);
|
|
|
|
const hasActiveFilters = useMemo(
|
|
() => Object.values(columnFilters).some((values) => (values?.length ?? 0) > 0),
|
|
[columnFilters]
|
|
);
|
|
|
|
const distinctValuesByColumn = useMemo(() => {
|
|
// Filter options are generated only for visible columns to match current header UI.
|
|
// Hidden columns still exist in row model, but are intentionally not filterable from header.
|
|
const result = {} as Record<CellKey, string[]>;
|
|
for (const column of visibleColumns) {
|
|
const set = new Set<string>();
|
|
for (const section of data?.sections ?? []) {
|
|
for (const circuit of section.circuits) {
|
|
set.add(normalizeFilterValue(getCircuitValue(circuit, column.key)));
|
|
for (const row of circuit.deviceRows) {
|
|
set.add(normalizeFilterValue(getDeviceValue(row, column.key)));
|
|
}
|
|
}
|
|
}
|
|
result[column.key] = [...set].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
}
|
|
return result;
|
|
}, [data, visibleColumns]);
|
|
|
|
const filteredSortedSections = useMemo(() => {
|
|
// Filtering and sorting are frontend view state. Backend sort order remains unchanged
|
|
// until user explicitly applies sorted order.
|
|
if (!data) {
|
|
return [] as CircuitTreeResponseDto["sections"];
|
|
}
|
|
const matchesColumnFilter = (
|
|
circuit: CircuitTreeCircuitDto,
|
|
row: CircuitTreeDeviceRowDto | null,
|
|
key: CellKey,
|
|
selected: string[]
|
|
) => {
|
|
if (!selected.length) {
|
|
return true;
|
|
}
|
|
const values = new Set<string>();
|
|
values.add(normalizeFilterValue(getCircuitValue(circuit, key)));
|
|
if (row) {
|
|
values.add(normalizeFilterValue(getDeviceValue(row, key)));
|
|
} else {
|
|
for (const device of circuit.deviceRows) {
|
|
values.add(normalizeFilterValue(getDeviceValue(device, key)));
|
|
}
|
|
}
|
|
return selected.some((entry) => values.has(entry));
|
|
};
|
|
|
|
const sections = data.sections
|
|
.map((section) => {
|
|
let circuits = section.circuits
|
|
.map((circuit) => {
|
|
const selectedFilters = Object.entries(columnFilters).filter(([, values]) => (values?.length ?? 0) > 0);
|
|
const circuitMatchesAll = selectedFilters.every(([key, values]) =>
|
|
matchesColumnFilter(circuit, null, key as CellKey, values ?? [])
|
|
);
|
|
if (!circuitMatchesAll) {
|
|
return null;
|
|
}
|
|
|
|
if (!hasActiveFilters || circuit.deviceRows.length <= 1) {
|
|
return circuit;
|
|
}
|
|
|
|
const matchingRows = circuit.deviceRows.filter((row) =>
|
|
selectedFilters.every(([key, values]) =>
|
|
matchesColumnFilter(circuit, row, key as CellKey, values ?? [])
|
|
)
|
|
);
|
|
|
|
if (matchingRows.length > 0) {
|
|
return { ...circuit, deviceRows: matchingRows };
|
|
}
|
|
if (selectedFilters.some(([key]) => circuitOnlyColumns.has(key as CellKey))) {
|
|
return circuit;
|
|
}
|
|
return null;
|
|
})
|
|
.filter(Boolean) as CircuitTreeCircuitDto[];
|
|
|
|
if (sortState) {
|
|
// Sort on circuit blocks so multi-device circuits keep row grouping integrity.
|
|
circuits = [...circuits].sort((a, b) => {
|
|
const cmp = compareSortValues(getBlockSortValue(a, sortState.key), getBlockSortValue(b, sortState.key));
|
|
return sortState.direction === "asc" ? cmp : -cmp;
|
|
});
|
|
}
|
|
return { ...section, circuits };
|
|
})
|
|
.filter((section) => section.circuits.length > 0);
|
|
|
|
return sections;
|
|
}, [data, columnFilters, hasActiveFilters, sortState]);
|
|
|
|
// visibleRows is the single normalized grid used by render + navigation + editability.
|
|
// This avoids selected/rendered/editable state drifting apart after filters/sorts/reloads.
|
|
const visibleRows = useMemo(() => {
|
|
if (!filteredSortedSections.length) {
|
|
return [] as VisibleGridRow[];
|
|
}
|
|
const rows: VisibleGridRow[] = [];
|
|
for (const section of filteredSortedSections) {
|
|
rows.push({
|
|
rowKey: `section:${section.id}`,
|
|
rowType: "section",
|
|
sectionId: section.id,
|
|
// Section headers are structural markers only: visible, but never editable/selectable.
|
|
cells: allColumns.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));
|
|
}
|
|
}
|
|
// Placeholder row is virtual (-frei-) but intentionally part of editable grid because
|
|
// editing/dropping here is the fast path to create a real circuit in this section.
|
|
rows.push(makeRow("placeholder", section.id));
|
|
}
|
|
return rows;
|
|
}, [filteredSortedSections]);
|
|
|
|
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 {
|
|
// Row keys are UI identities, not persisted identities. They are rebuilt on each tree refresh.
|
|
const rowKey =
|
|
rowType === "placeholder"
|
|
? `placeholder:${sectionId}`
|
|
: rowType === "deviceRow" && device
|
|
? `device:${device.id}`
|
|
: `${rowType}:${circuit?.id ?? sectionId}`;
|
|
const cells = allColumns.map((col) => {
|
|
const kind = getCellKind(rowType, col.key);
|
|
// Hidden columns remain in the normalized row data; only visibility controls rendering.
|
|
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]
|
|
);
|
|
|
|
// Map each visible row to editable cells currently on screen.
|
|
// This keeps Tab/arrow navigation aligned with hidden/reordered columns.
|
|
const rowCellMap = useMemo(() => {
|
|
const visibleKeySet = new Set(visibleColumnKeys);
|
|
const map = new Map<string, CellKey[]>();
|
|
for (const row of visibleRows) {
|
|
const keys = row.cells
|
|
.filter((cell) => cell.editable && visibleKeySet.has(cell.cellKey))
|
|
.map((cell) => cell.cellKey as CellKey);
|
|
if (keys.length) {
|
|
map.set(row.rowKey, keys);
|
|
}
|
|
}
|
|
return map;
|
|
}, [visibleRows, visibleColumnKeys]);
|
|
|
|
const editableRowOrder = useMemo(
|
|
() => visibleRows.filter((row) => rowCellMap.has(row.rowKey)).map((row) => row.rowKey),
|
|
[visibleRows, rowCellMap]
|
|
);
|
|
const selectableRowOrder = useMemo(
|
|
() =>
|
|
visibleRows
|
|
.filter(
|
|
(row) =>
|
|
row.rowType === "circuitCompact" ||
|
|
row.rowType === "circuitSummary" ||
|
|
row.rowType === "deviceRow" ||
|
|
row.rowType === "reserveCircuit"
|
|
)
|
|
.map((row) => row.rowKey),
|
|
[visibleRows]
|
|
);
|
|
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
setSelectedRowKeys((current) => current.filter((rowKey) => selectableRowOrder.includes(rowKey)));
|
|
setAnchorRowKey((current) => (current && selectableRowOrder.includes(current) ? current : null));
|
|
}, [selectableRowOrder]);
|
|
|
|
function isSelectableRowType(rowType: RowType) {
|
|
return (
|
|
rowType === "circuitCompact" ||
|
|
rowType === "circuitSummary" ||
|
|
rowType === "deviceRow" ||
|
|
rowType === "reserveCircuit"
|
|
);
|
|
}
|
|
|
|
function selectRowRange(targetRowKey: string) {
|
|
const anchor = anchorRowKey ?? selectedRowKeys[selectedRowKeys.length - 1] ?? targetRowKey;
|
|
const start = selectableRowOrder.indexOf(anchor);
|
|
const end = selectableRowOrder.indexOf(targetRowKey);
|
|
if (start < 0 || end < 0) {
|
|
setSelectedRowKeys([targetRowKey]);
|
|
setAnchorRowKey(targetRowKey);
|
|
return;
|
|
}
|
|
const from = Math.min(start, end);
|
|
const to = Math.max(start, end);
|
|
setSelectedRowKeys(selectableRowOrder.slice(from, to + 1));
|
|
setAnchorRowKey(anchor);
|
|
}
|
|
|
|
// Applies cell/row selection semantics. Row selection is UI-only state and later interpreted
|
|
// as either device-row batch selection or circuit batch selection depending on drag context.
|
|
function handleRowSelectionClick(
|
|
row: VisibleGridRow,
|
|
cellKey: CellKey,
|
|
options?: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }
|
|
) {
|
|
if (isSelectableRowType(row.rowType)) {
|
|
const ctrlOrMeta = Boolean(options?.ctrlKey || options?.metaKey);
|
|
if (options?.shiftKey) {
|
|
selectRowRange(row.rowKey);
|
|
} else if (ctrlOrMeta) {
|
|
setSelectedRowKeys((current) => {
|
|
if (current.includes(row.rowKey)) {
|
|
return current.filter((key) => key !== row.rowKey);
|
|
}
|
|
return [...current, row.rowKey];
|
|
});
|
|
setAnchorRowKey(row.rowKey);
|
|
} else {
|
|
setSelectedRowKeys([row.rowKey]);
|
|
setAnchorRowKey(row.rowKey);
|
|
}
|
|
} else if (!options?.ctrlKey && !options?.metaKey && !options?.shiftKey) {
|
|
setSelectedRowKeys([]);
|
|
setAnchorRowKey(null);
|
|
}
|
|
setSelectedCell({ rowKey: row.rowKey, cellKey });
|
|
requestAnimationFrame(() => containerRef.current?.focus());
|
|
}
|
|
|
|
// Captures semantic identity for post-reload focus restoration.
|
|
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,
|
|
};
|
|
}
|
|
|
|
// Resolves selection after writes with fallback chain:
|
|
// exact cell -> same device -> same circuit rowType -> same circuit -> same section -> first editable.
|
|
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;
|
|
}
|
|
|
|
// Reload helper for write paths. Stores pending intent because row/cell keys can change after reload.
|
|
async function reloadWithIntent(intent?: SelectionIntent | null) {
|
|
if (intent) {
|
|
pendingSelectionAfterReload.current = intent;
|
|
}
|
|
await loadTree({ showLoading: false });
|
|
if (!intent) {
|
|
requestAnimationFrame(() => containerRef.current?.focus());
|
|
}
|
|
}
|
|
|
|
// Runs a normal command and records it in session-local history.
|
|
async function runCommand(command: HistoryCommand) {
|
|
try {
|
|
setError(null);
|
|
setIsSaving(true);
|
|
const intent = await command.redo();
|
|
await reloadWithIntent(intent ?? null);
|
|
// Any new forward command invalidates redo history branch.
|
|
setUndoStack((current) => [...current, command]);
|
|
setRedoStack([]);
|
|
} catch (err) {
|
|
setError(normalizeUiError(err));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}
|
|
|
|
// Replays command in undo/redo mode. Commands encapsulate their own inverse logic.
|
|
async function applyHistory(command: HistoryCommand, mode: "undo" | "redo") {
|
|
try {
|
|
setError(null);
|
|
setHistoryBusy(true);
|
|
setIsSaving(true);
|
|
const intent = mode === "undo" ? await command.undo() : await command.redo();
|
|
await reloadWithIntent(intent ?? null);
|
|
if (mode === "undo") {
|
|
setUndoStack((current) => current.slice(0, -1));
|
|
setRedoStack((current) => [...current, command]);
|
|
} else {
|
|
setRedoStack((current) => current.slice(0, -1));
|
|
setUndoStack((current) => [...current, command]);
|
|
}
|
|
} catch (err) {
|
|
setError(normalizeUiError(err));
|
|
} finally {
|
|
setIsSaving(false);
|
|
setHistoryBusy(false);
|
|
}
|
|
}
|
|
|
|
// Undo command from session-local history stack.
|
|
async function handleUndo() {
|
|
if (historyBusy || isSaving || undoStack.length === 0) {
|
|
return;
|
|
}
|
|
const command = undoStack[undoStack.length - 1];
|
|
await applyHistory(command, "undo");
|
|
}
|
|
|
|
// Redo command from session-local history stack.
|
|
async function handleRedo() {
|
|
if (historyBusy || isSaving || redoStack.length === 0) {
|
|
return;
|
|
}
|
|
const command = redoStack[redoStack.length - 1];
|
|
await applyHistory(command, "redo");
|
|
}
|
|
|
|
// Persists currently sorted block order into section sortOrder values.
|
|
async function handleApplySortedOrder() {
|
|
if (!sortState || hasActiveFilters || !data) {
|
|
return;
|
|
}
|
|
|
|
const beforeBySection = data.sections.map((section) => ({
|
|
sectionId: section.id,
|
|
order: section.circuits.map((circuit) => circuit.id),
|
|
}));
|
|
const afterBySection = filteredSortedSections.map((section) => ({
|
|
sectionId: section.id,
|
|
order: section.circuits.map((circuit) => circuit.id),
|
|
}));
|
|
|
|
const changedSections = afterBySection.filter((after) => {
|
|
const before = beforeBySection.find((entry) => entry.sectionId === after.sectionId);
|
|
if (!before) {
|
|
return false;
|
|
}
|
|
if (before.order.length !== after.order.length) {
|
|
return false;
|
|
}
|
|
return before.order.some((id, index) => id !== after.order[index]);
|
|
});
|
|
|
|
if (changedSections.length === 0) {
|
|
setSortState(null);
|
|
return;
|
|
}
|
|
|
|
// Sorting is view-only until explicitly applied; this avoids accidental persisted reorders.
|
|
await runCommand({
|
|
label: "Apply sorted order",
|
|
redo: async () => {
|
|
for (const section of changedSections) {
|
|
await reorderSectionCircuits(section.sectionId, section.order);
|
|
}
|
|
setSortState(null);
|
|
setOpenFilterColumn(null);
|
|
return null;
|
|
},
|
|
undo: async () => {
|
|
for (const section of beforeBySection) {
|
|
await reorderSectionCircuits(section.sectionId, section.order);
|
|
}
|
|
return null;
|
|
},
|
|
});
|
|
}
|
|
|
|
// Toggles visible columns while keeping full column model intact for data/edit mapping.
|
|
function toggleColumnVisibility(key: CellKey) {
|
|
const column = allColumns.find((entry) => entry.key === key);
|
|
if (!column || column.locked) {
|
|
return;
|
|
}
|
|
setVisibleColumnKeys((current) => {
|
|
if (current.includes(key)) {
|
|
return current.filter((entry) => entry !== key);
|
|
}
|
|
return [...current, key];
|
|
});
|
|
}
|
|
|
|
function moveColumn(key: CellKey, direction: -1 | 1) {
|
|
if (key === "equipmentIdentifier") {
|
|
return;
|
|
}
|
|
setColumnOrder((current) => {
|
|
const index = current.indexOf(key);
|
|
if (index < 0) {
|
|
return current;
|
|
}
|
|
const nextIndex = index + direction;
|
|
if (nextIndex <= 0 || nextIndex >= current.length) {
|
|
return current;
|
|
}
|
|
const clone = [...current];
|
|
const [item] = clone.splice(index, 1);
|
|
clone.splice(nextIndex, 0, item);
|
|
return normalizeColumnOrder(clone);
|
|
});
|
|
}
|
|
|
|
function resetColumns() {
|
|
setVisibleColumnKeys(defaultVisibleColumnKeys);
|
|
setColumnOrder(normalizeColumnOrder(allColumns.map((column) => column.key)));
|
|
setOpenFilterColumn(null);
|
|
}
|
|
|
|
// Column drag affects layout state only and never mutates circuit/device data.
|
|
function moveColumnByDrag(dragKey: CellKey, targetKey: CellKey) {
|
|
if (dragKey === "equipmentIdentifier" || targetKey === "equipmentIdentifier") {
|
|
return;
|
|
}
|
|
setColumnOrder((current) => {
|
|
const from = current.indexOf(dragKey);
|
|
const to = current.indexOf(targetKey);
|
|
if (from < 1 || to < 1 || from === to) {
|
|
return current;
|
|
}
|
|
const clone = [...current];
|
|
const [item] = clone.splice(from, 1);
|
|
const targetIndex = clone.indexOf(targetKey);
|
|
if (targetIndex < 1) {
|
|
return current;
|
|
}
|
|
clone.splice(targetIndex, 0, item);
|
|
return normalizeColumnOrder(clone);
|
|
});
|
|
}
|
|
|
|
function handleColumnDragStart(event: DragEvent<HTMLDivElement>, key: CellKey) {
|
|
if (key === "equipmentIdentifier") {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
setDraggingColumnKey(key);
|
|
setColumnDropTargetKey(null);
|
|
event.dataTransfer.effectAllowed = "move";
|
|
event.dataTransfer.setData("application/x-column-key", key);
|
|
}
|
|
|
|
function handleColumnDragOver(event: DragEvent<HTMLDivElement>, targetKey: CellKey) {
|
|
if (!draggingColumnKey || targetKey === "equipmentIdentifier" || draggingColumnKey === targetKey) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "move";
|
|
setColumnDropTargetKey(targetKey);
|
|
}
|
|
|
|
function handleColumnDrop(event: DragEvent<HTMLDivElement>, targetKey: CellKey) {
|
|
event.preventDefault();
|
|
const dragKey = draggingColumnKey;
|
|
if (!dragKey || dragKey === targetKey) {
|
|
setColumnDropTargetKey(null);
|
|
return;
|
|
}
|
|
moveColumnByDrag(dragKey, targetKey);
|
|
setDraggingColumnKey(null);
|
|
setColumnDropTargetKey(null);
|
|
}
|
|
|
|
function handleColumnDragEnd() {
|
|
setDraggingColumnKey(null);
|
|
setColumnDropTargetKey(null);
|
|
}
|
|
|
|
// Apply deferred selection after async reload only once visible grid is rebuilt.
|
|
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]);
|
|
|
|
// Restores multi-selected device rows after operations that rebuild row keys.
|
|
useEffect(() => {
|
|
const pending = pendingSelectedDeviceRowIdsAfterReload.current;
|
|
if (!pending || pending.length === 0) {
|
|
return;
|
|
}
|
|
const rowKeys: string[] = [];
|
|
for (const row of visibleRows) {
|
|
if ((row.rowType === "deviceRow" || row.rowType === "circuitCompact") && row.device?.id && pending.includes(row.device.id)) {
|
|
rowKeys.push(row.rowKey);
|
|
}
|
|
}
|
|
if (rowKeys.length > 0) {
|
|
setSelectedRowKeys(rowKeys);
|
|
setAnchorRowKey(rowKeys[0]);
|
|
}
|
|
pendingSelectedDeviceRowIdsAfterReload.current = null;
|
|
}, [visibleRows]);
|
|
|
|
// Restores multi-selected circuits after reorder/delete/recreate operations.
|
|
useEffect(() => {
|
|
const pending = pendingSelectedCircuitIdsAfterReload.current;
|
|
if (!pending || pending.length === 0) {
|
|
return;
|
|
}
|
|
const rowKeys: string[] = [];
|
|
for (const row of visibleRows) {
|
|
if (
|
|
(row.rowType === "circuitCompact" || row.rowType === "circuitSummary" || row.rowType === "reserveCircuit") &&
|
|
row.circuit?.id &&
|
|
pending.includes(row.circuit.id)
|
|
) {
|
|
rowKeys.push(row.rowKey);
|
|
}
|
|
}
|
|
if (rowKeys.length > 0) {
|
|
setSelectedRowKeys(rowKeys);
|
|
setAnchorRowKey(rowKeys[0]);
|
|
}
|
|
pendingSelectedCircuitIdsAfterReload.current = null;
|
|
}, [visibleRows]);
|
|
|
|
// PendingFocus is used by toolbar actions that should immediately enter edit mode on a target cell.
|
|
useEffect(() => {
|
|
if (!pendingFocus) {
|
|
return;
|
|
}
|
|
setSelectedCell(pendingFocus);
|
|
startEdit(pendingFocus, "selectExisting");
|
|
setPendingFocus(null);
|
|
}, [pendingFocus]);
|
|
|
|
useEffect(() => {
|
|
if (!isColumnMenuOpen) {
|
|
setDraggingColumnKey(null);
|
|
setColumnDropTargetKey(null);
|
|
}
|
|
}, [isColumnMenuOpen]);
|
|
|
|
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]);
|
|
|
|
// Finds row in normalized grid by virtual row key.
|
|
function findRow(rowKey: string) {
|
|
return visibleRows.find((row) => row.rowKey === rowKey);
|
|
}
|
|
|
|
// Finds cell in normalized grid row.
|
|
function findCell(rowKey: string, cellKey: CellKey) {
|
|
const row = findRow(rowKey);
|
|
return row?.cells.find((cell) => cell.cellKey === cellKey);
|
|
}
|
|
|
|
// Opens edit session for selected cell.
|
|
// Enter/F2/double-click use selectExisting; type-to-edit uses replaceWithTypedChar.
|
|
function startEdit(cell: SelectedCell, mode: StartEditMode, typedChar?: string) {
|
|
const visibleCell = findCell(cell.rowKey, cell.cellKey);
|
|
if (!visibleCell || !visibleCell.editable) {
|
|
return;
|
|
}
|
|
// Spreadsheet behavior: typing on a selected cell starts overwrite mode.
|
|
const currentDisplay = mode === "replaceWithTypedChar" ? typedChar ?? "" : String(visibleCell.value ?? "");
|
|
focusTokenRef.current += 1;
|
|
setEditingCell({
|
|
...cell,
|
|
mode,
|
|
draft: currentDisplay === "-" ? "" : currentDisplay,
|
|
focusToken: focusTokenRef.current,
|
|
});
|
|
}
|
|
|
|
// Horizontal keyboard navigation constrained to editable cells in current row.
|
|
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 });
|
|
}
|
|
|
|
// Vertical keyboard navigation preserves nearest visible column when row cell sets differ.
|
|
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 = visibleColumns.findIndex((col) => col.key === selectedCell.cellKey);
|
|
let best = targetCells[0];
|
|
let bestDistance = Number.POSITIVE_INFINITY;
|
|
for (const key of targetCells) {
|
|
const idx = visibleColumns.findIndex((col) => col.key === key);
|
|
const dist = Math.abs(idx - preferred);
|
|
if (dist < bestDistance) {
|
|
bestDistance = dist;
|
|
best = key;
|
|
}
|
|
}
|
|
setSelectedCell({ rowKey: targetRowKey, cellKey: best });
|
|
}
|
|
|
|
// Maps generic grid cell edits to circuit PATCH payload fields.
|
|
async function patchCircuit(circuitId: string, key: CellKey, draft: string) {
|
|
const payload: Record<string, unknown> = {};
|
|
if (key === "protectionSummary" || key === "cableSummary") {
|
|
payload.remark = draft.trim() === "" ? undefined : draft;
|
|
} else if (["protectionRatedCurrent", "cableLength"].includes(key)) {
|
|
payload[key] = parseNumeric(key, draft);
|
|
} else if (key === "isReserve") {
|
|
const normalized = draft.trim().toLowerCase();
|
|
payload.isReserve = ["1", "true", "yes", "ja"].includes(normalized);
|
|
} else {
|
|
payload[key] = draft.trim() === "" ? undefined : draft;
|
|
}
|
|
await updateCircuitById(circuitId, payload);
|
|
}
|
|
|
|
// Maps generic grid cell edits to device-row PATCH payload fields.
|
|
async function patchDeviceRow(rowId: string, key: CellKey, draft: string) {
|
|
const payload: Record<string, unknown> = {};
|
|
if (["quantity", "powerPerUnit", "simultaneityFactor", "cosPhi"].includes(key)) {
|
|
payload[key] = parseNumeric(key, draft);
|
|
} else if (key === "roomSummary") {
|
|
const trimmed = draft.trim();
|
|
if (!trimmed) {
|
|
payload.roomNumberSnapshot = undefined;
|
|
payload.roomNameSnapshot = undefined;
|
|
} else {
|
|
const firstSpace = trimmed.indexOf(" ");
|
|
if (firstSpace > 0) {
|
|
payload.roomNumberSnapshot = trimmed.slice(0, firstSpace).trim();
|
|
payload.roomNameSnapshot = trimmed.slice(firstSpace + 1).trim();
|
|
} else {
|
|
payload.roomNumberSnapshot = trimmed;
|
|
payload.roomNameSnapshot = undefined;
|
|
}
|
|
}
|
|
} else {
|
|
payload[key] = draft.trim() === "" ? undefined : draft;
|
|
}
|
|
await updateCircuitDeviceRowById(rowId, payload);
|
|
}
|
|
|
|
// Creates a real circuit from virtual placeholder row.
|
|
// Device-field edits create circuit + first manual row; circuit-field edits patch circuit directly.
|
|
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 };
|
|
}
|
|
|
|
// Reads current persisted value into editable draft text for reversible edit commands.
|
|
function getCurrentDraftForCell(row: VisibleGridRow, cellKey: CellKey, kind: CellKind) {
|
|
if (kind === "deviceField" && row.device) {
|
|
return String(getDeviceValue(row.device, cellKey) ?? "");
|
|
}
|
|
if (kind === "circuitField" && row.circuit) {
|
|
return String(getCircuitValue(row.circuit, cellKey) ?? "");
|
|
}
|
|
return "";
|
|
}
|
|
|
|
// Commits the active editor input through command history so edits remain undoable.
|
|
async function commitEdit(direction: SaveDirection = "stay") {
|
|
if (!editingCell) {
|
|
return;
|
|
}
|
|
const row = findRow(editingCell.rowKey);
|
|
const cell = row?.cells.find((entry) => entry.cellKey === editingCell.cellKey);
|
|
if (!row || !cell || !cell.editable) {
|
|
setEditingCell(null);
|
|
return;
|
|
}
|
|
|
|
let targetSelection: SelectedCell = { rowKey: editingCell.rowKey, cellKey: editingCell.cellKey };
|
|
const nextSelectionIntent = () => {
|
|
let selected = targetSelection;
|
|
if (direction !== "stay") {
|
|
const idx = editableCells.findIndex(
|
|
(entry) => entry.rowKey === selected.rowKey && entry.cellKey === selected.cellKey
|
|
);
|
|
if (idx >= 0) {
|
|
const targetIdx =
|
|
direction === "next" ? Math.min(editableCells.length - 1, idx + 1) : Math.max(0, idx - 1);
|
|
selected = editableCells[targetIdx];
|
|
}
|
|
}
|
|
return buildSelectionIntent(selected);
|
|
};
|
|
|
|
// Placeholder rows are not persisted entities; editing them materializes a real circuit first.
|
|
if (row.rowType === "placeholder") {
|
|
let createdCircuitId: string | null = null;
|
|
const command: HistoryCommand = {
|
|
label: "Create from placeholder",
|
|
redo: async () => {
|
|
const selection = await createFromPlaceholder(row.sectionId, editingCell.cellKey, editingCell.draft);
|
|
targetSelection = selection;
|
|
const createdRow = selection.rowKey.startsWith("circuitCompact:")
|
|
? selection.rowKey.replace("circuitCompact:", "")
|
|
: selection.rowKey.replace("reserveCircuit:", "");
|
|
createdCircuitId = createdRow;
|
|
return nextSelectionIntent();
|
|
},
|
|
undo: async () => {
|
|
if (!createdCircuitId) {
|
|
return null;
|
|
}
|
|
await deleteCircuitById(createdCircuitId);
|
|
return buildSelectionIntent({ rowKey: `placeholder:${row.sectionId}`, cellKey: editingCell.cellKey });
|
|
},
|
|
};
|
|
setEditingCell(null);
|
|
await runCommand(command);
|
|
return;
|
|
}
|
|
|
|
if (cell.kind === "circuitField" && row.circuit) {
|
|
const previous = getCurrentDraftForCell(row, editingCell.cellKey, cell.kind);
|
|
const next = editingCell.draft;
|
|
const command: HistoryCommand = {
|
|
label: "Edit circuit field",
|
|
redo: async () => {
|
|
await patchCircuit(row.circuit!.id, editingCell.cellKey, next);
|
|
return nextSelectionIntent();
|
|
},
|
|
undo: async () => {
|
|
await patchCircuit(row.circuit!.id, editingCell.cellKey, previous);
|
|
return buildSelectionIntent({ rowKey: row.rowKey, cellKey: editingCell.cellKey });
|
|
},
|
|
};
|
|
setEditingCell(null);
|
|
await runCommand(command);
|
|
return;
|
|
}
|
|
|
|
if (cell.kind === "deviceField" && row.device) {
|
|
const previous = getCurrentDraftForCell(row, editingCell.cellKey, cell.kind);
|
|
const next = editingCell.draft;
|
|
const command: HistoryCommand = {
|
|
label: "Edit device field",
|
|
redo: async () => {
|
|
await patchDeviceRow(row.device!.id, editingCell.cellKey, next);
|
|
return nextSelectionIntent();
|
|
},
|
|
undo: async () => {
|
|
await patchDeviceRow(row.device!.id, editingCell.cellKey, previous);
|
|
return buildSelectionIntent({ rowKey: `device:${row.device!.id}`, cellKey: editingCell.cellKey });
|
|
},
|
|
};
|
|
setEditingCell(null);
|
|
await runCommand(command);
|
|
return;
|
|
}
|
|
|
|
if (cell.kind === "deviceField" && row.rowType === "reserveCircuit" && row.circuit) {
|
|
const next = editingCell.draft;
|
|
let createdRowId: string | null = null;
|
|
const command: HistoryCommand = {
|
|
label: "Create reserve device row",
|
|
redo: async () => {
|
|
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 };
|
|
createdRowId = created.id;
|
|
await patchDeviceRow(created.id, editingCell.cellKey, next);
|
|
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey };
|
|
return nextSelectionIntent();
|
|
},
|
|
undo: async () => {
|
|
if (!createdRowId) {
|
|
return null;
|
|
}
|
|
await deleteCircuitDeviceRowById(createdRowId);
|
|
return buildSelectionIntent({ rowKey: `reserveCircuit:${row.circuit!.id}`, cellKey: editingCell.cellKey });
|
|
},
|
|
};
|
|
setEditingCell(null);
|
|
await runCommand(command);
|
|
}
|
|
}
|
|
|
|
// Cancels current edit session and restores grid keyboard focus.
|
|
function cancelEdit() {
|
|
if (!editingCell) {
|
|
return;
|
|
}
|
|
setEditingCell(null);
|
|
setSelectedCell({ rowKey: editingCell.rowKey, cellKey: editingCell.cellKey });
|
|
requestAnimationFrame(() => containerRef.current?.focus());
|
|
}
|
|
|
|
async function handleAddReserveCircuit(sectionId: string) {
|
|
const section = data?.sections.find((entry) => entry.id === sectionId);
|
|
if (!section) {
|
|
return;
|
|
}
|
|
let createdCircuitId: string | null = null;
|
|
await runCommand({
|
|
label: "Add circuit",
|
|
redo: async () => {
|
|
const next = await getNextCircuitIdentifier(sectionId);
|
|
const sortOrder =
|
|
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
|
|
const created = (await createCircuit(projectId, circuitListId, {
|
|
sectionId,
|
|
equipmentIdentifier: next.nextIdentifier,
|
|
displayName: "Reserve",
|
|
sortOrder,
|
|
isReserve: true,
|
|
})) as CircuitTreeCircuitDto;
|
|
createdCircuitId = created.id;
|
|
setActiveSectionId(sectionId);
|
|
return {
|
|
rowKey: `reserveCircuit:${created.id}`,
|
|
cellKey: "equipmentIdentifier",
|
|
rowType: "reserveCircuit",
|
|
sectionId,
|
|
circuitId: created.id,
|
|
};
|
|
},
|
|
undo: async () => {
|
|
if (createdCircuitId) {
|
|
await deleteCircuitById(createdCircuitId);
|
|
}
|
|
return {
|
|
rowKey: `placeholder:${sectionId}`,
|
|
cellKey: "equipmentIdentifier",
|
|
rowType: "placeholder",
|
|
sectionId,
|
|
};
|
|
},
|
|
});
|
|
}
|
|
|
|
async function handleAddManualDevice(circuit: CircuitTreeCircuitDto, sectionId: string) {
|
|
let createdRowId: string | null = null;
|
|
await runCommand({
|
|
label: "Add manual device",
|
|
redo: async () => {
|
|
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 };
|
|
createdRowId = created.id;
|
|
setActiveSectionId(sectionId);
|
|
return {
|
|
rowKey: `device:${created.id}`,
|
|
cellKey: "displayName",
|
|
rowType: "deviceRow",
|
|
sectionId,
|
|
circuitId: circuit.id,
|
|
deviceId: created.id,
|
|
};
|
|
},
|
|
undo: async () => {
|
|
if (createdRowId) {
|
|
await deleteCircuitDeviceRowById(createdRowId);
|
|
}
|
|
return {
|
|
rowKey: `reserveCircuit:${circuit.id}`,
|
|
cellKey: "displayName",
|
|
rowType: "reserveCircuit",
|
|
sectionId,
|
|
circuitId: circuit.id,
|
|
};
|
|
},
|
|
});
|
|
}
|
|
|
|
function resolvePhaseType(device: ProjectDeviceDto): string {
|
|
return device.phaseType;
|
|
}
|
|
|
|
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;
|
|
}
|
|
let createdCircuitId: string | null = null;
|
|
let createdRowId: string | null = null;
|
|
await runCommand({
|
|
label: "Add project device as new circuit",
|
|
redo: async () => {
|
|
const created = await insertProjectDeviceAsNewCircuit(device, targetSectionId);
|
|
createdCircuitId = created.circuitId;
|
|
createdRowId = created.rowId;
|
|
return {
|
|
rowKey: `device:${created.rowId}`,
|
|
cellKey: "displayName",
|
|
rowType: "deviceRow",
|
|
sectionId: targetSectionId,
|
|
circuitId: created.circuitId,
|
|
deviceId: created.rowId,
|
|
};
|
|
},
|
|
undo: async () => {
|
|
if (createdCircuitId) {
|
|
await deleteCircuitById(createdCircuitId);
|
|
}
|
|
return {
|
|
rowKey: `placeholder:${targetSectionId}`,
|
|
cellKey: "displayName",
|
|
rowType: "placeholder",
|
|
sectionId: targetSectionId,
|
|
};
|
|
},
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
let createdRowId: string | null = null;
|
|
await runCommand({
|
|
label: "Add project device to circuit",
|
|
redo: async () => {
|
|
const created = await insertProjectDeviceToCircuit(device, circuitId);
|
|
createdRowId = created.rowId;
|
|
return {
|
|
rowKey: `device:${created.rowId}`,
|
|
cellKey: "displayName",
|
|
rowType: "deviceRow",
|
|
sectionId: findCircuitSectionId(circuitId) ?? "",
|
|
circuitId,
|
|
deviceId: created.rowId,
|
|
};
|
|
},
|
|
undo: async () => {
|
|
if (createdRowId) {
|
|
await deleteCircuitDeviceRowById(createdRowId);
|
|
}
|
|
return {
|
|
rowKey: `circuitSummary:${circuitId}`,
|
|
cellKey: "displayName",
|
|
rowType: "circuitSummary",
|
|
sectionId: findCircuitSectionId(circuitId) ?? "",
|
|
circuitId,
|
|
};
|
|
},
|
|
});
|
|
}
|
|
|
|
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),
|
|
connectionKind: device.connectionKind ?? undefined,
|
|
costGroup: device.costGroup ?? undefined,
|
|
quantity: device.quantity,
|
|
powerPerUnit: device.powerPerUnit,
|
|
simultaneityFactor: device.simultaneityFactor,
|
|
cosPhi: device.cosPhi ?? undefined,
|
|
category: device.category ?? undefined,
|
|
remark: device.remark ?? undefined,
|
|
})) as { id: string };
|
|
|
|
setActiveSectionId(sectionId);
|
|
setTargetCircuitId(createdCircuit.id);
|
|
return { circuitId: createdCircuit.id, rowId: createdRow.id };
|
|
}
|
|
|
|
async function insertProjectDeviceToCircuit(device: ProjectDeviceDto, circuitId: string) {
|
|
const createdRow = (await createCircuitDeviceRow(circuitId, {
|
|
linkedProjectDeviceId: device.id,
|
|
name: device.name,
|
|
displayName: device.displayName,
|
|
phaseType: resolvePhaseType(device),
|
|
connectionKind: device.connectionKind ?? undefined,
|
|
costGroup: device.costGroup ?? undefined,
|
|
quantity: device.quantity,
|
|
powerPerUnit: device.powerPerUnit,
|
|
simultaneityFactor: device.simultaneityFactor,
|
|
cosPhi: device.cosPhi ?? undefined,
|
|
category: device.category ?? undefined,
|
|
remark: device.remark ?? undefined,
|
|
})) as { id: string };
|
|
return { rowId: createdRow.id };
|
|
}
|
|
|
|
function getCircuitSnapshot(circuitId: string): CircuitSnapshot | null {
|
|
for (const section of data?.sections ?? []) {
|
|
const found = section.circuits.find((entry) => entry.id === circuitId);
|
|
if (!found) {
|
|
continue;
|
|
}
|
|
return {
|
|
id: found.id,
|
|
sectionId: found.sectionId,
|
|
equipmentIdentifier: found.equipmentIdentifier,
|
|
displayName: found.displayName ?? undefined,
|
|
sortOrder: found.sortOrder,
|
|
protectionType: found.protectionType ?? undefined,
|
|
protectionRatedCurrent: found.protectionRatedCurrent ?? undefined,
|
|
protectionCharacteristic: found.protectionCharacteristic ?? undefined,
|
|
cableType: found.cableType ?? undefined,
|
|
cableCrossSection: found.cableCrossSection ?? undefined,
|
|
cableLength: found.cableLength ?? undefined,
|
|
rcdAssignment: found.rcdAssignment ?? undefined,
|
|
terminalDesignation: found.terminalDesignation ?? undefined,
|
|
voltage: found.voltage ?? undefined,
|
|
status: found.status ?? undefined,
|
|
isReserve: found.isReserve,
|
|
remark: found.remark ?? undefined,
|
|
deviceRows: found.deviceRows.map((row) => ({ ...row })),
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function recreateCircuit(snapshot: CircuitSnapshot) {
|
|
const createdCircuit = (await createCircuit(projectId, circuitListId, {
|
|
sectionId: snapshot.sectionId,
|
|
equipmentIdentifier: snapshot.equipmentIdentifier,
|
|
displayName: snapshot.displayName,
|
|
sortOrder: snapshot.sortOrder,
|
|
protectionType: snapshot.protectionType,
|
|
protectionRatedCurrent: snapshot.protectionRatedCurrent,
|
|
protectionCharacteristic: snapshot.protectionCharacteristic,
|
|
cableType: snapshot.cableType,
|
|
cableCrossSection: snapshot.cableCrossSection,
|
|
cableLength: snapshot.cableLength,
|
|
rcdAssignment: snapshot.rcdAssignment,
|
|
terminalDesignation: snapshot.terminalDesignation,
|
|
voltage: snapshot.voltage,
|
|
status: snapshot.status,
|
|
isReserve: snapshot.isReserve,
|
|
remark: snapshot.remark,
|
|
})) as CircuitTreeCircuitDto;
|
|
|
|
const createdRowIds: string[] = [];
|
|
for (const row of snapshot.deviceRows) {
|
|
const created = (await createCircuitDeviceRow(createdCircuit.id, {
|
|
linkedProjectDeviceId: row.linkedProjectDeviceId,
|
|
name: row.name,
|
|
displayName: row.displayName,
|
|
phaseType: row.phaseType,
|
|
connectionKind: row.connectionKind,
|
|
costGroup: row.costGroup,
|
|
category: row.category,
|
|
level: row.level,
|
|
roomId: row.roomId,
|
|
roomNumberSnapshot: row.roomNumberSnapshot,
|
|
roomNameSnapshot: row.roomNameSnapshot,
|
|
quantity: row.quantity,
|
|
powerPerUnit: row.powerPerUnit,
|
|
simultaneityFactor: row.simultaneityFactor,
|
|
cosPhi: row.cosPhi,
|
|
remark: row.remark,
|
|
overriddenFields: row.overriddenFields,
|
|
sortOrder: row.sortOrder,
|
|
})) as { id: string };
|
|
createdRowIds.push(created.id);
|
|
}
|
|
return { circuitId: createdCircuit.id, rowIds: createdRowIds };
|
|
}
|
|
|
|
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 parseDraggedDeviceRowIds(event: DragEvent<HTMLElement>) {
|
|
const raw = event.dataTransfer.getData("application/x-circuit-device-row-ids");
|
|
if (!raw) {
|
|
return [];
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
if (!Array.isArray(parsed)) {
|
|
return [];
|
|
}
|
|
return parsed.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function parseDraggedCircuitIds(event: DragEvent<HTMLElement>) {
|
|
const raw = event.dataTransfer.getData("application/x-circuit-ids");
|
|
if (!raw) {
|
|
return [];
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
if (!Array.isArray(parsed)) {
|
|
return [];
|
|
}
|
|
return parsed.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Converts current row selection to device-row ids for bulk row moves.
|
|
// Section and placeholder rows are intentionally ignored.
|
|
function getSelectedEligibleDeviceRowIds(primaryRowId?: string | null) {
|
|
const selectedSet = new Set(selectedRowKeys);
|
|
const ids: string[] = [];
|
|
for (const row of visibleRows) {
|
|
if (!selectedSet.has(row.rowKey)) {
|
|
continue;
|
|
}
|
|
if ((row.rowType === "deviceRow" || row.rowType === "circuitCompact") && row.device?.id) {
|
|
ids.push(row.device.id);
|
|
}
|
|
}
|
|
if (ids.length > 1 && primaryRowId && ids.includes(primaryRowId)) {
|
|
return ids;
|
|
}
|
|
return primaryRowId ? [primaryRowId] : ids;
|
|
}
|
|
|
|
// Converts current row selection to unique circuit ids for circuit block reorder.
|
|
function getSelectedEligibleCircuitIds(primaryCircuitId?: string | null) {
|
|
const selectedSet = new Set(selectedRowKeys);
|
|
const ids: string[] = [];
|
|
const seen = new Set<string>();
|
|
for (const row of visibleRows) {
|
|
if (!selectedSet.has(row.rowKey)) {
|
|
continue;
|
|
}
|
|
if (
|
|
(row.rowType === "circuitCompact" || row.rowType === "circuitSummary" || row.rowType === "reserveCircuit") &&
|
|
row.circuit?.id &&
|
|
!seen.has(row.circuit.id)
|
|
) {
|
|
seen.add(row.circuit.id);
|
|
ids.push(row.circuit.id);
|
|
}
|
|
}
|
|
if (ids.length > 1 && primaryCircuitId && ids.includes(primaryCircuitId)) {
|
|
return ids;
|
|
}
|
|
return primaryCircuitId ? [primaryCircuitId] : ids;
|
|
}
|
|
|
|
function findCircuitSectionId(circuitId: string) {
|
|
for (const section of data?.sections ?? []) {
|
|
if (section.circuits.some((circuit) => circuit.id === circuitId)) {
|
|
return section.id;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Applies same-section circuit reorder as explicit id ordering.
|
|
// No implicit renumbering is performed here.
|
|
async function applyCircuitReorder(intent: CircuitReorderDropIntent, sourceCircuitIds: 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 block = sourceCircuitIds.filter((id) => ids.includes(id));
|
|
if (block.length === 0) {
|
|
throw new Error("Invalid source circuit.");
|
|
}
|
|
const blockSet = new Set(block);
|
|
const nextIds = ids.filter((id) => !blockSet.has(id));
|
|
|
|
if (intent.kind === "section-end") {
|
|
nextIds.push(...block);
|
|
} 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, ...block);
|
|
}
|
|
await reorderSectionCircuits(section.id, nextIds);
|
|
}
|
|
|
|
// Handles project-device drag intent. This path creates linked rows/circuits and
|
|
// must remain separate from move handlers that operate on existing entities.
|
|
async function handleDropWithIntent(event: DragEvent<HTMLElement>, intent: ProjectDeviceDropIntent) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const draggedId = parseDraggedProjectDeviceId(event) ?? draggingProjectDeviceId;
|
|
setDropIntent(null);
|
|
setDraggingProjectDeviceId(null);
|
|
setDraggingDeviceRowIds([]);
|
|
setDraggingCircuitIds([]);
|
|
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;
|
|
}
|
|
// 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") {
|
|
let createdCircuitId: string | null = null;
|
|
await runCommand({
|
|
label: "Drop project device to new circuit",
|
|
redo: async () => {
|
|
const created = await insertProjectDeviceAsNewCircuit(device, intent.sectionId);
|
|
createdCircuitId = created.circuitId;
|
|
return {
|
|
rowKey: `device:${created.rowId}`,
|
|
cellKey: "displayName",
|
|
rowType: "deviceRow",
|
|
sectionId: intent.sectionId,
|
|
circuitId: created.circuitId,
|
|
deviceId: created.rowId,
|
|
};
|
|
},
|
|
undo: async () => {
|
|
if (createdCircuitId) {
|
|
await deleteCircuitById(createdCircuitId);
|
|
}
|
|
return null;
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
let createdRowId: string | null = null;
|
|
await runCommand({
|
|
label: "Drop project device to circuit",
|
|
redo: async () => {
|
|
const created = await insertProjectDeviceToCircuit(device, intent.circuitId);
|
|
createdRowId = created.rowId;
|
|
return {
|
|
rowKey: `device:${created.rowId}`,
|
|
cellKey: "displayName",
|
|
rowType: "deviceRow",
|
|
sectionId: intent.sectionId,
|
|
circuitId: intent.circuitId,
|
|
deviceId: created.rowId,
|
|
};
|
|
},
|
|
undo: async () => {
|
|
if (createdRowId) {
|
|
await deleteCircuitDeviceRowById(createdRowId);
|
|
}
|
|
return null;
|
|
},
|
|
});
|
|
}
|
|
|
|
// Handles device-row drag intent (single or bulk) and preserves enough source grouping
|
|
// for deterministic undo back to original circuits.
|
|
async function handleDeviceRowDropWithIntent(event: DragEvent<HTMLElement>, intent: DeviceRowMoveDropIntent) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const draggedRowIds = parseDraggedDeviceRowIds(event);
|
|
const singleRowId = parseDraggedDeviceRowId(event) ?? draggingDeviceRowId;
|
|
const rowIds = draggedRowIds.length > 0 ? draggedRowIds : singleRowId ? [singleRowId] : [];
|
|
setDeviceMoveIntent(null);
|
|
setDraggingDeviceRowId(null);
|
|
setDraggingDeviceRowIds([]);
|
|
setDraggingCircuitIds([]);
|
|
if (rowIds.length === 0) {
|
|
setError("Missing dragged device row.");
|
|
return;
|
|
}
|
|
|
|
const sourceByRowId = new Map<string, string>();
|
|
for (const rowId of rowIds) {
|
|
const sourceCircuitId = findDeviceRowCircuitId(rowId);
|
|
if (!sourceCircuitId) {
|
|
setError("Invalid dragged device row.");
|
|
return;
|
|
}
|
|
sourceByRowId.set(rowId, sourceCircuitId);
|
|
}
|
|
const sourceCircuitId = sourceByRowId.get(rowIds[0]);
|
|
if (!sourceCircuitId) {
|
|
setError("Invalid dragged device row source.");
|
|
return;
|
|
}
|
|
|
|
// Device-row drag supports bulk selection, but target intent is always explicit:
|
|
// move into existing circuit or create new circuit in a target section.
|
|
if (intent.kind === "move-to-circuit") {
|
|
if (rowIds.length === 1 && intent.circuitId === sourceCircuitId) {
|
|
return;
|
|
}
|
|
const groupedBySource = new Map<string, string[]>();
|
|
for (const rowId of rowIds) {
|
|
const source = sourceByRowId.get(rowId)!;
|
|
if (!groupedBySource.has(source)) {
|
|
groupedBySource.set(source, []);
|
|
}
|
|
groupedBySource.get(source)!.push(rowId);
|
|
}
|
|
await runCommand({
|
|
label: rowIds.length > 1 ? `Move ${rowIds.length} device rows` : "Move device row",
|
|
redo: async () => {
|
|
await moveCircuitDeviceRowsBulk({ rowIds, targetCircuitId: intent.circuitId });
|
|
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
|
|
return null;
|
|
},
|
|
undo: async () => {
|
|
for (const [source, ids] of groupedBySource) {
|
|
await moveCircuitDeviceRowsBulk({ rowIds: ids, targetCircuitId: source });
|
|
}
|
|
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
|
|
return null;
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
let createdCircuitId: string | null = null;
|
|
const groupedBySource = new Map<string, string[]>();
|
|
for (const rowId of rowIds) {
|
|
const source = sourceByRowId.get(rowId)!;
|
|
if (!groupedBySource.has(source)) {
|
|
groupedBySource.set(source, []);
|
|
}
|
|
groupedBySource.get(source)!.push(rowId);
|
|
}
|
|
await runCommand({
|
|
label: rowIds.length > 1 ? `Move ${rowIds.length} device rows to new circuit` : "Move device row to new circuit",
|
|
redo: async () => {
|
|
const moved = await moveCircuitDeviceRowsBulk({
|
|
rowIds,
|
|
targetSectionId: intent.sectionId,
|
|
createNewCircuit: true,
|
|
});
|
|
createdCircuitId = moved.createdCircuitId ?? null;
|
|
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
|
|
return null;
|
|
},
|
|
undo: async () => {
|
|
for (const [source, ids] of groupedBySource) {
|
|
await moveCircuitDeviceRowsBulk({ rowIds: ids, targetCircuitId: source });
|
|
}
|
|
if (createdCircuitId) {
|
|
await deleteCircuitById(createdCircuitId);
|
|
}
|
|
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
|
|
return null;
|
|
},
|
|
});
|
|
}
|
|
|
|
// Handles circuit drag intent and reorders whole circuit blocks within one section only.
|
|
async function handleCircuitReorderDrop(event: DragEvent<HTMLElement>, intent: CircuitReorderDropIntent) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const draggedCircuitIds = parseDraggedCircuitIds(event);
|
|
const singleCircuitId = event.dataTransfer.getData("application/x-circuit-id") || draggingCircuitId;
|
|
const sourceCircuitIds = draggedCircuitIds.length > 0 ? draggedCircuitIds : singleCircuitId ? [singleCircuitId] : [];
|
|
setCircuitReorderIntent(null);
|
|
setDraggingCircuitId(null);
|
|
setDraggingCircuitIds([]);
|
|
if (sourceCircuitIds.length === 0) {
|
|
setError("Missing dragged circuit.");
|
|
return;
|
|
}
|
|
if (!intent.valid) {
|
|
setError("Cross-section circuit move is not allowed in this phase.");
|
|
return;
|
|
}
|
|
const section = data?.sections.find((entry) => entry.id === intent.sectionId);
|
|
if (!section) {
|
|
setError("Invalid section id.");
|
|
return;
|
|
}
|
|
const sectionCircuitIds = new Set(section.circuits.map((circuit) => circuit.id));
|
|
if (sourceCircuitIds.some((id) => !sectionCircuitIds.has(id))) {
|
|
setError("Cross-section circuit move is not allowed in this phase.");
|
|
return;
|
|
}
|
|
const beforeOrder = section.circuits.map((circuit) => circuit.id);
|
|
const primaryCircuitId = sourceCircuitIds[0];
|
|
await runCommand({
|
|
label: sourceCircuitIds.length > 1 ? `Reorder ${sourceCircuitIds.length} circuits` : "Reorder circuits",
|
|
redo: async () => {
|
|
await applyCircuitReorder(intent, sourceCircuitIds);
|
|
pendingSelectedCircuitIdsAfterReload.current = sourceCircuitIds;
|
|
return {
|
|
rowKey: `circuitSummary:${primaryCircuitId}`,
|
|
cellKey: "equipmentIdentifier",
|
|
rowType: "circuitSummary",
|
|
sectionId: intent.sectionId,
|
|
circuitId: primaryCircuitId,
|
|
};
|
|
},
|
|
undo: async () => {
|
|
await reorderSectionCircuits(intent.sectionId, beforeOrder);
|
|
pendingSelectedCircuitIdsAfterReload.current = sourceCircuitIds;
|
|
return {
|
|
rowKey: `circuitSummary:${primaryCircuitId}`,
|
|
cellKey: "equipmentIdentifier",
|
|
rowType: "circuitSummary",
|
|
sectionId: intent.sectionId,
|
|
circuitId: primaryCircuitId,
|
|
};
|
|
},
|
|
});
|
|
}
|
|
|
|
// Delete undo recreates the row from captured snapshot because delete is destructive.
|
|
async function handleDeleteDevice(rowId: string) {
|
|
if (!confirm("Delete this device row?")) {
|
|
return;
|
|
}
|
|
const sourceCircuitId = findDeviceRowCircuitId(rowId);
|
|
const sourceCircuit = sourceCircuitId ? getCircuitSnapshot(sourceCircuitId) : null;
|
|
const rowSnapshot = sourceCircuit?.deviceRows.find((row) => row.id === rowId);
|
|
if (!sourceCircuitId || !rowSnapshot) {
|
|
setError("Invalid device row id.");
|
|
return;
|
|
}
|
|
let recreatedRowId: string | null = null;
|
|
await runCommand({
|
|
label: "Delete device row",
|
|
redo: async () => {
|
|
await deleteCircuitDeviceRowById(recreatedRowId ?? rowId);
|
|
return null;
|
|
},
|
|
undo: async () => {
|
|
// Recreate instead of "undelete": backend ids are immutable once deleted.
|
|
const created = (await createCircuitDeviceRow(sourceCircuitId, {
|
|
linkedProjectDeviceId: rowSnapshot.linkedProjectDeviceId,
|
|
name: rowSnapshot.name,
|
|
displayName: rowSnapshot.displayName,
|
|
phaseType: rowSnapshot.phaseType,
|
|
connectionKind: rowSnapshot.connectionKind,
|
|
costGroup: rowSnapshot.costGroup,
|
|
category: rowSnapshot.category,
|
|
level: rowSnapshot.level,
|
|
roomId: rowSnapshot.roomId,
|
|
roomNumberSnapshot: rowSnapshot.roomNumberSnapshot,
|
|
roomNameSnapshot: rowSnapshot.roomNameSnapshot,
|
|
quantity: rowSnapshot.quantity,
|
|
powerPerUnit: rowSnapshot.powerPerUnit,
|
|
simultaneityFactor: rowSnapshot.simultaneityFactor,
|
|
cosPhi: rowSnapshot.cosPhi,
|
|
remark: rowSnapshot.remark,
|
|
overriddenFields: rowSnapshot.overriddenFields,
|
|
sortOrder: rowSnapshot.sortOrder,
|
|
})) as { id: string };
|
|
recreatedRowId = created.id;
|
|
return null;
|
|
},
|
|
});
|
|
}
|
|
|
|
// Deletes a circuit and all child rows; undo recreates from captured snapshot.
|
|
async function handleDeleteCircuit(circuitId: string) {
|
|
if (!confirm("Delete this circuit and all assigned device rows?")) {
|
|
return;
|
|
}
|
|
const snapshot = getCircuitSnapshot(circuitId);
|
|
if (!snapshot) {
|
|
setError("Invalid circuit id.");
|
|
return;
|
|
}
|
|
let recreatedCircuitId: string | null = null;
|
|
await runCommand({
|
|
label: "Delete circuit",
|
|
redo: async () => {
|
|
await deleteCircuitById(recreatedCircuitId ?? circuitId);
|
|
return null;
|
|
},
|
|
undo: async () => {
|
|
const recreated = await recreateCircuit(snapshot);
|
|
recreatedCircuitId = recreated.circuitId;
|
|
return null;
|
|
},
|
|
});
|
|
}
|
|
|
|
// Explicit renumber action. Undo restores previous BMKs via safe section identifier update.
|
|
async function handleRenumberSection(sectionId: string) {
|
|
if (!confirm("Renumber this section? Only circuits in this section will change.")) {
|
|
return;
|
|
}
|
|
const section = data?.sections.find((entry) => entry.id === sectionId);
|
|
if (!section) {
|
|
return;
|
|
}
|
|
const before = section.circuits.map((circuit) => ({
|
|
id: circuit.id,
|
|
equipmentIdentifier: circuit.equipmentIdentifier,
|
|
}));
|
|
await runCommand({
|
|
label: "Renumber section",
|
|
redo: async () => {
|
|
await renumberCircuitSection(sectionId);
|
|
return null;
|
|
},
|
|
undo: async () => {
|
|
// Restore prior BMKs through safe bulk endpoint to avoid transient unique collisions.
|
|
await updateSectionEquipmentIdentifiers(
|
|
sectionId,
|
|
before.map((entry) => ({
|
|
circuitId: entry.id,
|
|
equipmentIdentifier: entry.equipmentIdentifier,
|
|
}))
|
|
);
|
|
return null;
|
|
},
|
|
});
|
|
}
|
|
|
|
// Keeps container-level keyboard focus model valid when focus returns from toolbar/popovers.
|
|
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]);
|
|
}
|
|
}
|
|
|
|
// Spreadsheet-like keyboard dispatcher:
|
|
// - Enter/F2 starts edit with current value selected
|
|
// - printable key starts overwrite edit (type-to-edit)
|
|
// - Arrow/Tab move selectedCell when not editing
|
|
function handleContainerKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
|
if (editingCell) {
|
|
if (event.key === "Tab") {
|
|
event.preventDefault();
|
|
}
|
|
return;
|
|
}
|
|
if (event.key === "Escape") {
|
|
if (selectedRowKeys.length > 0) {
|
|
event.preventDefault();
|
|
setSelectedRowKeys([]);
|
|
setAnchorRowKey(null);
|
|
}
|
|
return;
|
|
}
|
|
if (event.ctrlKey && event.key.toLowerCase() === "z") {
|
|
event.preventDefault();
|
|
if (event.shiftKey) {
|
|
void handleRedo();
|
|
} else {
|
|
void handleUndo();
|
|
}
|
|
return;
|
|
}
|
|
if (event.ctrlKey && event.key.toLowerCase() === "y") {
|
|
event.preventDefault();
|
|
void handleRedo();
|
|
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) {
|
|
// First printable key should become draft content directly; do not pre-select old text.
|
|
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>;
|
|
}
|
|
if (filteredSortedSections.length === 0) {
|
|
return (
|
|
<div className="tree-editor-shell">
|
|
<div className="editor-toolbar">
|
|
<button type="button" onClick={() => void handleUndo()} disabled={undoStack.length === 0 || historyBusy || isSaving}>
|
|
Undo
|
|
</button>
|
|
<button type="button" onClick={() => void handleRedo()} disabled={redoStack.length === 0 || historyBusy || isSaving}>
|
|
Redo
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setSortState(null);
|
|
setColumnFilters({});
|
|
setOpenFilterColumn(null);
|
|
}}
|
|
disabled={!Boolean(sortState) && !hasActiveFilters}
|
|
>
|
|
Clear sort/filter
|
|
</button>
|
|
<button type="button" onClick={() => setIsColumnMenuOpen((current) => !current)}>
|
|
Columns
|
|
</button>
|
|
{isColumnMenuOpen ? (
|
|
<div className="column-settings-menu">
|
|
<div className="column-settings-actions">
|
|
<button type="button" onClick={() => resetColumns()}>
|
|
Reset columns
|
|
</button>
|
|
</div>
|
|
<div className="column-settings-list">
|
|
{orderedColumns.map((column) => {
|
|
const visible = visibleColumnKeys.includes(column.key);
|
|
return (
|
|
<div
|
|
key={`col-empty-${column.key}`}
|
|
className={`column-settings-item ${draggingColumnKey === column.key ? "dragging" : ""} ${columnDropTargetKey === column.key ? "drop-target" : ""} ${column.locked ? "locked" : ""}`}
|
|
draggable={!column.locked}
|
|
onDragStart={(event) => handleColumnDragStart(event, column.key)}
|
|
onDragOver={(event) => handleColumnDragOver(event, column.key)}
|
|
onDrop={(event) => handleColumnDrop(event, column.key)}
|
|
onDragEnd={() => handleColumnDragEnd()}
|
|
>
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={visible}
|
|
disabled={column.locked}
|
|
onChange={() => toggleColumnVisibility(column.key)}
|
|
/>
|
|
<span>{column.label}</span>
|
|
</label>
|
|
<div className="column-settings-order">
|
|
<button type="button" onClick={() => moveColumn(column.key, -1)}>
|
|
↑
|
|
</button>
|
|
<button type="button" onClick={() => moveColumn(column.key, 1)}>
|
|
↓
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div className="notice muted">No matching circuits for active filters.</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isSortedView = Boolean(sortState);
|
|
// Renumbering is intentionally blocked while sort/filter overlays are active so users
|
|
// cannot renumber against a transformed view that has not been persisted yet.
|
|
const hasActiveSortOrFilter = isSortedView || hasActiveFilters;
|
|
const draggingDeviceCount = draggingDeviceRowIds.length > 0 ? draggingDeviceRowIds.length : draggingDeviceRowId ? 1 : 0;
|
|
const activeDraggedCircuitIds =
|
|
draggingCircuitIds.length > 0 ? draggingCircuitIds : draggingCircuitId ? [draggingCircuitId] : [];
|
|
const draggingCircuitCount = activeDraggedCircuitIds.length;
|
|
|
|
return (
|
|
<div className="tree-editor-shell">
|
|
<div className="editor-toolbar">
|
|
<button type="button" onClick={() => void handleUndo()} disabled={undoStack.length === 0 || historyBusy || isSaving}>
|
|
Undo
|
|
</button>
|
|
<button type="button" onClick={() => void handleRedo()} disabled={redoStack.length === 0 || historyBusy || isSaving}>
|
|
Redo
|
|
</button>
|
|
{isSortedView ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleApplySortedOrder()}
|
|
disabled={hasActiveFilters || isSaving || historyBusy}
|
|
title={hasActiveFilters ? "Clear filters first to apply sorted order." : undefined}
|
|
>
|
|
Apply sorted order
|
|
</button>
|
|
) : null}
|
|
<button type="button" onClick={() => setIsColumnMenuOpen((current) => !current)}>
|
|
Columns
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setSortState(null);
|
|
setColumnFilters({});
|
|
setOpenFilterColumn(null);
|
|
}}
|
|
disabled={!isSortedView && !hasActiveFilters}
|
|
>
|
|
Clear sort/filter
|
|
</button>
|
|
{isColumnMenuOpen ? (
|
|
<div className="column-settings-menu">
|
|
<div className="column-settings-actions">
|
|
<button type="button" onClick={() => resetColumns()}>
|
|
Reset columns
|
|
</button>
|
|
</div>
|
|
<div className="column-settings-list">
|
|
{orderedColumns.map((column) => {
|
|
const visible = visibleColumnKeys.includes(column.key);
|
|
return (
|
|
<div
|
|
key={`col-${column.key}`}
|
|
className={`column-settings-item ${draggingColumnKey === column.key ? "dragging" : ""} ${columnDropTargetKey === column.key ? "drop-target" : ""} ${column.locked ? "locked" : ""}`}
|
|
draggable={!column.locked}
|
|
onDragStart={(event) => handleColumnDragStart(event, column.key)}
|
|
onDragOver={(event) => handleColumnDragOver(event, column.key)}
|
|
onDrop={(event) => handleColumnDrop(event, column.key)}
|
|
onDragEnd={() => handleColumnDragEnd()}
|
|
>
|
|
<label>
|
|
<input
|
|
type="checkbox"
|
|
checked={visible}
|
|
disabled={column.locked}
|
|
onChange={() => toggleColumnVisibility(column.key)}
|
|
/>
|
|
<span>{column.label}</span>
|
|
</label>
|
|
<div className="column-settings-order">
|
|
<button type="button" onClick={() => moveColumn(column.key, -1)}>
|
|
↑
|
|
</button>
|
|
<button type="button" onClick={() => moveColumn(column.key, 1)}>
|
|
↓
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
{hasActiveSortOrFilter ? (
|
|
<div className="notice muted">Sorting/filtering is view-only. Renumber is disabled while sort/filter is active.</div>
|
|
) : null}
|
|
{isSortedView && hasActiveFilters ? (
|
|
<div className="notice muted">Apply sorted order is disabled while filters are active.</div>
|
|
) : null}
|
|
{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);
|
|
setDraggingDeviceRowIds([]);
|
|
setDraggingCircuitId(null);
|
|
setDraggingCircuitIds([]);
|
|
setDeviceMoveIntent(null);
|
|
setCircuitReorderIntent(null);
|
|
event.dataTransfer.effectAllowed = "copy";
|
|
event.dataTransfer.setData("application/x-project-device-id", device.id);
|
|
}}
|
|
onDragEnd={() => {
|
|
setDraggingProjectDeviceId(null);
|
|
setDropIntent(null);
|
|
setDraggingDeviceRowIds([]);
|
|
setDraggingCircuitIds([]);
|
|
}}
|
|
>
|
|
<strong>{device.displayName || device.name}</strong>
|
|
<span>Name: {device.name}</span>
|
|
<span>Phase: {device.phaseType}</span>
|
|
<span>Qty: {device.quantity}</span>
|
|
<span>P/unit: {device.powerPerUnit}</span>
|
|
<span>g: {device.simultaneityFactor}</span>
|
|
<span>Total: {device.totalPower}</span>
|
|
<span>Cost group: {device.costGroup || "-"}</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}
|
|
onMouseDown={(event) => {
|
|
if (event.target === event.currentTarget) {
|
|
setSelectedRowKeys([]);
|
|
setAnchorRowKey(null);
|
|
}
|
|
}}
|
|
>
|
|
<table className="tree-grid">
|
|
<thead>
|
|
<tr>
|
|
{visibleColumns.map((column) => (
|
|
<th key={column.key} className={column.numeric ? "num" : ""}>
|
|
<div className="header-cell">
|
|
<button
|
|
type="button"
|
|
className="header-sort-btn"
|
|
onClick={() => {
|
|
setSortState((current) => {
|
|
if (!current || current.key !== column.key) {
|
|
return { key: column.key, direction: "asc" };
|
|
}
|
|
if (current.direction === "asc") {
|
|
return { key: column.key, direction: "desc" };
|
|
}
|
|
return null;
|
|
});
|
|
}}
|
|
>
|
|
{column.label}
|
|
{sortState?.key === column.key ? (sortState.direction === "asc" ? " ↑" : " ↓") : ""}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`header-filter-btn ${(columnFilters[column.key]?.length ?? 0) > 0 ? "active" : ""}`}
|
|
onClick={() => setOpenFilterColumn((current) => (current === column.key ? null : column.key))}
|
|
>
|
|
Filter
|
|
</button>
|
|
</div>
|
|
{openFilterColumn === column.key ? (
|
|
<div className="header-filter-menu">
|
|
<button
|
|
type="button"
|
|
className="header-filter-clear"
|
|
onClick={() =>
|
|
setColumnFilters((current) => ({
|
|
...current,
|
|
[column.key]: [],
|
|
}))
|
|
}
|
|
>
|
|
Clear
|
|
</button>
|
|
<div className="header-filter-values">
|
|
{distinctValuesByColumn[column.key].map((value) => {
|
|
const selected = columnFilters[column.key] ?? [];
|
|
const checked = selected.includes(value);
|
|
return (
|
|
<label key={`${column.key}-${value}`} className="header-filter-item">
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
onChange={(event) => {
|
|
setColumnFilters((current) => {
|
|
const previous = current[column.key] ?? [];
|
|
const next = event.target.checked
|
|
? [...previous, value]
|
|
: previous.filter((entry) => entry !== value);
|
|
return { ...current, [column.key]: next };
|
|
});
|
|
}}
|
|
/>
|
|
<span>{value}</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</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 (draggingCircuitCount > 0) {
|
|
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 (draggingCircuitCount > 0) {
|
|
void handleCircuitReorderDrop(event, {
|
|
kind: "section-end",
|
|
sectionId: section.id,
|
|
valid: false,
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
<td colSpan={visibleColumns.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}
|
|
disabled={hasActiveSortOrFilter}
|
|
onClick={() => void handleRenumberSection(section.id)}
|
|
title={hasActiveSortOrFilter ? "Clear sort/filter first to renumber safely." : undefined}
|
|
>
|
|
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 &&
|
|
((draggingCircuitIds.length > 0 && draggingCircuitIds.includes(row.circuit.id)) ||
|
|
(draggingCircuitIds.length === 0 && draggingCircuitId && row.circuit.id === draggingCircuitId))
|
|
? "circuit-dragging-block"
|
|
: ""
|
|
} ${selectedRowKeys.includes(row.rowKey) ? "row-selected" : ""}
|
|
}`}
|
|
onClick={() => setActiveSectionId(row.sectionId)}
|
|
onDragOver={(event) => {
|
|
if (draggingCircuitCount > 0) {
|
|
const sourceSectionIds = activeDraggedCircuitIds
|
|
.map((id) => findCircuitSectionId(id))
|
|
.filter((id): id is string => Boolean(id));
|
|
const valid = sourceSectionIds.length > 0 && sourceSectionIds.every((sectionId) => sectionId === row.sectionId);
|
|
if (row.rowType === "placeholder") {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = valid ? "move" : "none";
|
|
setCircuitReorderIntent({ kind: "section-end", sectionId: row.sectionId, valid });
|
|
return;
|
|
}
|
|
if (row.circuit && row.rowType !== "deviceRow") {
|
|
if (activeDraggedCircuitIds.includes(row.circuit.id)) {
|
|
setCircuitReorderIntent(null);
|
|
return;
|
|
}
|
|
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 (draggingDeviceCount > 0) {
|
|
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 sourceCircuitIds = (draggingDeviceRowIds.length > 0 ? draggingDeviceRowIds : draggingDeviceRowId ? [draggingDeviceRowId] : [])
|
|
.map((id) => findDeviceRowCircuitId(id))
|
|
.filter((id): id is string => Boolean(id));
|
|
if (sourceCircuitIds.some((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 (draggingCircuitCount > 0) {
|
|
if (row.rowType === "placeholder") {
|
|
const sourceSectionIds = activeDraggedCircuitIds
|
|
.map((id) => findCircuitSectionId(id))
|
|
.filter((id): id is string => Boolean(id));
|
|
void handleCircuitReorderDrop(event, {
|
|
kind: "section-end",
|
|
sectionId: row.sectionId,
|
|
valid: sourceSectionIds.length > 0 && sourceSectionIds.every((sectionId) => sectionId === row.sectionId),
|
|
});
|
|
return;
|
|
}
|
|
if (row.circuit && row.rowType !== "deviceRow") {
|
|
if (activeDraggedCircuitIds.includes(row.circuit.id)) {
|
|
return;
|
|
}
|
|
const sourceSectionIds = activeDraggedCircuitIds
|
|
.map((id) => findCircuitSectionId(id))
|
|
.filter((id): id is string => Boolean(id));
|
|
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: sourceSectionIds.length > 0 && sourceSectionIds.every((sectionId) => sectionId === 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 (draggingDeviceCount > 0) {
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
{visibleColumns.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"
|
|
: ""
|
|
} ${
|
|
row.device?.id &&
|
|
((draggingDeviceRowIds.length > 0 && draggingDeviceRowIds.includes(row.device.id)) ||
|
|
(draggingDeviceRowIds.length === 0 && draggingDeviceRowId === row.device.id))
|
|
? "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")
|
|
) {
|
|
const circuitIds = getSelectedEligibleCircuitIds(row.circuit.id);
|
|
setDraggingProjectDeviceId(null);
|
|
setDropIntent(null);
|
|
setDraggingDeviceRowId(null);
|
|
setDraggingDeviceRowIds([]);
|
|
setDeviceMoveIntent(null);
|
|
setDraggingCircuitId(row.circuit.id);
|
|
setDraggingCircuitIds(circuitIds);
|
|
setCircuitReorderIntent(null);
|
|
event.dataTransfer.effectAllowed = "move";
|
|
event.dataTransfer.setData("application/x-circuit-id", row.circuit.id);
|
|
event.dataTransfer.setData("application/x-circuit-ids", JSON.stringify(circuitIds));
|
|
return;
|
|
}
|
|
if (
|
|
row.device &&
|
|
column.key === "displayName" &&
|
|
(row.rowType === "deviceRow" || row.rowType === "circuitCompact")
|
|
) {
|
|
const rowIds = getSelectedEligibleDeviceRowIds(row.device.id);
|
|
setDraggingProjectDeviceId(null);
|
|
setDropIntent(null);
|
|
setDraggingCircuitId(null);
|
|
setDraggingCircuitIds([]);
|
|
setCircuitReorderIntent(null);
|
|
setDraggingDeviceRowId(row.device.id);
|
|
setDraggingDeviceRowIds(rowIds);
|
|
setDeviceMoveIntent(null);
|
|
event.dataTransfer.effectAllowed = "move";
|
|
event.dataTransfer.setData("application/x-circuit-device-row-id", row.device.id);
|
|
event.dataTransfer.setData("application/x-circuit-device-row-ids", JSON.stringify(rowIds));
|
|
}
|
|
}}
|
|
onDragEnd={() => {
|
|
setDraggingDeviceRowId(null);
|
|
setDraggingDeviceRowIds([]);
|
|
setDeviceMoveIntent(null);
|
|
setDraggingCircuitId(null);
|
|
setDraggingCircuitIds([]);
|
|
setCircuitReorderIntent(null);
|
|
}}
|
|
onClick={(event) => {
|
|
if (cell.editable) {
|
|
handleRowSelectionClick(row, column.key, {
|
|
ctrlKey: event.ctrlKey,
|
|
metaKey: event.metaKey,
|
|
shiftKey: event.shiftKey,
|
|
});
|
|
}
|
|
}}
|
|
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 ${draggingDeviceCount || 1} device${draggingDeviceCount === 1 ? "" : "s"} to new circuit`}</span>
|
|
) : null}
|
|
{deviceMoveIntent?.kind === "move-to-circuit" && deviceMoveIntent.circuitId === row.circuit?.id ? (
|
|
<span className="drop-hint">{`move ${draggingDeviceCount || 1} device${draggingDeviceCount === 1 ? "" : "s"} to this circuit`}</span>
|
|
) : null}
|
|
{circuitReorderIntent?.kind === "section-end" &&
|
|
row.rowType === "placeholder" &&
|
|
circuitReorderIntent.sectionId === row.sectionId ? (
|
|
<span className="drop-hint">
|
|
{circuitReorderIntent.valid
|
|
? `move ${draggingCircuitCount || 1} circuit${draggingCircuitCount === 1 ? "" : "s"} 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 ${draggingCircuitCount || 1} circuit${draggingCircuitCount === 1 ? "" : "s"} before this circuit`
|
|
: `move ${draggingCircuitCount || 1} circuit${draggingCircuitCount === 1 ? "" : "s"} 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>
|
|
);
|
|
}
|