diff --git a/AGENTS.md b/AGENTS.md index 74a4528..920a747 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 533dfc7..8050cfe 100644 --- a/docs/circuit-list-editor-interactions.md +++ b/docs/circuit-list-editor-interactions.md @@ -1,5 +1,8 @@ # Circuit List Editor Interactions +The back-arrow link beside the page title returns to the project's distribution +board overview without changing circuit-list data. + ## Editing Model Inline cells are static text by default. A cell enters edit mode by: @@ -44,6 +47,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 +56,23 @@ 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. + +The dialog takes keyboard focus, keeps Tab/Shift+Tab within its actions and +closes on Escape without writing a value. Closing returns focus to the grid; +only "Übernehmen" saves the selected mean value. Opening the picker is disabled +while a write or history operation is running. Its cell button stays outside +the Tab order; `Alt+ArrowDown` provides keyboard access from the selected cell. + ## 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 fe81d07..b6128b0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1324,6 +1324,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/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx b/src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx index 89679cf..90893f6 100644 --- a/src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx +++ b/src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { ArrowLeft } from "lucide-react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { CircuitTreeEditor } from "../../../../../../frontend/components/circuit-tree-editor"; @@ -10,9 +11,19 @@ export default function CircuitTreeEditPage() { return (
-
-

Stromkreisliste

-

Stromkreise und zugeordnete Geräte bearbeiten.

+
+ +
diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 4023fe6..eb7c052 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -20,6 +20,7 @@ import { Plus, Redo2, ShieldPlus, + TableProperties, Trash2, Undo2, } from "lucide-react"; @@ -159,6 +160,7 @@ 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 { CircuitGroupModal } from "./circuit-group-modal"; import { CircuitProtectionModal } from "./circuit-protection-modal"; import { @@ -318,6 +320,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([]); @@ -1871,22 +1875,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) ) { @@ -1894,7 +1902,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") { @@ -1917,7 +1925,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str redo: async () => { const selection = await createFromPlaceholder( row.sectionId, - editingCell.cellKey, + session.cellKey, draft ); targetSelection = selection; @@ -1934,7 +1942,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(); }, }; @@ -1948,7 +1956,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(); }, }; @@ -1963,7 +1971,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, @@ -1983,7 +1991,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(); }, }; @@ -1992,6 +2000,45 @@ 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) { + if (isSaving || historyBusy || !isGzfPickerCell(target)) { + return; + } + 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) { @@ -2935,6 +2982,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(); @@ -3002,6 +3057,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); @@ -3169,6 +3229,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str onSave={handleSaveCircuitGroup} /> ) : null} + {gzfPickerCell ? ( + + ) : null} {protectionEditorCircuit ? ( { requestAnimationFrame(() => { const active = document.activeElement as HTMLElement | null; + if (active?.closest('[role="dialog"][aria-modal="true"]')) { + return; + } if (!active || !containerRef.current?.contains(active)) { setEditingCell(null); focusGridWithoutScroll(); @@ -4499,6 +4572,27 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }); }} /> + ) : column.key === "simultaneityFactor" && + cell.editable && + isSelected ? ( + <> + + {formatCellValue(cell.value, column.key)} + ) : ( formatCellValue(cell.value, column.key) )} diff --git a/src/frontend/components/gzf-picker-modal.tsx b/src/frontend/components/gzf-picker-modal.tsx index 5f118a6..c09ef4f 100644 --- a/src/frontend/components/gzf-picker-modal.tsx +++ b/src/frontend/components/gzf-picker-modal.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useId, useRef } from "react"; import { AMEV_GZF_TABLE } from "../../shared/constants/amev-gzf"; interface GzfPickerModalProps { @@ -12,14 +13,50 @@ function formatFactor(value: number) { } export function GzfPickerModal({ onClose, onSelect }: GzfPickerModalProps) { + const dialogRef = useRef(null); + const titleId = useId(); + + useEffect(() => { + // Wait for the inline editor's blur callback before taking keyboard focus. + const frame = requestAnimationFrame(() => { + dialogRef.current?.querySelector("button")?.focus(); + }); + return () => cancelAnimationFrame(frame); + }, []); + return ( <> -
+
{ + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onClose(); + } else if (event.key === "Tab") { + const buttons = dialogRef.current?.querySelectorAll("button:not(:disabled)"); + const first = buttons?.[0]; + const last = buttons?.[buttons.length - 1]; + if (event.shiftKey && (document.activeElement === first || document.activeElement === dialogRef.current)) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first?.focus(); + } + } + }} + >
-

Gleichzeitigkeitsfaktor nach AMEV

+

Gleichzeitigkeitsfaktor nach AMEV

Tabelle 3 – Gleichzeitigkeitsfaktoren für Verbrauchergruppen