forked from jappel/leistungsbilanz-ts
3360 lines
131 KiB
TypeScript
3360 lines
131 KiB
TypeScript
"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>
|
||
);
|
||
}
|