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

3365 lines
127 KiB
TypeScript

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