Integrate circuit list navigation and AMEV cell picker

Preserve the ID fallback and compact editor controls. Add dialog keyboard focus, Tab containment and Escape handling; document navigation and picker behavior.

Validation: 418 tests, API/web builds, scripts and Next typechecks, isolated AMEV dialog checks and diff checks passed. Full GUI retest waived by the user based on the colleague's prior testing; the keyboard follow-up was checked in isolation.
This commit is contained in:
Julian Appel 2026-09-26 21:14:20 +02:00
commit 641513c2ef
6 changed files with 215 additions and 17 deletions

View file

@ -164,6 +164,9 @@ Keyboard behavior:
- Escape cancels - Escape cancels
- Tab / Shift+Tab navigates editable cells - Tab / Shift+Tab navigates editable cells
- Arrow keys navigate cells when not editing - 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. Support Ctrl+Plus and Ctrl+Shift+Plus for insertion.

View file

@ -1,5 +1,8 @@
# Circuit List Editor Interactions # 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 ## Editing Model
Inline cells are static text by default. A cell enters edit mode by: 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 - 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 - 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 - `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 - `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 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. 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 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. 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.

View file

@ -1324,6 +1324,38 @@ a.kpi:hover {
outline-offset: -2px; 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 { .tree-grid .section-title {
display: flex; display: flex;
align-items: baseline; align-items: baseline;

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { ArrowLeft } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { CircuitTreeEditor } from "../../../../../../frontend/components/circuit-tree-editor"; import { CircuitTreeEditor } from "../../../../../../frontend/components/circuit-tree-editor";
@ -10,9 +11,19 @@ export default function CircuitTreeEditPage() {
return ( return (
<main className="container-fluid px-3 px-xxl-4 py-4"> <main className="container-fluid px-3 px-xxl-4 py-4">
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3"> <div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
<div> <div className="d-flex align-items-center gap-2">
<h1 className="h4 mb-1">Stromkreisliste</h1> <Link
<p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p> aria-label="Zurück zur Verteilerübersicht"
className="btn btn-outline-secondary btn-sm"
href={`/projects/${params.projectId}`}
title="Zurück zur Verteilerübersicht"
>
<ArrowLeft aria-hidden="true" size={18} />
</Link>
<div>
<h1 className="h4 mb-1">Stromkreisliste</h1>
<p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p>
</div>
</div> </div>
<div className="d-flex gap-2"> <div className="d-flex gap-2">
<Link href={`/projects/${params.projectId}`} className="btn btn-outline-secondary btn-sm"> <Link href={`/projects/${params.projectId}`} className="btn btn-outline-secondary btn-sm">

View file

@ -20,6 +20,7 @@ import {
Plus, Plus,
Redo2, Redo2,
ShieldPlus, ShieldPlus,
TableProperties,
Trash2, Trash2,
Undo2, Undo2,
} from "lucide-react"; } from "lucide-react";
@ -159,6 +160,7 @@ import type {
CircuitSnapshot, CircuitSnapshot,
} from "../../domain/models/circuit-structure-project-command.model"; } from "../../domain/models/circuit-structure-project-command.model";
import { DistributionBoardComponentModal } from "./distribution-board-component-modal"; import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
import { GzfPickerModal } from "./gzf-picker-modal";
import { CircuitGroupModal } from "./circuit-group-modal"; import { CircuitGroupModal } from "./circuit-group-modal";
import { CircuitProtectionModal } from "./circuit-protection-modal"; import { CircuitProtectionModal } from "./circuit-protection-modal";
import { import {
@ -318,6 +320,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
useState<StructureComponentEditorIntent | null>(null); useState<StructureComponentEditorIntent | null>(null);
const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] = const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] =
useState<CircuitGroupEditorIntent | null>(null); useState<CircuitGroupEditorIntent | null>(null);
// GZF cell the AMEV picker writes to; null while the picker is closed.
const [gzfPickerCell, setGzfPickerCell] = useState<SelectedCell | null>(null);
const [protectionEditorCircuit, setProtectionEditorCircuit] = const [protectionEditorCircuit, setProtectionEditorCircuit] =
useState<CircuitTreeCircuitDto | null>(null); useState<CircuitTreeCircuitDto | null>(null);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]); const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
@ -1871,22 +1875,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
// Commits the active editor input through command history so edits remain undoable. // 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( async function commitEdit(
direction: SaveDirection = "stay", direction: SaveDirection = "stay",
draftOverride?: string draftOverride?: string,
target?: SelectedCell
) { ) {
if (!editingCell) { const session = target ?? editingCell;
if (!session) {
return; return;
} }
const row = findRow(editingCell.rowKey); const row = findRow(session.rowKey);
const cell = row?.cells.find((entry) => entry.cellKey === editingCell.cellKey); const cell = row?.cells.find((entry) => entry.cellKey === session.cellKey);
if (!row || !cell || !cell.editable) { if (!row || !cell || !cell.editable) {
setEditingCell(null); setEditingCell(null);
return; return;
} }
const draft = draftOverride ?? editingCell.draft; const draft = draftOverride ?? editingCell?.draft ?? "";
if ( if (
editingCell.cellKey === "equipmentIdentifier" && session.cellKey === "equipmentIdentifier" &&
row.circuit && row.circuit &&
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, draft) hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, draft)
) { ) {
@ -1894,7 +1902,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return; return;
} }
let targetSelection: SelectedCell = { rowKey: editingCell.rowKey, cellKey: editingCell.cellKey }; let targetSelection: SelectedCell = { rowKey: session.rowKey, cellKey: session.cellKey };
const nextSelectionIntent = () => { const nextSelectionIntent = () => {
let selected = targetSelection; let selected = targetSelection;
if (direction !== "stay") { if (direction !== "stay") {
@ -1917,7 +1925,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
redo: async () => { redo: async () => {
const selection = await createFromPlaceholder( const selection = await createFromPlaceholder(
row.sectionId, row.sectionId,
editingCell.cellKey, session.cellKey,
draft draft
); );
targetSelection = selection; targetSelection = selection;
@ -1934,7 +1942,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const command: HistoryCommand = { const command: HistoryCommand = {
label: "Stromkreisfeld bearbeiten", label: "Stromkreisfeld bearbeiten",
redo: async () => { redo: async () => {
await patchCircuit(row.circuit!.id, editingCell.cellKey, next); await patchCircuit(row.circuit!.id, session.cellKey, next);
return nextSelectionIntent(); return nextSelectionIntent();
}, },
}; };
@ -1948,7 +1956,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const command: HistoryCommand = { const command: HistoryCommand = {
label: "Gerätefeld bearbeiten", label: "Gerätefeld bearbeiten",
redo: async () => { redo: async () => {
await patchDeviceRow(row.device!.id, editingCell.cellKey, next); await patchDeviceRow(row.device!.id, session.cellKey, next);
return nextSelectionIntent(); return nextSelectionIntent();
}, },
}; };
@ -1963,7 +1971,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
label: "Gerätezeile im Reservestromkreis erstellen", label: "Gerätezeile im Reservestromkreis erstellen",
redo: async () => { redo: async () => {
const editValues = createValuesFromEditPatch( const editValues = createValuesFromEditPatch(
buildDeviceRowEditPatch(editingCell.cellKey, next) buildDeviceRowEditPatch(session.cellKey, next)
); );
const snapshot = createDeviceRowSnapshot( const snapshot = createDeviceRowSnapshot(
row.circuit!.id, row.circuit!.id,
@ -1983,7 +1991,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
snapshot, snapshot,
"Gerätezeile im Reservestromkreis erstellen" "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(); 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. // Cancels current edit session and restores grid keyboard focus.
function cancelEdit() { function cancelEdit() {
if (!editingCell) { if (!editingCell) {
@ -2935,6 +2982,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
// - printable key starts overwrite edit (type-to-edit) // - printable key starts overwrite edit (type-to-edit)
// - Arrow/Tab move selectedCell when not editing // - Arrow/Tab move selectedCell when not editing
function handleContainerKeyDown(event: KeyboardEvent<HTMLDivElement>) { function handleContainerKeyDown(event: KeyboardEvent<HTMLDivElement>) {
// 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 (editingCell) {
if (event.key === "Tab") { if (event.key === "Tab") {
event.preventDefault(); event.preventDefault();
@ -3002,6 +3057,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
moveHorizontal(-1); moveHorizontal(-1);
return; return;
} }
if (event.altKey && event.key === "ArrowDown" && isGzfPickerCell(selectedCell)) {
event.preventDefault();
openGzfPicker(selectedCell);
return;
}
if (event.key === "ArrowDown") { if (event.key === "ArrowDown") {
event.preventDefault(); event.preventDefault();
moveVertical(1); moveVertical(1);
@ -3169,6 +3229,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
onSave={handleSaveCircuitGroup} onSave={handleSaveCircuitGroup}
/> />
) : null} ) : null}
{gzfPickerCell ? (
<GzfPickerModal onClose={closeGzfPicker} onSelect={handleGzfPicked} />
) : null}
{protectionEditorCircuit ? ( {protectionEditorCircuit ? (
<CircuitProtectionModal <CircuitProtectionModal
equipmentIdentifier={ equipmentIdentifier={
@ -4487,11 +4550,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} else if (event.key === "Tab") { } else if (event.key === "Tab") {
event.preventDefault(); event.preventDefault();
void commitEdit(event.shiftKey ? "prev" : "next"); void commitEdit(event.shiftKey ? "prev" : "next");
} else if (
event.altKey &&
event.key === "ArrowDown" &&
column.key === "simultaneityFactor"
) {
event.preventDefault();
openGzfPicker({ rowKey: row.rowKey, cellKey: column.key });
} }
}} }}
onBlur={() => { onBlur={() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const active = document.activeElement as HTMLElement | null; const active = document.activeElement as HTMLElement | null;
if (active?.closest('[role="dialog"][aria-modal="true"]')) {
return;
}
if (!active || !containerRef.current?.contains(active)) { if (!active || !containerRef.current?.contains(active)) {
setEditingCell(null); setEditingCell(null);
focusGridWithoutScroll(); focusGridWithoutScroll();
@ -4499,6 +4572,27 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}); });
}} }}
/> />
) : column.key === "simultaneityFactor" &&
cell.editable &&
isSelected ? (
<>
<button
aria-label="Gleichzeitigkeitsfaktor nach AMEV wählen"
className="cell-picker-trigger"
disabled={isSaving || historyBusy}
tabIndex={-1}
onClick={(event) => {
event.stopPropagation();
openGzfPicker({ rowKey: row.rowKey, cellKey: column.key });
}}
onDoubleClick={(event) => event.stopPropagation()}
title="Gleichzeitigkeitsfaktor nach AMEV wählen (Alt+↓)"
type="button"
>
<TableProperties aria-hidden="true" size={13} />
</button>
{formatCellValue(cell.value, column.key)}
</>
) : ( ) : (
formatCellValue(cell.value, column.key) formatCellValue(cell.value, column.key)
)} )}

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { useEffect, useId, useRef } from "react";
import { AMEV_GZF_TABLE } from "../../shared/constants/amev-gzf"; import { AMEV_GZF_TABLE } from "../../shared/constants/amev-gzf";
interface GzfPickerModalProps { interface GzfPickerModalProps {
@ -12,14 +13,50 @@ function formatFactor(value: number) {
} }
export function GzfPickerModal({ onClose, onSelect }: GzfPickerModalProps) { export function GzfPickerModal({ onClose, onSelect }: GzfPickerModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const titleId = useId();
useEffect(() => {
// Wait for the inline editor's blur callback before taking keyboard focus.
const frame = requestAnimationFrame(() => {
dialogRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
});
return () => cancelAnimationFrame(frame);
}, []);
return ( return (
<> <>
<div aria-modal="true" className="modal fade show d-block" role="dialog" tabIndex={-1}> <div
aria-labelledby={titleId}
aria-modal="true"
className="modal fade show d-block"
ref={dialogRef}
role="dialog"
tabIndex={-1}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onClose();
} else if (event.key === "Tab") {
const buttons = dialogRef.current?.querySelectorAll<HTMLButtonElement>("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();
}
}
}}
>
<div className="modal-dialog modal-lg modal-dialog-centered"> <div className="modal-dialog modal-lg modal-dialog-centered">
<div className="modal-content"> <div className="modal-content">
<div className="modal-header"> <div className="modal-header">
<div> <div>
<h2 className="modal-title fs-5">Gleichzeitigkeitsfaktor nach AMEV</h2> <h2 className="modal-title fs-5" id={titleId}>Gleichzeitigkeitsfaktor nach AMEV</h2>
<p className="text-secondary small mb-0"> <p className="text-secondary small mb-0">
Tabelle 3 – Gleichzeitigkeitsfaktoren für Verbrauchergruppen Tabelle 3 – Gleichzeitigkeitsfaktoren für Verbrauchergruppen
</p> </p>