leistungsbilanz-ts/src/frontend/components/circuit-tree-editor.tsx

3360 lines
131 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
import {
inferProjectDeviceSectionKey,
isProjectDevicePlacementValid,
} from "../../domain/services/project-device-placement.service";
import {
getInsertionSortOrder,
resolveGridInsertionIntent,
} from "../utils/circuit-grid-insertion";
import {
findDuplicateEquipmentIdentifierCircuitIds,
hasEquipmentIdentifierConflict,
requiresCrossSectionMoveConfirmation,
resolveGridDeleteIntent,
} from "../utils/circuit-grid-safety";
import {
allColumns,
defaultVisibleColumnKeys,
deviceFieldKeys,
formatValue,
getCircuitValue,
getDeviceValue,
parseNumeric,
} from "../utils/circuit-grid-model";
import type {
CellKey,
CellKind,
ColumnDef,
RowType,
} from "../utils/circuit-grid-model";
import {
buildVisibleGridRows,
filterAndSortCircuitSections,
getDistinctFilterValues,
} from "../utils/circuit-grid-projection";
import type { VisibleGridRow } from "../utils/circuit-grid-projection";
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 SaveDirection = "stay" | "next" | "prev";
type StartEditMode = "selectExisting" | "replaceWithTypedChar";
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 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;
controlRequirement?: string;
status?: string;
isReserve: boolean;
remark?: string;
deviceRows: CircuitTreeDeviceRowDto[];
}
type ProjectDeviceDropIntent =
| { kind: "new-circuit"; sectionId: string; valid: boolean }
| { kind: "add-to-circuit"; circuitId: string; sectionId: string; valid: boolean };
type DeviceRowMoveDropIntent =
| { kind: "move-to-circuit"; circuitId: string; sectionId: string; requiresConfirmation: boolean }
| { kind: "move-to-new-circuit"; sectionId: string; requiresConfirmation: boolean };
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";
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 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 activeColumnFilters = useMemo(
() =>
visibleColumns.flatMap((column) => {
const values = columnFilters[column.key] ?? [];
return values.length > 0 ? [{ column, values }] : [];
}),
[columnFilters, visibleColumns]
);
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.
return getDistinctFilterValues(data?.sections ?? [], visibleColumns);
}, [data, visibleColumns]);
const filteredSortedSections = useMemo(() => {
// Filtering and sorting are frontend view state. Backend sort order remains unchanged
// until user explicitly applies sorted order.
return filterAndSortCircuitSections(data?.sections ?? [], columnFilters, sortState);
}, [data, columnFilters, 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(() => {
return buildVisibleGridRows(filteredSortedSections);
}, [filteredSortedSections]);
function clearColumnFilter(key: CellKey) {
setColumnFilters((current) => {
const next = { ...current };
delete next[key];
return next;
});
setOpenFilterColumn((current) => (current === key ? null : current));
}
function clearSortAndFilters() {
setSortState(null);
setColumnFilters({});
setOpenFilterColumn(null);
}
function renderActiveViewSummary() {
if (!sortState && activeColumnFilters.length === 0) {
return null;
}
const sortedColumn = sortState
? allColumns.find((column) => column.key === sortState.key)
: undefined;
return (
<div className="active-view-summary" aria-label="Active sort and filters">
<span className="active-view-label">Active view</span>
{sortState ? (
<button
type="button"
className="active-view-chip"
onClick={() => setSortState(null)}
title="Remove sorting"
>
Sort: {sortedColumn?.label ?? sortState.key} ({sortState.direction === "asc" ? "ascending" : "descending"})
<span aria-hidden="true"> ×</span>
</button>
) : null}
{activeColumnFilters.map(({ column, values }) => {
const shownValues = values.slice(0, 2).join(", ");
const valueSummary = values.length > 2 ? `${shownValues} +${values.length - 2}` : shownValues;
return (
<button
key={`active-filter-${column.key}`}
type="button"
className="active-view-chip"
onClick={() => clearColumnFilter(column.key)}
title={`Remove filter for ${column.label}`}
>
{column.label}: {valueSummary}
<span aria-hidden="true"> ×</span>
</button>
);
})}
<button type="button" className="active-view-reset" onClick={clearSortAndFilters}>
Reset view
</button>
</div>
);
}
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]);
const allCircuits = useMemo(
() => data?.sections.flatMap((section) => section.circuits) ?? [],
[data]
);
const duplicateEquipmentIdentifierCircuitIds = useMemo(
() => new Set(findDuplicateEquipmentIdentifierCircuitIds(allCircuits)),
[allCircuits]
);
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 });
focusGridWithoutScroll();
}
// 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) {
focusGridWithoutScroll();
}
}
// 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);
}
focusGridWithoutScroll();
}, [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);
}
function focusGridWithoutScroll() {
requestAnimationFrame(() => containerRef.current?.focus({ preventScroll: true }));
}
// 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", "voltage"].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;
}
if (
editingCell.cellKey === "equipmentIdentifier" &&
row.circuit &&
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, editingCell.draft)
) {
setError("Dieses Betriebsmittelkennzeichen ist bereits vorhanden. Die Änderung wurde nicht gespeichert.");
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 });
focusGridWithoutScroll();
}
async function handleAddReserveCircuit(sectionId: string, afterCircuitId?: 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 = getInsertionSortOrder(section.circuits, afterCircuitId);
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,
afterDeviceRowId?: string
) {
const originalDeviceCount = circuit.deviceRows.length;
const section = data?.sections.find((entry) => entry.id === sectionId);
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: section?.key === "three_phase" ? "three_phase" : "single_phase",
quantity: 1,
powerPerUnit: 0,
simultaneityFactor: 1,
cosPhi: 1,
sortOrder: getInsertionSortOrder(circuit.deviceRows, afterDeviceRowId),
})) as { id: string };
createdRowId = created.id;
setActiveSectionId(sectionId);
return {
rowKey: originalDeviceCount === 0 ? `circuitCompact:${circuit.id}` : `device:${created.id}`,
cellKey: "displayName",
rowType: originalDeviceCount === 0 ? "circuitCompact" : "deviceRow",
sectionId,
circuitId: circuit.id,
deviceId: created.id,
};
},
undo: async () => {
if (createdRowId) {
await deleteCircuitDeviceRowById(createdRowId);
}
const rowKey =
originalDeviceCount === 0
? `reserveCircuit:${circuit.id}`
: originalDeviceCount === 1
? `circuitCompact:${circuit.id}`
: afterDeviceRowId
? `device:${afterDeviceRowId}`
: `circuitSummary:${circuit.id}`;
return {
rowKey,
cellKey: "displayName",
rowType:
originalDeviceCount === 0
? "reserveCircuit"
: originalDeviceCount === 1
? "circuitCompact"
: afterDeviceRowId
? "deviceRow"
: "circuitSummary",
sectionId,
circuitId: circuit.id,
deviceId: afterDeviceRowId,
};
},
});
}
async function handleInsertBelowSelection() {
const row = selectedCell ? findRow(selectedCell.rowKey) : null;
const cell = row?.cells.find((entry) => entry.cellKey === selectedCell?.cellKey);
const selection =
row && cell && row.rowType !== "section"
? {
rowType: row.rowType,
cellKind: cell.kind,
sectionId: row.sectionId,
circuitId: row.circuit?.id,
deviceId: row.device?.id,
}
: null;
const intent = resolveGridInsertionIntent(
selection,
activeSectionId ?? data?.sections[0]?.id
);
if (!intent) {
setError("No section is available for insertion.");
return;
}
if (intent.kind === "circuit") {
await handleAddReserveCircuit(intent.sectionId, intent.afterCircuitId);
return;
}
const circuit = data?.sections
.flatMap((sectionEntry) => sectionEntry.circuits)
.find((entry) => entry.id === intent.circuitId);
if (!circuit) {
setError("Invalid circuit id.");
return;
}
await handleAddManualDevice(circuit, intent.sectionId, intent.afterDeviceRowId);
}
function resolvePhaseType(device: ProjectDeviceDto): string {
return device.phaseType;
}
function resolveSelectedProjectDevice() {
if (!selectedProjectDeviceId) {
return null;
}
return projectDevices.find((device) => device.id === selectedProjectDeviceId) ?? null;
}
function isProjectDeviceTargetValid(device: ProjectDeviceDto, sectionId: string) {
const section = data?.sections.find((entry) => entry.id === sectionId);
return Boolean(section && isProjectDevicePlacementValid(device, section));
}
function getProjectDeviceTargetError(device: ProjectDeviceDto, sectionId: string) {
const target = data?.sections.find((entry) => entry.id === sectionId);
const expectedKey = inferProjectDeviceSectionKey(device);
const expected = data?.sections.find((entry) => entry.key === expectedKey);
if (!target) {
return "Invalid target section.";
}
return `${device.displayName || device.name} belongs in ${expected?.displayName ?? expectedKey}, not ${target.displayName}.`;
}
async function handleAddProjectDeviceAsNewCircuit() {
const device = resolveSelectedProjectDevice();
if (!device) {
setError("Please select a project device.");
return;
}
if (!targetSectionId) {
setError("Please select a target section.");
return;
}
if (!isProjectDeviceTargetValid(device, targetSectionId)) {
setError(getProjectDeviceTargetError(device, targetSectionId));
return;
}
let createdCircuitId: string | null = null;
let createdRowId: string | null = null;
await runCommand({
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;
}
const sectionId = findCircuitSectionId(circuitId);
if (!sectionId || !isProjectDeviceTargetValid(device, sectionId)) {
setError(getProjectDeviceTargetError(device, sectionId ?? ""));
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,
controlRequirement: found.controlRequirement ?? 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,
controlRequirement: snapshot.controlRequirement,
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;
}
function getDeviceRowSourceSectionIds(rowIds: string[]) {
const sectionIds = new Set<string>();
for (const rowId of rowIds) {
const circuitId = findDeviceRowCircuitId(rowId);
const sectionId = circuitId ? findCircuitSectionId(circuitId) : null;
if (sectionId) {
sectionIds.add(sectionId);
}
}
return [...sectionIds];
}
function confirmCrossSectionDeviceMove(rowIds: string[], targetSectionId: string) {
const sourceSectionIds = getDeviceRowSourceSectionIds(rowIds);
if (!requiresCrossSectionMoveConfirmation(sourceSectionIds, targetSectionId)) {
return true;
}
const sourceNames = sourceSectionIds
.map((sectionId) => data?.sections.find((section) => section.id === sectionId)?.displayName ?? sectionId)
.join(", ");
const targetName =
data?.sections.find((section) => section.id === targetSectionId)?.displayName ?? targetSectionId;
return confirm(
`Move ${rowIds.length} device row(s) from ${sourceNames} to ${targetName}?\n\n` +
"This crosses a section boundary. Phase type, category, project-device links and local values remain unchanged. " +
"The target section's technical and numbering classification may therefore need manual review.\n\n" +
"Existing circuit identifiers will not be renumbered."
);
}
// 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;
}
if (!intent.valid || !isProjectDeviceTargetValid(device, intent.sectionId)) {
setError(getProjectDeviceTargetError(device, intent.sectionId));
return;
}
// Project-device drop logic is isolated from row/circuit move logic because
// it can create new rows/circuits instead of moving existing ones.
if (intent.kind === "new-circuit") {
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;
}
if (intent.kind === "move-to-circuit" && rowIds.length === 1 && intent.circuitId === sourceCircuitId) {
return;
}
if (!confirmCrossSectionDeviceMove(rowIds, intent.sectionId)) {
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") {
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) {
const sourceCircuitId = findDeviceRowCircuitId(rowId);
const sourceCircuit = sourceCircuitId ? getCircuitSnapshot(sourceCircuitId) : null;
const rowSnapshot = sourceCircuit?.deviceRows.find((row) => row.id === rowId);
if (!sourceCircuitId || !sourceCircuit || !rowSnapshot) {
setError("Invalid device row id.");
return;
}
if (sourceCircuit.deviceRows.length === 1) {
const keepAsReserve = confirm(
`"${rowSnapshot.displayName}" is the last device in ${sourceCircuit.equipmentIdentifier}.\n\n` +
"OK: delete the device and keep the empty circuit as reserve.\n" +
"Cancel: choose whether to delete the whole circuit instead."
);
if (!keepAsReserve) {
const deleteCircuit = confirm(
`Delete the complete circuit ${sourceCircuit.equipmentIdentifier}?\n\n` +
"OK: delete circuit and device.\nCancel: do not delete anything."
);
if (deleteCircuit) {
await handleDeleteCircuit(sourceCircuitId, false);
}
return;
}
} else if (!confirm(`Delete device row "${rowSnapshot.displayName}"?`)) {
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, askForConfirmation = true) {
const snapshot = getCircuitSnapshot(circuitId);
if (!snapshot) {
setError("Invalid circuit id.");
return;
}
if (
askForConfirmation &&
!confirm(
`Delete circuit ${snapshot.equipmentIdentifier} and ${snapshot.deviceRows.length} assigned device row(s)?\n\n` +
"Existing circuit identifiers will not be renumbered."
)
) {
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;
},
});
}
async function handleDeleteSelection() {
const row = selectedCell ? findRow(selectedCell.rowKey) : null;
const cell = row?.cells.find((entry) => entry.cellKey === selectedCell?.cellKey);
const intent = resolveGridDeleteIntent(
row && cell && row.rowType !== "section"
? {
rowType: row.rowType,
cellKind: cell.kind,
circuitId: row.circuit?.id,
deviceId: row.device?.id,
}
: null
);
if (!intent) {
return;
}
if (intent.kind === "device") {
await handleDeleteDevice(intent.deviceId);
return;
}
await handleDeleteCircuit(intent.circuitId);
}
// 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;
}
const eventTarget = event.target as HTMLElement;
if (eventTarget.closest("button, input, select, textarea")) {
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();
void handleInsertBelowSelection();
return;
}
if (event.key === "Delete" && selectedCell) {
event.preventDefault();
void handleDeleteSelection();
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 && !data) {
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={clearSortAndFilters}
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>
{renderActiveViewSummary()}
{error ? (
<div className="notice error editor-error-notice" role="alert">
<span>{error}</span>
<button type="button" onClick={() => setError(null)}>Dismiss</button>
</div>
) : null}
<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 activeDraggedDeviceRowIds =
draggingDeviceRowIds.length > 0
? draggingDeviceRowIds
: draggingDeviceRowId
? [draggingDeviceRowId]
: [];
const draggingDeviceCount = activeDraggedDeviceRowIds.length;
const activeDraggedCircuitIds =
draggingCircuitIds.length > 0 ? draggingCircuitIds : draggingCircuitId ? [draggingCircuitId] : [];
const draggingCircuitCount = activeDraggedCircuitIds.length;
const selectedProjectDevice = resolveSelectedProjectDevice();
const suggestedSection = selectedProjectDevice
? data.sections.find((section) => section.key === inferProjectDeviceSectionKey(selectedProjectDevice))
: null;
const selectedRowCircuitId = selectedCell ? findRow(selectedCell.rowKey)?.circuit?.id ?? null : null;
const resolvedSidebarTargetCircuitId = targetCircuitId ?? selectedRowCircuitId;
const resolvedSidebarTargetSectionId = resolvedSidebarTargetCircuitId
? findCircuitSectionId(resolvedSidebarTargetCircuitId)
: null;
const canAddToSelectedSection = Boolean(
selectedProjectDevice &&
targetSectionId &&
isProjectDeviceTargetValid(selectedProjectDevice, targetSectionId)
);
const canAddToSelectedCircuit = Boolean(
selectedProjectDevice &&
resolvedSidebarTargetSectionId &&
isProjectDeviceTargetValid(selectedProjectDevice, resolvedSidebarTargetSectionId)
);
return (
<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={clearSortAndFilters}
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>
{renderActiveViewSummary()}
{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}
{error ? (
<div className="notice error editor-error-notice" role="alert">
<span>{error}</span>
<button type="button" onClick={() => setError(null)}>Dismiss</button>
</div>
) : null}
{duplicateEquipmentIdentifierCircuitIds.size > 0 ? (
<div className="notice warning" role="alert">
Duplicate equipment identifiers are highlighted. Renumbering remains an explicit action.
</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) => {
const valid = !selectedProjectDevice || isProjectDevicePlacementValid(selectedProjectDevice, section);
return (
<option key={section.id} value={section.id} disabled={!valid}>
{section.displayName}{valid ? "" : " (not valid)"}
</option>
);
})}
</select>
</label>
{selectedProjectDevice && suggestedSection ? (
<p className="notice muted">Suggested section: {suggestedSection.displayName}</p>
) : null}
<button
type="button"
disabled={!canAddToSelectedSection}
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) => {
const valid = !selectedProjectDevice || isProjectDeviceTargetValid(selectedProjectDevice, option.sectionId);
return (
<option key={option.id} value={option.id} disabled={!valid}>
{option.label}{valid ? "" : " (not valid)"}
</option>
);
})}
</select>
</label>
<button
type="button"
disabled={!canAddToSelectedCircuit}
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))}
aria-pressed={(columnFilters[column.key]?.length ?? 0) > 0}
title={
(columnFilters[column.key]?.length ?? 0) > 0
? `${columnFilters[column.key]?.length} filter value(s) selected`
: `Filter ${column.label}`
}
>
Filter{(columnFilters[column.key]?.length ?? 0) > 0 ? ` (${columnFilters[column.key]?.length})` : ""}
</button>
</div>
{openFilterColumn === column.key ? (
<div className="header-filter-menu">
<div className="header-filter-explanation">
Select one or more values. A circuit is shown when its block contains any selected value.
</div>
<div className="header-filter-actions">
<span>
{(columnFilters[column.key]?.length ?? 0) > 0
? `${columnFilters[column.key]?.length} selected`
: "Showing all"}
</span>
<button
type="button"
className="header-filter-clear"
onClick={() => clearColumnFilter(column.key)}
disabled={(columnFilters[column.key]?.length ?? 0) === 0}
>
Show all
</button>
</div>
<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 && dropIntent.valid
? "drop-target-active"
: ""
} ${
dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id && !dropIntent.valid
? "drop-target-invalid"
: ""
}`}
onDragOver={(event) => {
if (draggingCircuitCount > 0) {
event.preventDefault();
event.dataTransfer.dropEffect = "none";
setCircuitReorderIntent({
kind: "section-end",
sectionId: section.id,
valid: false,
});
return;
}
if (!draggingProjectDeviceId) {
return;
}
const device = projectDevices.find((entry) => entry.id === draggingProjectDeviceId);
const valid = Boolean(device && isProjectDevicePlacementValid(device, section));
event.preventDefault();
event.dataTransfer.dropEffect = valid ? "copy" : "none";
setDropIntent({ kind: "new-circuit", sectionId: section.id, valid });
}}
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) {
const device = projectDevices.find((entry) => entry.id === draggingProjectDeviceId);
void handleDropWithIntent(event, {
kind: "new-circuit",
sectionId: section.id,
valid: Boolean(device && isProjectDevicePlacementValid(device, section)),
});
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">
{dropIntent.valid ? "drop here to create new circuit" : "device does not match this section"}
</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 && dropIntent.valid) ||
(deviceMoveIntent?.kind === "move-to-new-circuit" &&
deviceMoveIntent.sectionId === row.sectionId &&
!deviceMoveIntent.requiresConfirmation) ||
(circuitReorderIntent?.kind === "section-end" && circuitReorderIntent.sectionId === row.sectionId))
? "drop-target-active"
: ""
} ${
row.rowType === "placeholder" &&
deviceMoveIntent?.kind === "move-to-new-circuit" &&
deviceMoveIntent.sectionId === row.sectionId &&
deviceMoveIntent.requiresConfirmation
? "drop-target-confirm"
: ""
} ${
row.circuit &&
deviceMoveIntent?.kind === "move-to-circuit" &&
deviceMoveIntent.circuitId === row.circuit.id &&
deviceMoveIntent.requiresConfirmation
? "drop-target-confirm"
: ""
} ${
row.rowType === "placeholder" &&
dropIntent?.kind === "new-circuit" &&
dropIntent.sectionId === row.sectionId &&
!dropIntent.valid
? "drop-target-invalid"
: ""
} ${
row.rowType === "placeholder" &&
circuitReorderIntent?.kind === "section-end" &&
circuitReorderIntent.sectionId === row.sectionId &&
!circuitReorderIntent.valid
? "drop-target-invalid"
: ""
} ${
row.circuit &&
dropIntent?.kind === "add-to-circuit" &&
dropIntent.circuitId === row.circuit.id &&
dropIntent.valid
? "drop-target-active"
: ""
} ${
row.circuit &&
dropIntent?.kind === "add-to-circuit" &&
dropIntent.circuitId === row.circuit.id &&
!dropIntent.valid
? "drop-target-invalid"
: ""
} ${
row.circuit &&
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) {
const device = projectDevices.find((entry) => entry.id === draggingProjectDeviceId);
const section = data.sections.find((entry) => entry.id === row.sectionId);
const valid = Boolean(device && section && isProjectDevicePlacementValid(device, section));
if (row.rowType === "placeholder") {
event.preventDefault();
event.dataTransfer.dropEffect = valid ? "copy" : "none";
setDropIntent({ kind: "new-circuit", sectionId: row.sectionId, valid });
return;
}
if (row.circuit && row.rowType !== "deviceRow") {
event.preventDefault();
event.dataTransfer.dropEffect = valid ? "copy" : "none";
setDropIntent({
kind: "add-to-circuit",
circuitId: row.circuit.id,
sectionId: row.sectionId,
valid,
});
}
return;
}
if (draggingDeviceCount > 0) {
const requiresConfirmation = requiresCrossSectionMoveConfirmation(
getDeviceRowSourceSectionIds(activeDraggedDeviceRowIds),
row.sectionId
);
if (row.rowType === "placeholder") {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
setDeviceMoveIntent({
kind: "move-to-new-circuit",
sectionId: row.sectionId,
requiresConfirmation,
});
return;
}
if (row.circuit && row.rowType !== "deviceRow") {
const sourceCircuitIds = activeDraggedDeviceRowIds
.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,
requiresConfirmation,
});
}
}
}
}}
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) {
const device = projectDevices.find((entry) => entry.id === draggingProjectDeviceId);
const section = data.sections.find((entry) => entry.id === row.sectionId);
const valid = Boolean(device && section && isProjectDevicePlacementValid(device, section));
if (row.rowType === "placeholder") {
void handleDropWithIntent(event, { kind: "new-circuit", sectionId: row.sectionId, valid });
return;
}
if (row.circuit && row.rowType !== "deviceRow") {
void handleDropWithIntent(event, {
kind: "add-to-circuit",
circuitId: row.circuit.id,
sectionId: row.sectionId,
valid,
});
}
return;
}
if (draggingDeviceCount > 0) {
const requiresConfirmation = requiresCrossSectionMoveConfirmation(
getDeviceRowSourceSectionIds(activeDraggedDeviceRowIds),
row.sectionId
);
if (row.rowType === "placeholder") {
void handleDeviceRowDropWithIntent(event, {
kind: "move-to-new-circuit",
sectionId: row.sectionId,
requiresConfirmation,
});
return;
}
if (row.circuit && row.rowType !== "deviceRow") {
void handleDeviceRowDropWithIntent(event, {
kind: "move-to-circuit",
circuitId: row.circuit.id,
sectionId: row.sectionId,
requiresConfirmation,
});
}
}
}}
>
{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;
const hasDraftIdentifierConflict = Boolean(
isEditing &&
column.key === "equipmentIdentifier" &&
row.circuit &&
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, editingCell?.draft ?? "")
);
const hasIdentifierConflict = Boolean(
column.key === "equipmentIdentifier" &&
row.circuit &&
(duplicateEquipmentIdentifierCircuitIds.has(row.circuit.id) || hasDraftIdentifierConflict)
);
return (
<td
key={column.key}
className={`${column.numeric ? "num" : ""} ${cell.editable ? "cell-editable" : ""} ${isSelected ? "cell-selected" : ""} ${hasIdentifierConflict ? "cell-invalid" : ""} ${
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}
aria-invalid={hasDraftIdentifierConflict}
title={hasDraftIdentifierConflict ? "Equipment identifier already exists." : undefined}
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);
focusGridWithoutScroll();
}
});
}}
/>
) : (
formatValue(cell.value)
)}
</td>
);
})}
<td
className={`action-cell ${
(dropIntent?.kind === "add-to-circuit" &&
dropIntent.circuitId === row.circuit?.id &&
dropIntent.valid) ||
(deviceMoveIntent?.kind === "move-to-circuit" &&
deviceMoveIntent.circuitId === row.circuit?.id &&
!deviceMoveIntent.requiresConfirmation)
? "drop-target-active"
: ""
} ${
dropIntent?.kind === "add-to-circuit" &&
dropIntent.circuitId === row.circuit?.id &&
!dropIntent.valid
? "drop-target-invalid"
: ""
} ${
deviceMoveIntent?.kind === "move-to-circuit" &&
deviceMoveIntent.circuitId === row.circuit?.id &&
deviceMoveIntent.requiresConfirmation
? "drop-target-confirm"
: ""
}`}
>
{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">
{dropIntent.valid ? "new circuit in this section" : "device does not match this section"}
</span>
) : null}
{dropIntent?.kind === "add-to-circuit" && dropIntent.circuitId === row.circuit?.id ? (
<span className="drop-hint">
{dropIntent.valid ? "add to this circuit" : "device does not match this circuit section"}
</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${deviceMoveIntent.requiresConfirmation ? " (confirmation required)" : ""}`}
</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${deviceMoveIntent.requiresConfirmation ? " (confirmation required)" : ""}`}
</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">
Ctrl+Plus inserts below the selected circuit or device context. Use Undo to revert the insertion.
</p>
</div>
);
}