Harden grid deletion and BMK validation

This commit is contained in:
2026-07-22 20:27:01 +02:00
parent ea61772e62
commit f8802fecdb
6 changed files with 300 additions and 12 deletions
+22
View File
@@ -382,6 +382,16 @@ body {
outline-offset: -2px;
}
.tree-grid .cell-invalid {
outline: 2px solid #c2410c;
outline-offset: -2px;
background: #fff7ed !important;
}
.tree-grid .cell-invalid input {
border-color: #c2410c;
}
.tree-grid input {
width: 100%;
min-width: 5rem;
@@ -486,6 +496,18 @@ body {
border-color: #f5b5b5;
}
.notice.warning {
background: #fff7ed;
border-color: #fdba74;
}
.editor-error-notice {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.notice.muted {
background: #f6f6f6;
border-color: #e4e4e4;
+116 -10
View File
@@ -9,6 +9,11 @@ import {
getInsertionSortOrder,
resolveGridInsertionIntent,
} from "../utils/circuit-grid-insertion";
import {
findDuplicateEquipmentIdentifierCircuitIds,
hasEquipmentIdentifierConflict,
resolveGridDeleteIntent,
} from "../utils/circuit-grid-safety";
import {
createCircuit,
createCircuitDeviceRow,
@@ -788,6 +793,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
);
}, [data]);
const allCircuits = useMemo(
() => data?.sections.flatMap((section) => section.circuits) ?? [],
[data]
);
const duplicateEquipmentIdentifierCircuitIds = useMemo(
() => new Set(findDuplicateEquipmentIdentifierCircuitIds(allCircuits)),
[allCircuits]
);
function makeRow(
rowType: RowType,
sectionId: string,
@@ -1495,6 +1509,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setEditingCell(null);
return;
}
if (
editingCell.cellKey === "equipmentIdentifier" &&
row.circuit &&
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, editingCell.draft)
) {
setError("Dieses Betriebsmittelkennzeichen ist bereits vorhanden. Die Änderung wurde nicht gespeichert.");
return;
}
let targetSelection: SelectedCell = { rowKey: editingCell.rowKey, cellKey: editingCell.cellKey };
const nextSelectionIntent = () => {
@@ -2377,16 +2399,32 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
// Delete undo recreates the row from captured snapshot because delete is destructive.
async function handleDeleteDevice(rowId: string) {
if (!confirm("Delete this device row?")) {
return;
}
const sourceCircuitId = findDeviceRowCircuitId(rowId);
const sourceCircuit = sourceCircuitId ? getCircuitSnapshot(sourceCircuitId) : null;
const rowSnapshot = sourceCircuit?.deviceRows.find((row) => row.id === rowId);
if (!sourceCircuitId || !rowSnapshot) {
if (!sourceCircuitId || !sourceCircuit || !rowSnapshot) {
setError("Invalid device row id.");
return;
}
if (sourceCircuit.deviceRows.length === 1) {
const keepAsReserve = confirm(
`"${rowSnapshot.displayName}" is the last device in ${sourceCircuit.equipmentIdentifier}.\n\n` +
"OK: delete the device and keep the empty circuit as reserve.\n" +
"Cancel: choose whether to delete the whole circuit instead."
);
if (!keepAsReserve) {
const deleteCircuit = confirm(
`Delete the complete circuit ${sourceCircuit.equipmentIdentifier}?\n\n` +
"OK: delete circuit and device.\nCancel: do not delete anything."
);
if (deleteCircuit) {
await handleDeleteCircuit(sourceCircuitId, false);
}
return;
}
} else if (!confirm(`Delete device row "${rowSnapshot.displayName}"?`)) {
return;
}
let recreatedRowId: string | null = null;
await runCommand({
label: "Delete device row",
@@ -2423,15 +2461,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
// Deletes a circuit and all child rows; undo recreates from captured snapshot.
async function handleDeleteCircuit(circuitId: string) {
if (!confirm("Delete this circuit and all assigned device rows?")) {
return;
}
async function handleDeleteCircuit(circuitId: string, askForConfirmation = true) {
const snapshot = getCircuitSnapshot(circuitId);
if (!snapshot) {
setError("Invalid circuit id.");
return;
}
if (
askForConfirmation &&
!confirm(
`Delete circuit ${snapshot.equipmentIdentifier} and ${snapshot.deviceRows.length} assigned device row(s)?\n\n` +
"Existing circuit identifiers will not be renumbered."
)
) {
return;
}
let recreatedCircuitId: string | null = null;
await runCommand({
label: "Delete circuit",
@@ -2447,6 +2491,29 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
});
}
async function handleDeleteSelection() {
const row = selectedCell ? findRow(selectedCell.rowKey) : null;
const cell = row?.cells.find((entry) => entry.cellKey === selectedCell?.cellKey);
const intent = resolveGridDeleteIntent(
row && cell && row.rowType !== "section"
? {
rowType: row.rowType,
cellKind: cell.kind,
circuitId: row.circuit?.id,
deviceId: row.device?.id,
}
: null
);
if (!intent) {
return;
}
if (intent.kind === "device") {
await handleDeleteDevice(intent.deviceId);
return;
}
await handleDeleteCircuit(intent.circuitId);
}
// Explicit renumber action. Undo restores previous BMKs via safe section identifier update.
async function handleRenumberSection(sectionId: string) {
if (!confirm("Renumber this section? Only circuits in this section will change.")) {
@@ -2506,6 +2573,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
return;
}
const eventTarget = event.target as HTMLElement;
if (eventTarget.closest("button, input, select, textarea")) {
return;
}
if (event.key === "Escape") {
if (selectedRowKeys.length > 0) {
event.preventDefault();
@@ -2536,6 +2607,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
void handleInsertBelowSelection();
return;
}
if (event.key === "Delete" && selectedCell) {
event.preventDefault();
void handleDeleteSelection();
return;
}
if (event.key === "Tab" && selectedCell) {
event.preventDefault();
const idx = editableCells.findIndex(
@@ -2583,7 +2659,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (isLoading && !data) {
return <div className="notice info">Loading circuit tree editor...</div>;
}
if (error) {
if (error && !data) {
return <div className="notice error">{error}</div>;
}
if (!data || data.sections.length === 0) {
@@ -2657,6 +2733,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</div>
) : null}
</div>
{error ? (
<div className="notice error editor-error-notice" role="alert">
<span>{error}</span>
<button type="button" onClick={() => setError(null)}>Dismiss</button>
</div>
) : null}
<div className="notice muted">No matching circuits for active filters.</div>
</div>
);
@@ -2773,6 +2855,17 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
{isSortedView && hasActiveFilters ? (
<div className="notice muted">Apply sorted order is disabled while filters are active.</div>
) : null}
{error ? (
<div className="notice error editor-error-notice" role="alert">
<span>{error}</span>
<button type="button" onClick={() => setError(null)}>Dismiss</button>
</div>
) : null}
{duplicateEquipmentIdentifierCircuitIds.size > 0 ? (
<div className="notice warning" role="alert">
Duplicate equipment identifiers are highlighted. Renumbering remains an explicit action.
</div>
) : null}
{isSaving ? <div className="notice info">Saving...</div> : null}
<div className="tree-editor-layout">
<aside className="project-device-sidebar">
@@ -3327,10 +3420,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
selectedCell?.rowKey === row.rowKey && selectedCell.cellKey === column.key;
const isEditing =
editingCell?.rowKey === row.rowKey && editingCell.cellKey === column.key;
const hasDraftIdentifierConflict = Boolean(
isEditing &&
column.key === "equipmentIdentifier" &&
row.circuit &&
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, editingCell?.draft ?? "")
);
const hasIdentifierConflict = Boolean(
column.key === "equipmentIdentifier" &&
row.circuit &&
(duplicateEquipmentIdentifierCircuitIds.has(row.circuit.id) || hasDraftIdentifierConflict)
);
return (
<td
key={column.key}
className={`${column.numeric ? "num" : ""} ${cell.editable ? "cell-editable" : ""} ${isSelected ? "cell-selected" : ""} ${
className={`${column.numeric ? "num" : ""} ${cell.editable ? "cell-editable" : ""} ${isSelected ? "cell-selected" : ""} ${hasIdentifierConflict ? "cell-invalid" : ""} ${
Boolean(row.device) && column.key === "displayName" && (row.rowType === "deviceRow" || row.rowType === "circuitCompact")
? "device-drag-handle"
: ""
@@ -3426,6 +3530,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
<input
ref={inputRef}
value={editingCell.draft}
aria-invalid={hasDraftIdentifierConflict}
title={hasDraftIdentifierConflict ? "Equipment identifier already exists." : undefined}
onChange={(event) =>
setEditingCell((current) =>
current ? { ...current, draft: event.target.value } : current
+74
View File
@@ -0,0 +1,74 @@
import type {
GridInsertionCellKind,
GridInsertionRowType,
} from "./circuit-grid-insertion.js";
export interface GridDeleteSelection {
rowType: GridInsertionRowType;
cellKind: GridInsertionCellKind;
circuitId?: string;
deviceId?: string;
}
export type GridDeleteIntent =
| { kind: "circuit"; circuitId: string }
| { kind: "device"; deviceId: string };
export interface CircuitIdentifierRecord {
id: string;
equipmentIdentifier: string;
}
export function resolveGridDeleteIntent(selection: GridDeleteSelection | null): GridDeleteIntent | null {
if (!selection || selection.rowType === "placeholder") {
return null;
}
const targetsDevice =
selection.rowType === "deviceRow" ||
(selection.rowType === "circuitCompact" && selection.cellKind === "deviceField");
if (targetsDevice && selection.deviceId) {
return { kind: "device", deviceId: selection.deviceId };
}
if (selection.circuitId) {
return { kind: "circuit", circuitId: selection.circuitId };
}
return null;
}
export function normalizeEquipmentIdentifier(value: string): string {
return value.trim().toUpperCase();
}
export function hasEquipmentIdentifierConflict(
circuits: CircuitIdentifierRecord[],
currentCircuitId: string,
candidate: string
): boolean {
const normalizedCandidate = normalizeEquipmentIdentifier(candidate);
if (!normalizedCandidate) {
return false;
}
return circuits.some(
(circuit) =>
circuit.id !== currentCircuitId &&
normalizeEquipmentIdentifier(circuit.equipmentIdentifier) === normalizedCandidate
);
}
export function findDuplicateEquipmentIdentifierCircuitIds(
circuits: CircuitIdentifierRecord[]
): string[] {
const idsByIdentifier = new Map<string, string[]>();
for (const circuit of circuits) {
const normalized = normalizeEquipmentIdentifier(circuit.equipmentIdentifier);
if (!normalized) {
continue;
}
const ids = idsByIdentifier.get(normalized) ?? [];
ids.push(circuit.id);
idsByIdentifier.set(normalized, ids);
}
return [...idsByIdentifier.values()].filter((ids) => ids.length > 1).flat();
}