From 48b4dd0fc000cf37337929dde0f64c218e3c993b Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Mon, 4 May 2026 18:42:20 +0200 Subject: [PATCH] Editor fully working as expected --- src/app/globals.css | 6 +- .../components/circuit-tree-editor.tsx | 1165 +++++++++-------- 2 files changed, 628 insertions(+), 543 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 3a008a5..6798083 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -27,7 +27,7 @@ body { } .circuit-tree-table .reserve-row td { - background: #fff8e8; + background: #f8fbff; } .circuit-tree-table .placeholder-row td { @@ -91,8 +91,8 @@ body { color: #6b7280; } -.tree-grid .reserve-row td { - background: #fff8e7; +.tree-grid .empty-circuit-row td { + background: #f8fbff; } .tree-grid .placeholder-row td { diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index b0d8059..801983d 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -16,41 +16,21 @@ import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto, CircuitTreeRespons type CellKey = | "equipmentIdentifier" - | "name" | "displayName" - | "phaseType" - | "connectionKind" - | "costGroup" - | "category" - | "level" - | "roomNumberSnapshot" - | "roomNameSnapshot" | "quantity" | "powerPerUnit" | "simultaneityFactor" - | "cosPhi" | "rowTotalPower" | "circuitTotalPower" - | "protectionType" - | "protectionRatedCurrent" - | "protectionCharacteristic" - | "cableType" - | "cableCrossSection" - | "cableLength" - | "rcdAssignment" - | "terminalDesignation" - | "voltage" - | "status" - | "isReserve" + | "protectionSummary" + | "cableSummary" + | "roomSummary" | "remark"; -interface GridRow { - rowKey: string; - kind: "section" | "summary" | "compact" | "device" | "reserve" | "placeholder"; - sectionId: string; - circuit?: CircuitTreeCircuitDto; - device?: CircuitTreeDeviceRowDto; -} +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; @@ -59,41 +39,66 @@ interface SelectedCell { interface EditingCell extends SelectedCell { draft: string; + mode: StartEditMode; + focusToken: number; } -type SaveDirection = "stay" | "next" | "prev"; +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[]; +} const columns: Array<{ key: CellKey; label: string; numeric?: boolean }> = [ { key: "equipmentIdentifier", label: "Equipment identifier" }, - { key: "name", label: "Name" }, { key: "displayName", label: "Display name" }, - { key: "phaseType", label: "Phase type" }, - { key: "connectionKind", label: "Connection kind" }, - { key: "costGroup", label: "Cost group" }, - { key: "category", label: "Category" }, - { key: "level", label: "Level" }, - { key: "roomNumberSnapshot", label: "Room number" }, - { key: "roomNameSnapshot", label: "Room name" }, { key: "quantity", label: "Quantity", numeric: true }, { key: "powerPerUnit", label: "Power / unit", numeric: true }, { key: "simultaneityFactor", label: "Simultaneity", numeric: true }, - { key: "cosPhi", label: "cosPhi", numeric: true }, { key: "rowTotalPower", label: "Row total", numeric: true }, { key: "circuitTotalPower", label: "Circuit total", numeric: true }, - { key: "protectionType", label: "Protection type" }, - { key: "protectionRatedCurrent", label: "Protection current", numeric: true }, - { key: "protectionCharacteristic", label: "Protection characteristic" }, - { key: "cableType", label: "Cable type" }, - { key: "cableCrossSection", label: "Cable cross-section" }, - { key: "cableLength", label: "Cable length", numeric: true }, - { key: "rcdAssignment", label: "RCD assignment" }, - { key: "terminalDesignation", label: "Terminal designation" }, - { key: "voltage", label: "Voltage", numeric: true }, - { key: "status", label: "Status" }, - { key: "isReserve", label: "Reserve" }, + { 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 "-"; @@ -104,118 +109,26 @@ function formatValue(value: string | number | boolean | undefined) { return String(value); } -function getDeviceFieldValue(device: CircuitTreeDeviceRowDto, cellKey: CellKey): string | number | boolean | undefined { - switch (cellKey) { - case "name": - return device.name; - case "displayName": - return device.displayName || device.name; - case "phaseType": - return device.phaseType; - case "connectionKind": - return device.connectionKind; - case "costGroup": - return device.costGroup; - case "category": - return device.category; - case "level": - return device.level; - case "roomNumberSnapshot": - return device.roomNumberSnapshot; - case "roomNameSnapshot": - return device.roomNameSnapshot; - case "quantity": - return device.quantity; - case "powerPerUnit": - return device.powerPerUnit; - case "simultaneityFactor": - return device.simultaneityFactor; - case "cosPhi": - return device.cosPhi; - case "rowTotalPower": - return device.rowTotalPower; - case "remark": - return device.remark; - default: - return undefined; +function 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 } -} - -function getCircuitFieldValue(circuit: CircuitTreeCircuitDto, cellKey: CellKey): string | number | boolean | undefined { - switch (cellKey) { - case "equipmentIdentifier": - return circuit.equipmentIdentifier; - case "displayName": - return circuit.displayName; - case "circuitTotalPower": - return circuit.circuitTotalPower; - case "protectionType": - return circuit.protectionType; - case "protectionRatedCurrent": - return circuit.protectionRatedCurrent; - case "protectionCharacteristic": - return circuit.protectionCharacteristic; - case "cableType": - return circuit.cableType; - case "cableCrossSection": - return circuit.cableCrossSection; - case "cableLength": - return circuit.cableLength; - case "rcdAssignment": - return circuit.rcdAssignment; - case "terminalDesignation": - return circuit.terminalDesignation; - case "voltage": - return circuit.voltage; - case "status": - return circuit.status; - case "isReserve": - return circuit.isReserve; - case "remark": - return circuit.remark; - default: - return undefined; + if (message.includes("Duplicate equipmentIdentifier")) { + return "Das Betriebsmittelkennzeichen ist in dieser Stromkreisliste bereits vorhanden."; } -} - -function isCircuitField(cellKey: CellKey) { - return [ - "equipmentIdentifier", - "displayName", - "protectionType", - "protectionRatedCurrent", - "protectionCharacteristic", - "cableType", - "cableCrossSection", - "cableLength", - "rcdAssignment", - "terminalDesignation", - "voltage", - "status", - "isReserve", - "remark", - "circuitTotalPower", - ].includes(cellKey); -} - -function isDeviceField(cellKey: CellKey) { - return [ - "name", - "displayName", - "phaseType", - "connectionKind", - "costGroup", - "category", - "level", - "roomNumberSnapshot", - "roomNameSnapshot", - "quantity", - "powerPerUnit", - "simultaneityFactor", - "cosPhi", - "remark", - "rowTotalPower", - ].includes(cellKey); + if (message.includes("Invalid number")) { + return "Bitte einen gültigen Zahlenwert eingeben."; + } + return message; } function parseNumeric(cellKey: CellKey, draft: string): number | undefined { @@ -230,15 +143,102 @@ function parseNumeric(cellKey: CellKey, draft: string): number | undefined { return parsed; } -function normalizeUiError(err: unknown): string { - const message = err instanceof Error ? err.message : "Operation failed."; - if (message.includes("Duplicate equipmentIdentifier")) { - return "Das Betriebsmittelkennzeichen ist in dieser Stromkreisliste bereits vorhanden."; +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; } - if (message.includes("Invalid number")) { - return "Bitte einen gültigen Zahlenwert eingeben."; +} + +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; } - return message; +} + +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 }) { @@ -251,320 +251,310 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const [activeSectionId, setActiveSectionId] = useState(null); const [isSaving, setIsSaving] = useState(false); const [pendingFocus, setPendingFocus] = useState(null); - const pendingSelectionAfterReload = useRef(null); + const pendingSelectionAfterReload = useRef(null); const containerRef = useRef(null); const inputRef = useRef(null); - const lastEditingSignatureRef = useRef(null); + const focusTokenRef = useRef(1); - async function loadTree() { - setIsLoading(true); + 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); - if (pendingSelectionAfterReload.current) { - setSelectedCell(pendingSelectionAfterReload.current); - pendingSelectionAfterReload.current = null; - } } catch (err) { setError(normalizeUiError(err)); } finally { - setIsLoading(false); - requestAnimationFrame(() => containerRef.current?.focus()); + if (showLoading) { + setIsLoading(false); + } } } useEffect(() => { - void loadTree(); + void loadTree({ showLoading: true }); }, [projectId, circuitListId]); - const gridRows = useMemo(() => { + const visibleRows = useMemo(() => { if (!data) { - return [] as GridRow[]; + return [] as VisibleGridRow[]; } - const rows: GridRow[] = []; + const rows: VisibleGridRow[] = []; for (const section of data.sections) { - rows.push({ rowKey: `section:${section.id}`, kind: "section", sectionId: section.id }); + 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({ - rowKey: `reserve:${circuit.id}`, - kind: "reserve", - sectionId: section.id, - circuit, - }); + rows.push(makeRow("reserveCircuit", section.id, circuit)); continue; } if (circuit.deviceRows.length === 1) { - rows.push({ - rowKey: `compact:${circuit.id}`, - kind: "compact", - sectionId: section.id, - circuit, - device: circuit.deviceRows[0], - }); + rows.push(makeRow("circuitCompact", section.id, circuit, circuit.deviceRows[0])); continue; } - rows.push({ - rowKey: `summary:${circuit.id}`, - kind: "summary", - sectionId: section.id, - circuit, - }); + rows.push(makeRow("circuitSummary", section.id, circuit)); for (const device of circuit.deviceRows) { - rows.push({ - rowKey: `device:${device.id}`, - kind: "device", - sectionId: section.id, - circuit, - device, - }); + rows.push(makeRow("deviceRow", section.id, circuit, device)); } } - rows.push({ rowKey: `placeholder:${section.id}`, kind: "placeholder", sectionId: section.id }); + rows.push(makeRow("placeholder", section.id)); } return rows; }, [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); + startEdit(pendingFocus, "selectExisting"); setPendingFocus(null); - requestAnimationFrame(() => containerRef.current?.focus()); }, [pendingFocus]); useEffect(() => { if (!editingCell) { - lastEditingSignatureRef.current = null; return; } - const signature = `${editingCell.rowKey}:${editingCell.cellKey}`; - if (lastEditingSignatureRef.current === signature) { - return; - } - lastEditingSignatureRef.current = signature; requestAnimationFrame(() => { - inputRef.current?.focus(); - inputRef.current?.select(); + 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]); - - const editableCells = useMemo(() => { - const cells: SelectedCell[] = []; - for (const row of gridRows) { - for (const column of columns) { - if (canEditCell(row, column.key)) { - cells.push({ rowKey: row.rowKey, cellKey: column.key }); - } - } - } - return cells; - }, [gridRows]); - - const rowEditableCells = useMemo(() => { - const map = new Map(); - for (const row of gridRows) { - const keys = columns.map((col) => col.key).filter((key) => canEditCell(row, key)); - if (keys.length > 0) { - map.set(row.rowKey, keys); - } - } - return map; - }, [gridRows]); - - const editableRowOrder = useMemo( - () => gridRows.filter((row) => rowEditableCells.has(row.rowKey)).map((row) => row.rowKey), - [gridRows, rowEditableCells] - ); + }, [editingCell?.focusToken]); function findRow(rowKey: string) { - return gridRows.find((row) => row.rowKey === rowKey); + return visibleRows.find((row) => row.rowKey === rowKey); } - function canEditCell(row: GridRow, cellKey: CellKey) { - if (row.kind === "section") { - return false; - } - if (cellKey === "rowTotalPower" || cellKey === "circuitTotalPower") { - return false; - } - if (row.kind === "placeholder") { - return true; - } - if (row.kind === "summary") { - return isCircuitField(cellKey); - } - if (row.kind === "reserve") { - return isCircuitField(cellKey) || isDeviceField(cellKey); - } - if (row.kind === "device") { - return isDeviceField(cellKey); - } - if (row.kind === "compact") { - return isCircuitField(cellKey) || isDeviceField(cellKey); - } - return false; + function findCell(rowKey: string, cellKey: CellKey) { + const row = findRow(rowKey); + return row?.cells.find((cell) => cell.cellKey === cellKey); } - function getCellValue(row: GridRow, cellKey: CellKey): string | number | boolean | undefined { - const circuit = row.circuit; - const device = row.device; - if (!circuit) { - return undefined; - } - if (row.kind === "compact" && device) { - if (cellKey === "displayName") { - return device.displayName || device.name; - } - if (isDeviceField(cellKey)) { - return getDeviceFieldValue(device, cellKey); - } - } - if (row.kind === "device" && device) { - return getDeviceFieldValue(device, cellKey); - } - return getCircuitFieldValue(circuit, cellKey); - } - - function startEdit(cell: SelectedCell, initialDraft?: string) { - const row = findRow(cell.rowKey); - if (!row || !canEditCell(row, cell.cellKey)) { + function startEdit(cell: SelectedCell, mode: StartEditMode, typedChar?: string) { + const visibleCell = findCell(cell.rowKey, cell.cellKey); + if (!visibleCell || !visibleCell.editable) { return; } - const value = initialDraft ?? String(getCellValue(row, cell.cellKey) ?? ""); - setEditingCell({ ...cell, draft: value === "-" ? "" : value }); + 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) { - if (editableCells.length) { - setSelectedCell(editableCells[0]); - } return; } - const keys = rowEditableCells.get(selectedCell.rowKey); - if (!keys || !keys.length) { + const rowCells = rowCellMap.get(selectedCell.rowKey) ?? []; + const idx = rowCells.indexOf(selectedCell.cellKey); + if (idx < 0) { return; } - const currentIndex = keys.indexOf(selectedCell.cellKey); - const nextIndex = Math.min(keys.length - 1, Math.max(0, currentIndex + direction)); - setSelectedCell({ rowKey: selectedCell.rowKey, cellKey: keys[nextIndex] }); + 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) { - if (editableCells.length) { - setSelectedCell(editableCells[0]); - } return; } const rowIndex = editableRowOrder.indexOf(selectedCell.rowKey); if (rowIndex < 0) { return; } - const targetRowIndex = rowIndex + direction; - if (targetRowIndex < 0 || targetRowIndex >= editableRowOrder.length) { + const targetIndex = rowIndex + direction; + if (targetIndex < 0 || targetIndex >= editableRowOrder.length) { return; } - const targetRowKey = editableRowOrder[targetRowIndex]; - const targetKeys = rowEditableCells.get(targetRowKey) ?? []; - if (!targetKeys.length) { + const targetRowKey = editableRowOrder[targetIndex]; + const targetCells = rowCellMap.get(targetRowKey) ?? []; + if (!targetCells.length) { return; } - const preferredColIndex = columns.findIndex((column) => column.key === selectedCell.cellKey); - let best = targetKeys[0]; + const preferred = columns.findIndex((col) => col.key === selectedCell.cellKey); + let best = targetCells[0]; let bestDistance = Number.POSITIVE_INFINITY; - for (const key of targetKeys) { - const idx = columns.findIndex((column) => column.key === key); - const distance = Math.abs(idx - preferredColIndex); - if (distance < bestDistance) { - bestDistance = distance; + 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 saveEditingCell(direction: SaveDirection = "stay") { - if (!editingCell) { - return; - } - const row = findRow(editingCell.rowKey); - if (!row || !row.circuit) { - setEditingCell(null); - return; - } - try { - setIsSaving(true); - setError(null); - const key = editingCell.cellKey; - const draft = editingCell.draft; - let nextSelection: SelectedCell | null = { rowKey: editingCell.rowKey, cellKey: editingCell.cellKey }; - - if (row.kind === "placeholder") { - const createdSelection = await createFromPlaceholder(row.sectionId, key, draft); - if (createdSelection) { - nextSelection = createdSelection; - } - } else if ((row.kind === "summary" || row.kind === "reserve") && isCircuitField(key)) { - await patchCircuit(row.circuit.id, key, draft); - } else if (row.kind === "reserve" && isDeviceField(key)) { - 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, key, draft); - nextSelection = { rowKey: `compact:${row.circuit.id}`, cellKey: key }; - } else if (row.kind === "device" && row.device && isDeviceField(key)) { - await patchDeviceRow(row.device.id, key, draft); - } else if (row.kind === "compact") { - if (row.device && isDeviceField(key)) { - await patchDeviceRow(row.device.id, key, draft); - } else if (isCircuitField(key)) { - await patchCircuit(row.circuit.id, key, draft); - } - } - - if (direction !== "stay" && nextSelection) { - const idx = editableCells.findIndex( - (cell) => cell.rowKey === nextSelection!.rowKey && cell.cellKey === nextSelection!.cellKey - ); - if (idx >= 0) { - const targetIdx = - direction === "next" - ? Math.min(editableCells.length - 1, idx + 1) - : Math.max(0, idx - 1); - nextSelection = editableCells[targetIdx]; - } - } - - setEditingCell(null); - pendingSelectionAfterReload.current = nextSelection; - await loadTree(); - requestAnimationFrame(() => containerRef.current?.focus()); - } catch (err) { - setError(normalizeUiError(err)); - } finally { - setIsSaving(false); - } - } - async function patchCircuit(circuitId: string, key: CellKey, draft: string) { const payload: Record = {}; - if (key === "isReserve") { - payload.isReserve = draft.toLowerCase() === "true" || draft === "1" || draft.toLowerCase() === "yes"; - } else if (["protectionRatedCurrent", "cableLength", "voltage"].includes(key)) { - payload[key] = parseNumeric(key, draft); - } else if (key === "circuitTotalPower" || key === "rowTotalPower") { - return; + if (key === "protectionSummary" || key === "cableSummary") { + payload.remark = draft.trim() === "" ? undefined : draft; } else { payload[key] = draft.trim() === "" ? undefined : draft; } @@ -573,15 +563,34 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str async function patchDeviceRow(rowId: string, key: CellKey, draft: string) { const payload: Record = {}; - if (["quantity", "powerPerUnit", "simultaneityFactor", "cosPhi"].includes(key)) { + 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 { + 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."); @@ -589,17 +598,17 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const next = await getNextCircuitIdentifier(sectionId); const sortOrder = section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10; - const isDevice = isDeviceField(key); - const baseCircuit = (await createCircuit(projectId, circuitListId, { + const isDeviceField = deviceFieldKeys.has(key); + const createdCircuit = (await createCircuit(projectId, circuitListId, { sectionId, equipmentIdentifier: next.nextIdentifier, displayName: "New circuit", sortOrder, - isReserve: !isDevice, + isReserve: !isDeviceField, })) as CircuitTreeCircuitDto; - if (isDevice) { - const createdRow = (await createCircuitDeviceRow(baseCircuit.id, { + if (isDeviceField) { + const createdRow = (await createCircuitDeviceRow(createdCircuit.id, { name: "Manual device", displayName: "Manual device", phaseType: "single_phase", @@ -609,11 +618,87 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str cosPhi: 1, })) as { id: string }; await patchDeviceRow(createdRow.id, key, draft); - return { rowKey: `compact:${baseCircuit.id}`, cellKey: key }; + return { rowKey: `circuitCompact:${createdCircuit.id}`, cellKey: key }; } - await patchCircuit(baseCircuit.id, key, draft); - return { rowKey: `reserve:${baseCircuit.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) { @@ -634,9 +719,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str sortOrder, isReserve: true, }); - await loadTree(); + await loadTree({ showLoading: false }); setActiveSectionId(sectionId); - requestAnimationFrame(() => containerRef.current?.focus()); } catch (err) { setError(normalizeUiError(err)); } finally { @@ -657,10 +741,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str simultaneityFactor: 1, cosPhi: 1, })) as { id: string }; - await loadTree(); + await loadTree({ showLoading: false }); setActiveSectionId(sectionId); setPendingFocus({ - rowKey: circuit.deviceRows.length === 0 ? `compact:${circuit.id}` : `device:${created.id}`, + rowKey: circuit.deviceRows.length === 0 ? `circuitCompact:${circuit.id}` : `device:${created.id}`, cellKey: "displayName", }); } catch (err) { @@ -678,7 +762,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str setError(null); setIsSaving(true); await deleteCircuitDeviceRowById(rowId); - await loadTree(); + await loadTree({ showLoading: false }); } catch (err) { setError(normalizeUiError(err)); } finally { @@ -694,7 +778,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str setError(null); setIsSaving(true); await deleteCircuitById(circuitId); - await loadTree(); + await loadTree({ showLoading: false }); } catch (err) { setError(normalizeUiError(err)); } finally { @@ -710,7 +794,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str setError(null); setIsSaving(true); await renumberCircuitSection(sectionId); - await loadTree(); + await loadTree({ showLoading: false }); } catch (err) { setError(normalizeUiError(err)); } finally { @@ -718,23 +802,24 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } } - function handleKeyDown(event: KeyboardEvent) { + function handleContainerFocus() { if (editingCell) { - if (event.key === "Tab") { - event.preventDefault(); + const row = findRow(editingCell.rowKey); + const cell = row?.cells.find((entry) => entry.cellKey === editingCell.cellKey); + if (!row || !cell || !cell.editable) { + setEditingCell(null); } return; } - if (event.key === "Tab" && selectedCell) { - event.preventDefault(); - const idx = editableCells.findIndex( - (cell) => cell.rowKey === selectedCell.rowKey && cell.cellKey === selectedCell.cellKey - ); - if (idx >= 0) { - const target = editableCells[ - event.shiftKey ? Math.max(0, idx - 1) : Math.min(editableCells.length - 1, idx + 1) - ]; - setSelectedCell(target); + if (!selectedCell && editableCells.length) { + setSelectedCell(editableCells[0]); + } + } + + function handleContainerKeyDown(event: KeyboardEvent) { + if (editingCell) { + if (event.key === "Tab") { + event.preventDefault(); } return; } @@ -749,47 +834,50 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } return; } - if (event.key === "ArrowRight") { + if (event.key === "Tab" && selectedCell) { event.preventDefault(); - moveHorizontal(1); - } else if (event.key === "ArrowLeft") { - event.preventDefault(); - moveHorizontal(-1); - } else if (event.key === "ArrowDown") { - event.preventDefault(); - moveVertical(1); - } else if (event.key === "ArrowUp") { - event.preventDefault(); - moveVertical(-1); - } else if (event.key === "Enter" || event.key === "F2") { - event.preventDefault(); - if (selectedCell) { - startEdit(selectedCell); - } - } else if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) { - if (selectedCell) { - event.preventDefault(); - startEdit(selectedCell, event.key); - } - } - } - - function handleContainerFocus() { - if (editingCell) { - const row = findRow(editingCell.rowKey); - if (!row || !canEditCell(row, editingCell.cellKey)) { - setEditingCell(null); - } else { - requestAnimationFrame(() => inputRef.current?.focus()); + 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 (!selectedCell && editableCells.length > 0) { - setSelectedCell(editableCells[0]); + 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) { + if (isLoading && !data) { return
Loading circuit tree editor...
; } if (error) { @@ -806,8 +894,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str className="tree-grid-wrap" ref={containerRef} tabIndex={0} - onKeyDown={handleKeyDown} onFocus={handleContainerFocus} + onKeyDown={handleContainerKeyDown} > @@ -821,8 +909,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str - {gridRows.map((row) => { - if (row.kind === "section") { + {visibleRows.map((row) => { + if (row.rowType === "section") { const section = data.sections.find((entry) => entry.id === row.sectionId)!; return ( @@ -830,10 +918,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str {section.displayName} @@ -841,108 +929,105 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str ); } - if (row.kind === "placeholder") { - return ( - - - - - ); - } - return ( setActiveSectionId(row.sectionId)} > {columns.map((column) => { - const selected = + const cell = row.cells.find((entry) => entry.cellKey === column.key)!; + const isSelected = selectedCell?.rowKey === row.rowKey && selectedCell.cellKey === column.key; - const editing = + const isEditing = editingCell?.rowKey === row.rowKey && editingCell.cellKey === column.key; - const editable = canEditCell(row, column.key); - const value = getCellValue(row, column.key); - return ( ); })} ); @@ -950,7 +1035,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
- -
-frei-read-only placeholder
setSelectedCell({ rowKey: row.rowKey, cellKey: column.key })} - onClickCapture={() => requestAnimationFrame(() => containerRef.current?.focus())} - onDoubleClick={() => startEdit({ rowKey: row.rowKey, cellKey: column.key })} - > - {editing ? ( - - setEditingCell((current) => - current ? { ...current, draft: event.target.value } : current - ) - } - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void saveEditingCell("stay"); - } else if (event.key === "Escape") { - event.preventDefault(); - setEditingCell(null); - setSelectedCell({ rowKey: row.rowKey, cellKey: column.key }); - requestAnimationFrame(() => containerRef.current?.focus()); + className={`${column.numeric ? "num" : ""} ${cell.editable ? "cell-editable" : ""} ${isSelected ? "cell-selected" : ""}`} + 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 saveEditingCell(event.shiftKey ? "prev" : "next"); + void commitEdit(event.shiftKey ? "prev" : "next"); } }} - onBlur={() => { - if (!editingCell) { - return; - } - requestAnimationFrame(() => { - const active = document.activeElement as HTMLElement | null; - const insideEditor = - !!active && !!containerRef.current && containerRef.current.contains(active); - if (!insideEditor) { + onBlur={() => { + requestAnimationFrame(() => { + const active = document.activeElement as HTMLElement | null; + if (!active || !containerRef.current?.contains(active)) { setEditingCell(null); + requestAnimationFrame(() => containerRef.current?.focus()); } }); }} /> ) : ( - formatValue(value) + formatValue(cell.value) )} - {row.circuit && row.kind !== "device" ? ( - <> - - - - ) : null} - {row.device ? ( - - ) : null} + {row.circuit && row.rowType !== "deviceRow" ? ( + <> + + + + ) : null} + {row.device ? ( + + ) : null}
-

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

+

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

); }