diff --git a/docs/circuit-list-editor-interactions.md b/docs/circuit-list-editor-interactions.md index 7b1aabc..dfb5226 100644 --- a/docs/circuit-list-editor-interactions.md +++ b/docs/circuit-list-editor-interactions.md @@ -38,9 +38,16 @@ Selection, keyboard movement, and editability checks run against this normalized - arrow keys: move selected cell in grid when not editing - type-to-edit: printable keys open edit mode and replace current display value with typed character - `Ctrl+Plus` / `Ctrl+Shift+Plus`: insert below the selected circuit or device context +- `Delete`: delete the selected circuit or device context after confirmation For insertion, a circuit-level cell creates a reserve circuit directly after the selected circuit. A device-level cell creates a manual device row directly after the selected device. On the virtual `-frei-` row, or without a valid row selection, the shortcut creates a circuit at the end of the active section. Insertions use command history and never renumber existing equipment identifiers. +For deletion, a circuit-level cell targets the complete circuit and a device-level cell targets only the device row. Deleting the last device requires an explicit choice between keeping the empty circuit as reserve, deleting the complete circuit, or cancelling. Delete commands are undoable and never renumber remaining circuits. + +## Equipment Identifier Validation + +Equipment identifiers are compared case-insensitively after trimming whitespace. A conflicting edit is highlighted before submission and cannot be committed. Existing conflicts are highlighted in the grid. Validation errors remain dismissible above the editor instead of replacing the complete workspace. + ## `-frei-` Placeholder Behavior Each section has a trailing placeholder row showing `-frei-` in BMK column: diff --git a/package.json b/package.json index da706fa..0b08f30 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "start": "node dist/server/index.js", - "test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts", - "test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts", + "test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts", + "test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "node scripts/db-backup.js", diff --git a/src/app/globals.css b/src/app/globals.css index c1bfb11..999705f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 9b27eda..2629e89 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -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