Bugfix editor

This commit is contained in:
2026-05-04 10:14:39 +02:00
parent ad498f2bb5
commit 567b719fa0
+195 -45
View File
@@ -61,6 +61,8 @@ interface EditingCell extends SelectedCell {
draft: string; draft: string;
} }
type SaveDirection = "stay" | "next" | "prev";
const columns: Array<{ key: CellKey; label: string; numeric?: boolean }> = [ const columns: Array<{ key: CellKey; label: string; numeric?: boolean }> = [
{ key: "equipmentIdentifier", label: "Equipment identifier" }, { key: "equipmentIdentifier", label: "Equipment identifier" },
{ key: "name", label: "Name" }, { key: "name", label: "Name" },
@@ -228,6 +230,17 @@ function parseNumeric(cellKey: CellKey, draft: string): number | undefined {
return parsed; 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.";
}
if (message.includes("Invalid number")) {
return "Bitte einen gültigen Zahlenwert eingeben.";
}
return message;
}
export function CircuitTreeEditor(props: { projectId: string; circuitListId: string }) { export function CircuitTreeEditor(props: { projectId: string; circuitListId: string }) {
const { projectId, circuitListId } = props; const { projectId, circuitListId } = props;
const [data, setData] = useState<CircuitTreeResponseDto | null>(null); const [data, setData] = useState<CircuitTreeResponseDto | null>(null);
@@ -238,7 +251,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [activeSectionId, setActiveSectionId] = useState<string | null>(null); const [activeSectionId, setActiveSectionId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [pendingFocus, setPendingFocus] = useState<SelectedCell | null>(null); const [pendingFocus, setPendingFocus] = useState<SelectedCell | null>(null);
const pendingSelectionAfterReload = useRef<SelectedCell | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
async function loadTree() { async function loadTree() {
setIsLoading(true); setIsLoading(true);
@@ -246,10 +261,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
try { try {
const tree = await getCircuitTree(projectId, circuitListId); const tree = await getCircuitTree(projectId, circuitListId);
setData(tree); setData(tree);
if (pendingSelectionAfterReload.current) {
setSelectedCell(pendingSelectionAfterReload.current);
pendingSelectionAfterReload.current = null;
}
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Could not load circuit tree."); setError(normalizeUiError(err));
} finally { } finally {
setIsLoading(false); setIsLoading(false);
requestAnimationFrame(() => containerRef.current?.focus());
} }
} }
@@ -310,10 +330,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return; return;
} }
setSelectedCell(pendingFocus); setSelectedCell(pendingFocus);
setEditingCell({ ...pendingFocus, draft: "" }); startEdit(pendingFocus);
setPendingFocus(null); setPendingFocus(null);
requestAnimationFrame(() => containerRef.current?.focus());
}, [pendingFocus]); }, [pendingFocus]);
useEffect(() => {
if (!editingCell) {
return;
}
requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
}, [editingCell]);
const editableCells = useMemo(() => { const editableCells = useMemo(() => {
const cells: SelectedCell[] = []; const cells: SelectedCell[] = [];
for (const row of gridRows) { for (const row of gridRows) {
@@ -326,6 +357,22 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return cells; return cells;
}, [gridRows]); }, [gridRows]);
const rowEditableCells = useMemo(() => {
const map = new Map<string, CellKey[]>();
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]
);
function findRow(rowKey: string) { function findRow(rowKey: string) {
return gridRows.find((row) => row.rowKey === rowKey); return gridRows.find((row) => row.rowKey === rowKey);
} }
@@ -337,9 +384,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (cellKey === "rowTotalPower" || cellKey === "circuitTotalPower") { if (cellKey === "rowTotalPower" || cellKey === "circuitTotalPower") {
return false; return false;
} }
if (row.kind === "summary" || row.kind === "reserve") { if (row.kind === "summary") {
return isCircuitField(cellKey); return isCircuitField(cellKey);
} }
if (row.kind === "reserve") {
return isCircuitField(cellKey) || isDeviceField(cellKey);
}
if (row.kind === "device") { if (row.kind === "device") {
return isDeviceField(cellKey); return isDeviceField(cellKey);
} }
@@ -378,24 +428,57 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setEditingCell({ ...cell, draft: value === "-" ? "" : value }); setEditingCell({ ...cell, draft: value === "-" ? "" : value });
} }
function moveSelection(offset: number) { function moveHorizontal(direction: 1 | -1) {
if (!selectedCell) { if (!selectedCell) {
if (editableCells.length > 0) { if (editableCells.length) {
setSelectedCell(editableCells[0]); setSelectedCell(editableCells[0]);
} }
return; return;
} }
const index = editableCells.findIndex( const keys = rowEditableCells.get(selectedCell.rowKey);
(cell) => cell.rowKey === selectedCell.rowKey && cell.cellKey === selectedCell.cellKey if (!keys || !keys.length) {
);
if (index < 0) {
return; return;
} }
const nextIndex = Math.min(editableCells.length - 1, Math.max(0, index + offset)); const currentIndex = keys.indexOf(selectedCell.cellKey);
setSelectedCell(editableCells[nextIndex]); const nextIndex = Math.min(keys.length - 1, Math.max(0, currentIndex + direction));
setSelectedCell({ rowKey: selectedCell.rowKey, cellKey: keys[nextIndex] });
} }
async function saveEditingCell(nextCell?: SelectedCell | null) { 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) {
return;
}
const targetRowKey = editableRowOrder[targetRowIndex];
const targetKeys = rowEditableCells.get(targetRowKey) ?? [];
if (!targetKeys.length) {
return;
}
const preferredColIndex = columns.findIndex((column) => column.key === selectedCell.cellKey);
let best = targetKeys[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;
best = key;
}
}
setSelectedCell({ rowKey: targetRowKey, cellKey: best });
}
async function saveEditingCell(direction: SaveDirection = "stay") {
if (!editingCell) { if (!editingCell) {
return; return;
} }
@@ -409,9 +492,22 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError(null); setError(null);
const key = editingCell.cellKey; const key = editingCell.cellKey;
const draft = editingCell.draft; const draft = editingCell.draft;
let nextSelection: SelectedCell | null = { rowKey: editingCell.rowKey, cellKey: editingCell.cellKey };
if ((row.kind === "summary" || row.kind === "reserve") && isCircuitField(key)) { if ((row.kind === "summary" || row.kind === "reserve") && isCircuitField(key)) {
await patchCircuit(row.circuit.id, key, draft); 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)) { } else if (row.kind === "device" && row.device && isDeviceField(key)) {
await patchDeviceRow(row.device.id, key, draft); await patchDeviceRow(row.device.id, key, draft);
} else if (row.kind === "compact") { } else if (row.kind === "compact") {
@@ -422,13 +518,25 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
} }
setEditingCell(null); if (direction !== "stay" && nextSelection) {
await loadTree(); const idx = editableCells.findIndex(
if (nextCell) { (cell) => cell.rowKey === nextSelection!.rowKey && cell.cellKey === nextSelection!.cellKey
setSelectedCell(nextCell); );
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) { } catch (err) {
setError(err instanceof Error ? err.message : "Save failed."); setError(normalizeUiError(err));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -478,8 +586,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}); });
await loadTree(); await loadTree();
setActiveSectionId(sectionId); setActiveSectionId(sectionId);
requestAnimationFrame(() => containerRef.current?.focus());
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to add reserve circuit."); setError(normalizeUiError(err));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -505,7 +614,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
cellKey: "displayName", cellKey: "displayName",
}); });
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to add device row."); setError(normalizeUiError(err));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -521,7 +630,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await deleteCircuitDeviceRowById(rowId); await deleteCircuitDeviceRowById(rowId);
await loadTree(); await loadTree();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete device row."); setError(normalizeUiError(err));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -537,7 +646,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await deleteCircuitById(circuitId); await deleteCircuitById(circuitId);
await loadTree(); await loadTree();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete circuit."); setError(normalizeUiError(err));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -553,7 +662,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await renumberCircuitSection(sectionId); await renumberCircuitSection(sectionId);
await loadTree(); await loadTree();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to renumber section."); setError(normalizeUiError(err));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -561,6 +670,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) { function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (editingCell) { if (editingCell) {
if (event.key === "Tab") {
event.preventDefault();
}
return; return;
} }
const isCtrlPlus = const isCtrlPlus =
@@ -576,16 +688,16 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
if (event.key === "ArrowRight") { if (event.key === "ArrowRight") {
event.preventDefault(); event.preventDefault();
moveSelection(1); moveHorizontal(1);
} else if (event.key === "ArrowLeft") { } else if (event.key === "ArrowLeft") {
event.preventDefault(); event.preventDefault();
moveSelection(-1); moveHorizontal(-1);
} else if (event.key === "ArrowDown") { } else if (event.key === "ArrowDown") {
event.preventDefault(); event.preventDefault();
moveSelection(1); moveVertical(1);
} else if (event.key === "ArrowUp") { } else if (event.key === "ArrowUp") {
event.preventDefault(); event.preventDefault();
moveSelection(-1); moveVertical(-1);
} else if (event.key === "Enter" || event.key === "F2") { } else if (event.key === "Enter" || event.key === "F2") {
event.preventDefault(); event.preventDefault();
if (selectedCell) { if (selectedCell) {
@@ -599,6 +711,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
} }
function handleContainerFocus() {
if (editingCell) {
const row = findRow(editingCell.rowKey);
if (!row || !canEditCell(row, editingCell.cellKey)) {
setEditingCell(null);
} else {
requestAnimationFrame(() => inputRef.current?.focus());
}
return;
}
if (!selectedCell && editableCells.length > 0) {
setSelectedCell(editableCells[0]);
}
}
if (isLoading) { if (isLoading) {
return <div className="notice info">Loading circuit tree editor...</div>; return <div className="notice info">Loading circuit tree editor...</div>;
} }
@@ -612,7 +739,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return ( return (
<div className="tree-editor-shell"> <div className="tree-editor-shell">
{isSaving ? <div className="notice info">Saving...</div> : null} {isSaving ? <div className="notice info">Saving...</div> : null}
<div className="tree-grid-wrap" ref={containerRef} tabIndex={0} onKeyDown={handleKeyDown}> <div
className="tree-grid-wrap"
ref={containerRef}
tabIndex={0}
onKeyDown={handleKeyDown}
onFocus={handleContainerFocus}
>
<table className="tree-grid"> <table className="tree-grid">
<thead> <thead>
<tr> <tr>
@@ -673,11 +806,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
key={column.key} key={column.key}
className={`${column.numeric ? "num" : ""} ${selected ? "cell-selected" : ""} ${editable ? "cell-editable" : ""}`} className={`${column.numeric ? "num" : ""} ${selected ? "cell-selected" : ""} ${editable ? "cell-editable" : ""}`}
onClick={() => setSelectedCell({ rowKey: row.rowKey, cellKey: column.key })} onClick={() => setSelectedCell({ rowKey: row.rowKey, cellKey: column.key })}
onClickCapture={() => requestAnimationFrame(() => containerRef.current?.focus())}
onDoubleClick={() => startEdit({ rowKey: row.rowKey, cellKey: column.key })} onDoubleClick={() => startEdit({ rowKey: row.rowKey, cellKey: column.key })}
> >
{editing ? ( {editing ? (
<input <input
autoFocus ref={inputRef}
value={editingCell.draft} value={editingCell.draft}
onChange={(event) => onChange={(event) =>
setEditingCell((current) => setEditingCell((current) =>
@@ -687,27 +821,31 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault(); event.preventDefault();
void saveEditingCell(selectedCell); void saveEditingCell("stay");
} else if (event.key === "Escape") { } else if (event.key === "Escape") {
event.preventDefault(); event.preventDefault();
setEditingCell(null); setEditingCell(null);
setSelectedCell({ rowKey: row.rowKey, cellKey: column.key });
requestAnimationFrame(() => containerRef.current?.focus());
} else if (event.key === "Tab") { } else if (event.key === "Tab") {
event.preventDefault(); event.preventDefault();
const idx = editableCells.findIndex( void saveEditingCell(event.shiftKey ? "prev" : "next");
(cell) =>
cell.rowKey === editingCell.rowKey && cell.cellKey === editingCell.cellKey
);
const next =
idx >= 0
? editableCells[
event.shiftKey
? Math.max(0, idx - 1)
: Math.min(editableCells.length - 1, idx + 1)
]
: null;
void saveEditingCell(next);
} }
}} }}
onBlur={() => {
if (!editingCell) {
return;
}
requestAnimationFrame(() => {
const active = document.activeElement as HTMLElement | null;
const insideEditor =
!!active && !!containerRef.current && containerRef.current.contains(active);
if (!insideEditor) {
setEditingCell(null);
requestAnimationFrame(() => containerRef.current?.focus());
}
});
}}
/> />
) : ( ) : (
formatValue(value) formatValue(value)
@@ -718,16 +856,28 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
<td className="action-cell"> <td className="action-cell">
{row.circuit && row.kind !== "device" ? ( {row.circuit && row.kind !== "device" ? (
<> <>
<button type="button" onClick={() => void handleAddManualDevice(row.circuit!, row.sectionId)}> <button
type="button"
tabIndex={editingCell ? -1 : 0}
onClick={() => void handleAddManualDevice(row.circuit!, row.sectionId)}
>
Add manual device Add manual device
</button> </button>
<button type="button" onClick={() => void handleDeleteCircuit(row.circuit!.id)}> <button
type="button"
tabIndex={editingCell ? -1 : 0}
onClick={() => void handleDeleteCircuit(row.circuit!.id)}
>
Delete circuit Delete circuit
</button> </button>
</> </>
) : null} ) : null}
{row.device ? ( {row.device ? (
<button type="button" onClick={() => void handleDeleteDevice(row.device!.id)}> <button
type="button"
tabIndex={editingCell ? -1 : 0}
onClick={() => void handleDeleteDevice(row.device!.id)}
>
Delete device Delete device
</button> </button>
) : null} ) : null}