From ced4917a860a00984f097599b848970241c91df8 Mon Sep 17 00:00:00 2001 From: Grovy311 Date: Sat, 26 Sep 2026 11:52:38 +0200 Subject: [PATCH] Offer the AMEV simultaneity-factor table in the circuit list The selected GZF cell shows a small table-icon button, and Alt+ArrowDown opens the same picker from the selected or edited cell; Escape closes it. The shared GzfPickerModal is reused. The picked mean value is committed through commitEdit with an explicit target cell, so it is a regular undoable edit on device rows, compact circuits, reserve circuits and the -frei- row. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 3 + docs/circuit-list-editor-interactions.md | 12 ++ src/app/globals.css | 32 +++++ .../components/circuit-tree-editor.tsx | 110 ++++++++++++++++-- 4 files changed, 145 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f1a509d..ce438ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,6 +164,9 @@ Keyboard behavior: - Escape cancels - Tab / Shift+Tab navigates editable cells - Arrow keys navigate cells when not editing +- Alt+ArrowDown on a GZF cell opens the AMEV table (`GzfPickerModal`); the + selected GZF cell also shows a small table-icon button. The picked value is + committed like a typed edit. Support Ctrl+Plus and Ctrl+Shift+Plus for insertion. diff --git a/docs/circuit-list-editor-interactions.md b/docs/circuit-list-editor-interactions.md index c8c02aa..6c793fc 100644 --- a/docs/circuit-list-editor-interactions.md +++ b/docs/circuit-list-editor-interactions.md @@ -44,6 +44,7 @@ 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 +- `Alt+ArrowDown` on a GZF cell (selected or editing): open the AMEV simultaneity-factor table - `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. @@ -52,6 +53,17 @@ For deletion, a circuit-level cell targets the complete circuit and a device-lev Creating or deleting a device row updates the row and the circuit reserve state in one SQLite transaction. +## AMEV Simultaneity Factor + +The selected GZF cell shows a small table-icon button; it and `Alt+ArrowDown` open +the shared `GzfPickerModal` (AMEV Planungshilfe, Tabelle 3), which the project +device dialog uses as well. "Übernehmen" commits the mean value of the chosen +consumer group through the same path as a typed edit (`commitEdit` with an +explicit target cell), so it is one undoable command and works on device rows, +compact circuits, reserve circuits and the `-frei-` row. A running inline edit +of the cell is discarded when the picker opens. The button only appears on the +selected cell; the grid keeps its static-text cells. + ## 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. diff --git a/src/app/globals.css b/src/app/globals.css index 22da5df..21b1050 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1230,6 +1230,38 @@ a.kpi:hover { outline-offset: -2px; } +/* Picker trigger on the selected GZF cell. Positioned over the cell so the + column width does not change while the selection moves. */ +.tree-grid td:has(> .cell-picker-trigger) { + position: relative; +} + +.tree-grid .cell-picker-trigger { + position: absolute; + top: 50%; + left: 0.2rem; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.15rem; + height: 1.15rem; + border: 1px solid var(--accent-blue-strong-border); + border-radius: 3px; + padding: 0; + background: var(--panel-bg); + color: var(--color-primary); + cursor: pointer; +} + +:root[data-bs-theme="dark"] .tree-grid .cell-picker-trigger { + color: var(--color-accent-pale); +} + +.tree-grid .cell-picker-trigger:hover { + background: var(--accent-blue-soft-bg); +} + .tree-grid .section-title { display: flex; align-items: baseline; diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 03aa38d..1e90d78 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -135,6 +135,8 @@ import type { CircuitSnapshot, } from "../../domain/models/circuit-structure-project-command.model"; import { DistributionBoardComponentModal } from "./distribution-board-component-modal"; +import { GzfPickerModal } from "./gzf-picker-modal"; +import { TableProperties } from "lucide-react"; import { CircuitGroupModal } from "./circuit-group-modal"; import { CircuitProtectionModal } from "./circuit-protection-modal"; import { @@ -276,6 +278,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str useState(null); const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] = useState(null); + // GZF cell the AMEV picker writes to; null while the picker is closed. + const [gzfPickerCell, setGzfPickerCell] = useState(null); const [protectionEditorCircuit, setProtectionEditorCircuit] = useState(null); const [projectDevices, setProjectDevices] = useState([]); @@ -1829,22 +1833,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } // Commits the active editor input through command history so edits remain undoable. + // `target` commits a value for a cell without an open inline editor + // (AMEV GZF picker); the regular edit path passes no target. async function commitEdit( direction: SaveDirection = "stay", - draftOverride?: string + draftOverride?: string, + target?: SelectedCell ) { - if (!editingCell) { + const session = target ?? editingCell; + if (!session) { return; } - const row = findRow(editingCell.rowKey); - const cell = row?.cells.find((entry) => entry.cellKey === editingCell.cellKey); + const row = findRow(session.rowKey); + const cell = row?.cells.find((entry) => entry.cellKey === session.cellKey); if (!row || !cell || !cell.editable) { setEditingCell(null); return; } - const draft = draftOverride ?? editingCell.draft; + const draft = draftOverride ?? editingCell?.draft ?? ""; if ( - editingCell.cellKey === "equipmentIdentifier" && + session.cellKey === "equipmentIdentifier" && row.circuit && hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, draft) ) { @@ -1852,7 +1860,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return; } - let targetSelection: SelectedCell = { rowKey: editingCell.rowKey, cellKey: editingCell.cellKey }; + let targetSelection: SelectedCell = { rowKey: session.rowKey, cellKey: session.cellKey }; const nextSelectionIntent = () => { let selected = targetSelection; if (direction !== "stay") { @@ -1875,7 +1883,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str redo: async () => { const selection = await createFromPlaceholder( row.sectionId, - editingCell.cellKey, + session.cellKey, draft ); targetSelection = selection; @@ -1892,7 +1900,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const command: HistoryCommand = { label: "Stromkreisfeld bearbeiten", redo: async () => { - await patchCircuit(row.circuit!.id, editingCell.cellKey, next); + await patchCircuit(row.circuit!.id, session.cellKey, next); return nextSelectionIntent(); }, }; @@ -1906,7 +1914,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const command: HistoryCommand = { label: "Gerätefeld bearbeiten", redo: async () => { - await patchDeviceRow(row.device!.id, editingCell.cellKey, next); + await patchDeviceRow(row.device!.id, session.cellKey, next); return nextSelectionIntent(); }, }; @@ -1921,7 +1929,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str label: "Gerätezeile im Reservestromkreis erstellen", redo: async () => { const editValues = createValuesFromEditPatch( - buildDeviceRowEditPatch(editingCell.cellKey, next) + buildDeviceRowEditPatch(session.cellKey, next) ); const snapshot = createDeviceRowSnapshot( row.circuit!.id, @@ -1941,7 +1949,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str snapshot, "Gerätezeile im Reservestromkreis erstellen" ); - targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey }; + targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: session.cellKey }; return nextSelectionIntent(); }, }; @@ -1950,6 +1958,42 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } } + function isGzfPickerCell(target: SelectedCell | null): target is SelectedCell { + return Boolean( + target && + target.cellKey === "simultaneityFactor" && + findCell(target.rowKey, target.cellKey)?.editable + ); + } + + // Opens the AMEV simultaneity-factor table for one GZF cell. A running + // inline edit is discarded because the picked value replaces it. + function openGzfPicker(target: SelectedCell) { + setEditingCell(null); + setSelectedCell(target); + setGzfPickerCell(target); + } + + function closeGzfPicker() { + setGzfPickerCell(null); + focusGridWithoutScroll(); + } + + // Commits the picked mean value like a typed edit (undoable command). + function handleGzfPicked(value: number) { + const target = gzfPickerCell; + setGzfPickerCell(null); + if (!target) { + return; + } + // parseNumeric accepts only the German decimal comma. + const draft = value.toLocaleString("de-DE", { + maximumFractionDigits: 2, + useGrouping: false, + }); + void commitEdit("stay", draft, target); + } + // Cancels current edit session and restores grid keyboard focus. function cancelEdit() { if (!editingCell) { @@ -2893,6 +2937,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str // - printable key starts overwrite edit (type-to-edit) // - Arrow/Tab move selectedCell when not editing function handleContainerKeyDown(event: KeyboardEvent) { + // The picker modal sits outside the grid; keep grid keys from acting behind it. + if (gzfPickerCell) { + if (event.key === "Escape") { + event.preventDefault(); + closeGzfPicker(); + } + return; + } if (editingCell) { if (event.key === "Tab") { event.preventDefault(); @@ -2960,6 +3012,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str moveHorizontal(-1); return; } + if (event.altKey && event.key === "ArrowDown" && isGzfPickerCell(selectedCell)) { + event.preventDefault(); + openGzfPicker(selectedCell); + return; + } if (event.key === "ArrowDown") { event.preventDefault(); moveVertical(1); @@ -3122,6 +3179,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str onSave={handleSaveCircuitGroup} /> ) : null} + {gzfPickerCell ? ( + + ) : null} {protectionEditorCircuit ? ( { @@ -4435,6 +4502,25 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }); }} /> + ) : column.key === "simultaneityFactor" && + cell.editable && + isSelected ? ( + <> + + {formatCellValue(cell.value, column.key)} + ) : ( formatCellValue(cell.value, column.key) )}