Bugfix editor

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