"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; undo: () => Promise; } 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([ "quantity", "powerPerUnit", "simultaneityFactor", "rowTotalPower", "roomSummary", "technicalName", "connectionKind", "phaseType", "costGroup", "category", "level", "roomNumberSnapshot", "roomNameSnapshot", "cosPhi", ]); const circuitOnlyColumns = new Set([ "equipmentIdentifier", "protectionSummary", "cableSummary", "circuitTotalPower", "protectionType", "protectionRatedCurrent", "protectionCharacteristic", "cableType", "cableCrossSection", "cableLength", "rcdAssignment", "terminalDesignation", "status", "isReserve", ]); const deviceFieldKeys = new Set([ "displayName", "technicalName", "connectionKind", "phaseType", "costGroup", "category", "level", "roomSummary", "roomNumberSnapshot", "roomNameSnapshot", "quantity", "powerPerUnit", "simultaneityFactor", "cosPhi", "remark", ]); const circuitFieldKeys = new Set([ "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) { 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(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); // selectedCell is spreadsheet-style keyboard focus target within normalized grid. // It can exist without an active edit input. const [selectedCell, setSelectedCell] = useState(null); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [anchorRowKey, setAnchorRowKey] = useState(null); // editingCell is mounted input session state (draft + mode + focus token). // It should only exist for currently editable grid cells. const [editingCell, setEditingCell] = useState(null); const [activeSectionId, setActiveSectionId] = useState(null); const [isSaving, setIsSaving] = useState(false); const [projectDevices, setProjectDevices] = useState([]); const [projectDeviceSearch, setProjectDeviceSearch] = useState(""); const [selectedProjectDeviceId, setSelectedProjectDeviceId] = useState(null); const [targetSectionId, setTargetSectionId] = useState(null); const [targetCircuitId, setTargetCircuitId] = useState(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(null); const [dropIntent, setDropIntent] = useState(null); const [draggingDeviceRowId, setDraggingDeviceRowId] = useState(null); const [draggingDeviceRowIds, setDraggingDeviceRowIds] = useState([]); const [deviceMoveIntent, setDeviceMoveIntent] = useState(null); const [draggingCircuitId, setDraggingCircuitId] = useState(null); const [draggingCircuitIds, setDraggingCircuitIds] = useState([]); const [circuitReorderIntent, setCircuitReorderIntent] = useState(null); // Undo/redo history is session-local UI state (not persisted server-side). const [undoStack, setUndoStack] = useState([]); const [redoStack, setRedoStack] = useState([]); const [historyBusy, setHistoryBusy] = useState(false); const [sortState, setSortState] = useState<{ key: CellKey; direction: SortDirection } | null>(null); const [columnFilters, setColumnFilters] = useState>>({}); const [openFilterColumn, setOpenFilterColumn] = useState(null); const [visibleColumnKeys, setVisibleColumnKeys] = useState(defaultVisibleColumnKeys); const [columnOrder, setColumnOrder] = useState(allColumns.map((column) => column.key)); const [isColumnMenuOpen, setIsColumnMenuOpen] = useState(false); const [draggingColumnKey, setDraggingColumnKey] = useState(null); const [columnDropTargetKey, setColumnDropTargetKey] = useState(null); const [pendingFocus, setPendingFocus] = useState(null); const pendingSelectionAfterReload = useRef(null); const pendingSelectedDeviceRowIdsAfterReload = useRef(null); const pendingSelectedCircuitIdsAfterReload = useRef(null); const containerRef = useRef(null); const inputRef = useRef(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> = {}; 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; for (const column of visibleColumns) { const set = new Set(); 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(); 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(); 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, 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, targetKey: CellKey) { if (!draggingColumnKey || targetKey === "equipmentIdentifier" || draggingColumnKey === targetKey) { return; } event.preventDefault(); event.dataTransfer.dropEffect = "move"; setColumnDropTargetKey(targetKey); } function handleColumnDrop(event: DragEvent, 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 = {}; 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 = {}; 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 { 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) { return event.dataTransfer.getData("application/x-project-device-id") || null; } function parseDraggedDeviceRowId(event: DragEvent) { return event.dataTransfer.getData("application/x-circuit-device-row-id") || null; } function parseDraggedDeviceRowIds(event: DragEvent) { 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) { 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(); 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, 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, 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(); 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(); 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(); 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, 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) { 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
Loading circuit tree editor...
; } if (error) { return
{error}
; } if (!data || data.sections.length === 0) { return
No sections/circuits available.
; } if (filteredSortedSections.length === 0) { return (
{isColumnMenuOpen ? (
{orderedColumns.map((column) => { const visible = visibleColumnKeys.includes(column.key); return (
handleColumnDragStart(event, column.key)} onDragOver={(event) => handleColumnDragOver(event, column.key)} onDrop={(event) => handleColumnDrop(event, column.key)} onDragEnd={() => handleColumnDragEnd()} >
); })}
) : null}
No matching circuits for active filters.
); } 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 (
{isSortedView ? ( ) : null} {isColumnMenuOpen ? (
{orderedColumns.map((column) => { const visible = visibleColumnKeys.includes(column.key); return (
handleColumnDragStart(event, column.key)} onDragOver={(event) => handleColumnDragOver(event, column.key)} onDrop={(event) => handleColumnDrop(event, column.key)} onDragEnd={() => handleColumnDragEnd()} >
); })}
) : null}
{hasActiveSortOrFilter ? (
Sorting/filtering is view-only. Renumber is disabled while sort/filter is active.
) : null} {isSortedView && hasActiveFilters ? (
Apply sorted order is disabled while filters are active.
) : null} {isSaving ?
Saving...
: null}
{ if (event.target === event.currentTarget) { setSelectedRowKeys([]); setAnchorRowKey(null); } }} > {visibleColumns.map((column) => ( ))} {visibleRows.map((row) => { if (row.rowType === "section") { const section = data.sections.find((entry) => entry.id === row.sectionId)!; return ( { 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, }); } }} > ); } return ( 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 ( ); })} ); })}
{openFilterColumn === column.key ? (
{distinctValuesByColumn[column.key].map((value) => { const selected = columnFilters[column.key] ?? []; const checked = selected.includes(value); return ( ); })}
) : null}
Actions
{section.displayName}
{dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? ( drop here to create new circuit ) : null}
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 ? ( 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) )} {row.circuit && row.rowType !== "deviceRow" ? ( <> ) : null} {row.device ? ( ) : null} {dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? ( new circuit in this section ) : null} {dropIntent?.kind === "add-to-circuit" && dropIntent.circuitId === row.circuit?.id ? ( add to this circuit ) : null} {deviceMoveIntent?.kind === "move-to-new-circuit" && row.rowType === "placeholder" && deviceMoveIntent.sectionId === row.sectionId ? ( {`move ${draggingDeviceCount || 1} device${draggingDeviceCount === 1 ? "" : "s"} to new circuit`} ) : null} {deviceMoveIntent?.kind === "move-to-circuit" && deviceMoveIntent.circuitId === row.circuit?.id ? ( {`move ${draggingDeviceCount || 1} device${draggingDeviceCount === 1 ? "" : "s"} to this circuit`} ) : null} {circuitReorderIntent?.kind === "section-end" && row.rowType === "placeholder" && circuitReorderIntent.sectionId === row.sectionId ? ( {circuitReorderIntent.valid ? `move ${draggingCircuitCount || 1} circuit${draggingCircuitCount === 1 ? "" : "s"} to section end` : "cross-section move not allowed"} ) : null} {circuitReorderIntent && (circuitReorderIntent.kind === "before-circuit" || circuitReorderIntent.kind === "after-circuit") && circuitReorderIntent.targetCircuitId === row.circuit?.id ? ( {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"} ) : null}

TODO Phase: Ctrl+Plus currently adds circuit at end of active section.

); }