Files
leistungsbilanz-ts/src/frontend/components/circuit-tree-editor.tsx
T
2026-05-04 23:27:13 +02:00

1816 lines
67 KiB
TypeScript

"use client";
import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
import {
createCircuit,
createCircuitDeviceRow,
deleteCircuitById,
deleteCircuitDeviceRowById,
getCircuitTree,
getNextCircuitIdentifier,
listProjectDevices,
moveCircuitDeviceRowById,
reorderSectionCircuits,
renumberCircuitSection,
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";
type SaveDirection = "stay" | "next" | "prev";
type StartEditMode = "selectExisting" | "replaceWithTypedChar";
type RowType = "section" | "circuitCompact" | "circuitSummary" | "deviceRow" | "reserveCircuit" | "placeholder";
type CellKind = "circuitField" | "deviceField" | "computed" | "readonly";
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[];
}
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 columns: Array<{ key: CellKey; label: string; numeric?: boolean }> = [
{ key: "equipmentIdentifier", label: "Equipment identifier" },
{ key: "displayName", label: "Display name" },
{ key: "quantity", label: "Quantity", numeric: true },
{ key: "powerPerUnit", label: "Power / unit", numeric: true },
{ key: "simultaneityFactor", label: "Simultaneity", numeric: true },
{ key: "rowTotalPower", label: "Row total", numeric: true },
{ key: "circuitTotalPower", label: "Circuit total", numeric: true },
{ key: "protectionSummary", label: "Protection summary" },
{ key: "cableSummary", label: "Cable summary" },
{ key: "roomSummary", label: "Room" },
{ key: "remark", label: "Remark" },
];
const deviceFieldKeys = new Set<CellKey>([
"displayName",
"roomSummary",
"quantity",
"powerPerUnit",
"simultaneityFactor",
"remark",
]);
const circuitFieldKeys = new Set<CellKey>([
"equipmentIdentifier",
"displayName",
"protectionSummary",
"cableSummary",
"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 "displayName":
return device.displayName || device.name;
case "roomSummary":
return [device.roomNumberSnapshot, device.roomNameSnapshot].filter(Boolean).join(" ").trim() || undefined;
case "quantity":
return device.quantity;
case "powerPerUnit":
return device.powerPerUnit;
case "simultaneityFactor":
return device.simultaneityFactor;
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 "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 "remark":
return circuit.remark;
default:
return undefined;
}
}
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 }) {
const { projectId, circuitListId } = props;
const [data, setData] = useState<CircuitTreeResponseDto | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedCell, setSelectedCell] = useState<SelectedCell | null>(null);
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);
const [draggingProjectDeviceId, setDraggingProjectDeviceId] = useState<string | null>(null);
const [dropIntent, setDropIntent] = useState<ProjectDeviceDropIntent | null>(null);
const [draggingDeviceRowId, setDraggingDeviceRowId] = useState<string | null>(null);
const [deviceMoveIntent, setDeviceMoveIntent] = useState<DeviceRowMoveDropIntent | null>(null);
const [draggingCircuitId, setDraggingCircuitId] = useState<string | null>(null);
const [circuitReorderIntent, setCircuitReorderIntent] = useState<CircuitReorderDropIntent | null>(null);
const [pendingFocus, setPendingFocus] = useState<SelectedCell | null>(null);
const pendingSelectionAfterReload = useRef<SelectionIntent | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const focusTokenRef = useRef(1);
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);
}
}
}
useEffect(() => {
void loadTree({ showLoading: true });
}, [projectId, circuitListId]);
useEffect(() => {
async function loadProjectDeviceList() {
try {
const list = await listProjectDevices(projectId);
setProjectDevices(list);
} catch (err) {
setError(normalizeUiError(err));
}
}
void loadProjectDeviceList();
}, [projectId]);
const visibleRows = useMemo(() => {
if (!data) {
return [] as VisibleGridRow[];
}
const rows: VisibleGridRow[] = [];
for (const section of data.sections) {
rows.push({
rowKey: `section:${section.id}`,
rowType: "section",
sectionId: section.id,
cells: columns.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));
}
}
rows.push(makeRow("placeholder", section.id));
}
return rows;
}, [data]);
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 {
const rowKey =
rowType === "placeholder"
? `placeholder:${sectionId}`
: rowType === "deviceRow" && device
? `device:${device.id}`
: `${rowType}:${circuit?.id ?? sectionId}`;
const cells = columns.map((col) => {
const kind = getCellKind(rowType, col.key);
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]
);
const rowCellMap = useMemo(() => {
const map = new Map<string, CellKey[]>();
for (const row of visibleRows) {
const keys = row.cells.filter((cell) => cell.editable).map((cell) => cell.cellKey as CellKey);
if (keys.length) {
map.set(row.rowKey, keys);
}
}
return map;
}, [visibleRows]);
const editableRowOrder = useMemo(
() => visibleRows.filter((row) => rowCellMap.has(row.rowKey)).map((row) => row.rowKey),
[visibleRows, rowCellMap]
);
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]);
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,
};
}
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;
}
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]);
useEffect(() => {
if (!pendingFocus) {
return;
}
setSelectedCell(pendingFocus);
startEdit(pendingFocus, "selectExisting");
setPendingFocus(null);
}, [pendingFocus]);
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]);
function findRow(rowKey: string) {
return visibleRows.find((row) => row.rowKey === rowKey);
}
function findCell(rowKey: string, cellKey: CellKey) {
const row = findRow(rowKey);
return row?.cells.find((cell) => cell.cellKey === cellKey);
}
function startEdit(cell: SelectedCell, mode: StartEditMode, typedChar?: string) {
const visibleCell = findCell(cell.rowKey, cell.cellKey);
if (!visibleCell || !visibleCell.editable) {
return;
}
const currentDisplay = mode === "replaceWithTypedChar" ? typedChar ?? "" : String(visibleCell.value ?? "");
focusTokenRef.current += 1;
setEditingCell({
...cell,
mode,
draft: currentDisplay === "-" ? "" : currentDisplay,
focusToken: focusTokenRef.current,
});
}
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 });
}
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 = columns.findIndex((col) => col.key === selectedCell.cellKey);
let best = targetCells[0];
let bestDistance = Number.POSITIVE_INFINITY;
for (const key of targetCells) {
const idx = columns.findIndex((col) => col.key === key);
const dist = Math.abs(idx - preferred);
if (dist < bestDistance) {
bestDistance = dist;
best = key;
}
}
setSelectedCell({ rowKey: targetRowKey, cellKey: best });
}
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 {
payload[key] = draft.trim() === "" ? undefined : draft;
}
await updateCircuitById(circuitId, payload);
}
async function patchDeviceRow(rowId: string, key: CellKey, draft: string) {
const payload: Record<string, unknown> = {};
if (["quantity", "powerPerUnit", "simultaneityFactor"].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);
}
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 };
}
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;
}
try {
setIsSaving(true);
setError(null);
let targetSelection: SelectedCell = { rowKey: editingCell.rowKey, cellKey: editingCell.cellKey };
if (row.rowType === "placeholder") {
targetSelection = await createFromPlaceholder(row.sectionId, editingCell.cellKey, editingCell.draft);
} else if (cell.kind === "circuitField" && row.circuit) {
await patchCircuit(row.circuit.id, editingCell.cellKey, editingCell.draft);
} else if (cell.kind === "deviceField") {
if (row.device) {
await patchDeviceRow(row.device.id, editingCell.cellKey, editingCell.draft);
} else if (row.rowType === "reserveCircuit" && row.circuit) {
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 };
await patchDeviceRow(created.id, editingCell.cellKey, editingCell.draft);
targetSelection = { rowKey: `circuitCompact:${row.circuit.id}`, cellKey: editingCell.cellKey };
}
}
if (direction !== "stay") {
const idx = editableCells.findIndex(
(entry) => entry.rowKey === targetSelection.rowKey && entry.cellKey === targetSelection.cellKey
);
if (idx >= 0) {
const targetIdx =
direction === "next"
? Math.min(editableCells.length - 1, idx + 1)
: Math.max(0, idx - 1);
targetSelection = editableCells[targetIdx];
}
}
const intent = buildSelectionIntent(targetSelection);
setEditingCell(null);
if (intent) {
pendingSelectionAfterReload.current = intent;
}
await loadTree({ showLoading: false });
if (!intent) {
requestAnimationFrame(() => containerRef.current?.focus());
}
} catch (err) {
setError(normalizeUiError(err));
} finally {
setIsSaving(false);
}
}
function cancelEdit() {
if (!editingCell) {
return;
}
setEditingCell(null);
setSelectedCell({ rowKey: editingCell.rowKey, cellKey: editingCell.cellKey });
requestAnimationFrame(() => containerRef.current?.focus());
}
async function handleAddReserveCircuit(sectionId: string) {
try {
setError(null);
setIsSaving(true);
const section = data?.sections.find((entry) => entry.id === sectionId);
if (!section) {
return;
}
const next = await getNextCircuitIdentifier(sectionId);
const sortOrder =
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
await createCircuit(projectId, circuitListId, {
sectionId,
equipmentIdentifier: next.nextIdentifier,
displayName: "Reserve",
sortOrder,
isReserve: true,
});
await loadTree({ showLoading: false });
setActiveSectionId(sectionId);
} catch (err) {
setError(normalizeUiError(err));
} finally {
setIsSaving(false);
}
}
async function handleAddManualDevice(circuit: CircuitTreeCircuitDto, sectionId: string) {
try {
setError(null);
setIsSaving(true);
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 };
await loadTree({ showLoading: false });
setActiveSectionId(sectionId);
setPendingFocus({
rowKey: circuit.deviceRows.length === 0 ? `circuitCompact:${circuit.id}` : `device:${created.id}`,
cellKey: "displayName",
});
} catch (err) {
setError(normalizeUiError(err));
} finally {
setIsSaving(false);
}
}
function resolvePhaseType(device: ProjectDeviceDto): string {
if (device.phaseCount === 3) {
return "three_phase";
}
return "single_phase";
}
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;
}
try {
setError(null);
setIsSaving(true);
await insertProjectDeviceAsNewCircuit(device, targetSectionId);
} catch (err) {
setError(normalizeUiError(err));
} finally {
setIsSaving(false);
}
}
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;
}
try {
setError(null);
setIsSaving(true);
await insertProjectDeviceToCircuit(device, circuitId);
} catch (err) {
setError(normalizeUiError(err));
} finally {
setIsSaving(false);
}
}
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),
quantity: device.quantity,
powerPerUnit: device.installedPowerPerUnitKw,
simultaneityFactor: device.demandFactor,
cosPhi: device.powerFactor ?? undefined,
category: device.category ?? undefined,
})) as { id: string };
await loadTree({ showLoading: false });
setActiveSectionId(sectionId);
setTargetCircuitId(createdCircuit.id);
setPendingFocus({ rowKey: `device:${createdRow.id}`, cellKey: "displayName" });
}
async function insertProjectDeviceToCircuit(device: ProjectDeviceDto, circuitId: string) {
const createdRow = (await createCircuitDeviceRow(circuitId, {
linkedProjectDeviceId: device.id,
name: device.name,
displayName: device.displayName,
phaseType: resolvePhaseType(device),
quantity: device.quantity,
powerPerUnit: device.installedPowerPerUnitKw,
simultaneityFactor: device.demandFactor,
cosPhi: device.powerFactor ?? undefined,
category: device.category ?? undefined,
})) as { id: string };
await loadTree({ showLoading: false });
setPendingFocus({ rowKey: `device:${createdRow.id}`, cellKey: "displayName" });
}
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 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;
}
function findCircuitSectionId(circuitId: string) {
for (const section of data?.sections ?? []) {
if (section.circuits.some((circuit) => circuit.id === circuitId)) {
return section.id;
}
}
return null;
}
async function applyCircuitReorder(intent: CircuitReorderDropIntent, sourceCircuitId: 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 fromIndex = ids.indexOf(sourceCircuitId);
if (fromIndex < 0) {
throw new Error("Invalid source circuit.");
}
const nextIds = [...ids];
nextIds.splice(fromIndex, 1);
if (intent.kind === "section-end") {
nextIds.push(sourceCircuitId);
} 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, sourceCircuitId);
}
await reorderSectionCircuits(section.id, nextIds);
}
async function handleDropWithIntent(event: DragEvent<HTMLElement>, intent: ProjectDeviceDropIntent) {
event.preventDefault();
event.stopPropagation();
const draggedId = parseDraggedProjectDeviceId(event) ?? draggingProjectDeviceId;
setDropIntent(null);
setDraggingProjectDeviceId(null);
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;
}
try {
setError(null);
setIsSaving(true);
if (intent.kind === "new-circuit") {
await insertProjectDeviceAsNewCircuit(device, intent.sectionId);
} else {
await insertProjectDeviceToCircuit(device, intent.circuitId);
}
} catch (err) {
setError(normalizeUiError(err));
} finally {
setIsSaving(false);
}
}
async function handleDeviceRowDropWithIntent(event: DragEvent<HTMLElement>, intent: DeviceRowMoveDropIntent) {
event.preventDefault();
event.stopPropagation();
const rowId = parseDraggedDeviceRowId(event) ?? draggingDeviceRowId;
setDeviceMoveIntent(null);
setDraggingDeviceRowId(null);
if (!rowId) {
setError("Missing dragged device row.");
return;
}
const sourceCircuitId = findDeviceRowCircuitId(rowId);
if (!sourceCircuitId) {
setError("Invalid dragged device row.");
return;
}
try {
setError(null);
setIsSaving(true);
if (intent.kind === "move-to-circuit") {
if (intent.circuitId === sourceCircuitId) {
return;
}
await moveCircuitDeviceRowById(rowId, { targetCircuitId: intent.circuitId });
} else {
await moveCircuitDeviceRowById(rowId, {
targetSectionId: intent.sectionId,
createNewCircuit: true,
});
}
await loadTree({ showLoading: false });
setPendingFocus({ rowKey: `device:${rowId}`, cellKey: "displayName" });
} catch (err) {
setError(normalizeUiError(err));
} finally {
setIsSaving(false);
}
}
async function handleCircuitReorderDrop(event: DragEvent<HTMLElement>, intent: CircuitReorderDropIntent) {
event.preventDefault();
event.stopPropagation();
const sourceCircuitId = event.dataTransfer.getData("application/x-circuit-id") || draggingCircuitId;
setCircuitReorderIntent(null);
setDraggingCircuitId(null);
if (!sourceCircuitId) {
setError("Missing dragged circuit.");
return;
}
if (!intent.valid) {
setError("Cross-section circuit move is not allowed in this phase.");
return;
}
try {
setError(null);
setIsSaving(true);
await applyCircuitReorder(intent, sourceCircuitId);
pendingSelectionAfterReload.current = {
rowKey: `circuitSummary:${sourceCircuitId}`,
cellKey: "equipmentIdentifier",
rowType: "circuitSummary",
sectionId: intent.sectionId,
circuitId: sourceCircuitId,
};
await loadTree({ showLoading: false });
} catch (err) {
setError(normalizeUiError(err));
} finally {
setIsSaving(false);
}
}
async function handleDeleteDevice(rowId: string) {
if (!confirm("Delete this device row?")) {
return;
}
try {
setError(null);
setIsSaving(true);
await deleteCircuitDeviceRowById(rowId);
await loadTree({ showLoading: false });
} catch (err) {
setError(normalizeUiError(err));
} finally {
setIsSaving(false);
}
}
async function handleDeleteCircuit(circuitId: string) {
if (!confirm("Delete this circuit and all assigned device rows?")) {
return;
}
try {
setError(null);
setIsSaving(true);
await deleteCircuitById(circuitId);
await loadTree({ showLoading: false });
} catch (err) {
setError(normalizeUiError(err));
} finally {
setIsSaving(false);
}
}
async function handleRenumberSection(sectionId: string) {
if (!confirm("Renumber this section? Only circuits in this section will change.")) {
return;
}
try {
setError(null);
setIsSaving(true);
await renumberCircuitSection(sectionId);
await loadTree({ showLoading: false });
} catch (err) {
setError(normalizeUiError(err));
} finally {
setIsSaving(false);
}
}
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]);
}
}
function handleContainerKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (editingCell) {
if (event.key === "Tab") {
event.preventDefault();
}
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) {
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>;
}
return (
<div className="tree-editor-shell">
{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);
setDraggingCircuitId(null);
setDeviceMoveIntent(null);
setCircuitReorderIntent(null);
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData("application/x-project-device-id", device.id);
}}
onDragEnd={() => {
setDraggingProjectDeviceId(null);
setDropIntent(null);
}}
>
<strong>{device.displayName || device.name}</strong>
<span>Name: {device.name}</span>
<span>Phase: {device.phaseCount === 3 ? "three_phase" : "single_phase"}</span>
<span>Qty: {device.quantity}</span>
<span>P/unit: {device.installedPowerPerUnitKw}</span>
<span>g: {device.demandFactor}</span>
<span>Cost group: -</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}
>
<table className="tree-grid">
<thead>
<tr>
{columns.map((column) => (
<th key={column.key} className={column.numeric ? "num" : ""}>
{column.label}
</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 (draggingCircuitId) {
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 (draggingCircuitId) {
void handleCircuitReorderDrop(event, {
kind: "section-end",
sectionId: section.id,
valid: false,
});
}
}}
>
<td colSpan={columns.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} onClick={() => void handleRenumberSection(section.id)}>
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 && draggingCircuitId && row.circuit.id === draggingCircuitId ? "circuit-dragging-block" : ""
}`}
onClick={() => setActiveSectionId(row.sectionId)}
onDragOver={(event) => {
if (draggingCircuitId) {
if (row.rowType === "placeholder") {
const sourceSectionId = findCircuitSectionId(draggingCircuitId);
const valid = sourceSectionId === row.sectionId;
event.preventDefault();
event.dataTransfer.dropEffect = valid ? "move" : "none";
setCircuitReorderIntent({ kind: "section-end", sectionId: row.sectionId, valid });
return;
}
if (row.circuit && row.rowType !== "deviceRow") {
const sourceSectionId = findCircuitSectionId(draggingCircuitId);
if (row.circuit.id === draggingCircuitId) {
setCircuitReorderIntent(null);
return;
}
const valid = sourceSectionId === row.sectionId;
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 (draggingDeviceRowId) {
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 sourceCircuitId = findDeviceRowCircuitId(draggingDeviceRowId);
if (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 (draggingCircuitId) {
if (row.rowType === "placeholder") {
const sourceSectionId = findCircuitSectionId(draggingCircuitId);
void handleCircuitReorderDrop(event, {
kind: "section-end",
sectionId: row.sectionId,
valid: sourceSectionId === row.sectionId,
});
return;
}
if (row.circuit && row.rowType !== "deviceRow") {
if (row.circuit.id === draggingCircuitId) {
return;
}
const sourceSectionId = findCircuitSectionId(draggingCircuitId);
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: sourceSectionId === 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 (draggingDeviceRowId) {
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,
});
}
}
}}
>
{columns.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"
: ""
} ${
draggingDeviceRowId && row.device?.id === draggingDeviceRowId ? "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")
) {
setDraggingProjectDeviceId(null);
setDropIntent(null);
setDraggingDeviceRowId(null);
setDeviceMoveIntent(null);
setDraggingCircuitId(row.circuit.id);
setCircuitReorderIntent(null);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("application/x-circuit-id", row.circuit.id);
return;
}
if (
row.device &&
column.key === "displayName" &&
(row.rowType === "deviceRow" || row.rowType === "circuitCompact")
) {
setDraggingProjectDeviceId(null);
setDropIntent(null);
setDraggingCircuitId(null);
setCircuitReorderIntent(null);
setDraggingDeviceRowId(row.device.id);
setDeviceMoveIntent(null);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("application/x-circuit-device-row-id", row.device.id);
}
}}
onDragEnd={() => {
setDraggingDeviceRowId(null);
setDeviceMoveIntent(null);
setDraggingCircuitId(null);
setCircuitReorderIntent(null);
}}
onClick={() => {
if (cell.editable) {
setSelectedCell({ rowKey: row.rowKey, cellKey: column.key });
requestAnimationFrame(() => containerRef.current?.focus());
}
}}
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 device to new circuit</span>
) : null}
{deviceMoveIntent?.kind === "move-to-circuit" && deviceMoveIntent.circuitId === row.circuit?.id ? (
<span className="drop-hint">move device to this circuit</span>
) : null}
{circuitReorderIntent?.kind === "section-end" &&
row.rowType === "placeholder" &&
circuitReorderIntent.sectionId === row.sectionId ? (
<span className="drop-hint">
{circuitReorderIntent.valid ? "move circuit 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 circuit before this circuit"
: "move circuit 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>
);
}