"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([ "displayName", "roomSummary", "quantity", "powerPerUnit", "simultaneityFactor", "remark", ]); const circuitFieldKeys = new Set([ "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) { 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(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [selectedCell, setSelectedCell] = useState(null); const [editingCell, setEditingCell] = useState(null); const [activeSectionId, setActiveSectionId] = useState(null); const [isSaving, setIsSaving] = useState(false); const [projectDevices, setProjectDevices] = useState([]); const [projectDeviceSearch, setProjectDeviceSearch] = useState(""); const [selectedProjectDeviceId, setSelectedProjectDeviceId] = useState(null); const [targetSectionId, setTargetSectionId] = useState(null); const [targetCircuitId, setTargetCircuitId] = useState(null); const [draggingProjectDeviceId, setDraggingProjectDeviceId] = useState(null); const [dropIntent, setDropIntent] = useState(null); const [draggingDeviceRowId, setDraggingDeviceRowId] = useState(null); const [deviceMoveIntent, setDeviceMoveIntent] = useState(null); const [draggingCircuitId, setDraggingCircuitId] = useState(null); const [circuitReorderIntent, setCircuitReorderIntent] = useState(null); const [pendingFocus, setPendingFocus] = useState(null); const pendingSelectionAfterReload = useRef(null); const containerRef = useRef(null); const inputRef = useRef(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(); 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 = {}; 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 = {}; 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 { 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) { return event.dataTransfer.getData("application/x-project-device-id") || null; } function parseDraggedDeviceRowId(event: DragEvent) { return event.dataTransfer.getData("application/x-circuit-device-row-id") || null; } function 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, 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, 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, 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) { 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
Loading circuit tree editor...
; } if (error) { return
{error}
; } if (!data || data.sections.length === 0) { return
No sections/circuits available.
; } return (
{isSaving ?
Saving...
: null}
{columns.map((column) => ( ))} {visibleRows.map((row) => { if (row.rowType === "section") { const section = data.sections.find((entry) => entry.id === row.sectionId)!; return ( { 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, }); } }} > ); } return ( 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 ( ); })} ); })}
{column.label} Actions
{section.displayName}
{dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? ( drop here to create new circuit ) : null}
{ 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 ? ( setEditingCell((current) => current ? { ...current, draft: event.target.value } : current ) } onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void commitEdit("stay"); } else if (event.key === "Escape") { event.preventDefault(); cancelEdit(); } else if (event.key === "Tab") { event.preventDefault(); void commitEdit(event.shiftKey ? "prev" : "next"); } }} onBlur={() => { requestAnimationFrame(() => { const active = document.activeElement as HTMLElement | null; if (!active || !containerRef.current?.contains(active)) { setEditingCell(null); requestAnimationFrame(() => containerRef.current?.focus()); } }); }} /> ) : ( formatValue(cell.value) )} {row.circuit && row.rowType !== "deviceRow" ? ( <> ) : null} {row.device ? ( ) : null} {dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? ( new circuit in this section ) : null} {dropIntent?.kind === "add-to-circuit" && dropIntent.circuitId === row.circuit?.id ? ( add to this circuit ) : null} {deviceMoveIntent?.kind === "move-to-new-circuit" && row.rowType === "placeholder" && deviceMoveIntent.sectionId === row.sectionId ? ( move device to new circuit ) : null} {deviceMoveIntent?.kind === "move-to-circuit" && deviceMoveIntent.circuitId === row.circuit?.id ? ( move device to this circuit ) : null} {circuitReorderIntent?.kind === "section-end" && row.rowType === "placeholder" && circuitReorderIntent.sectionId === row.sectionId ? ( {circuitReorderIntent.valid ? "move circuit to section end" : "cross-section move not allowed"} ) : null} {circuitReorderIntent && (circuitReorderIntent.kind === "before-circuit" || circuitReorderIntent.kind === "after-circuit") && circuitReorderIntent.targetCircuitId === row.circuit?.id ? ( {circuitReorderIntent.valid ? circuitReorderIntent.kind === "before-circuit" ? "move circuit before this circuit" : "move circuit after this circuit" : "cross-section move not allowed"} ) : null}

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

); }