Compare commits

...

2 commits

Author SHA1 Message Date
Grovy311
ced4917a86 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 <noreply@anthropic.com>
2026-09-26 11:52:38 +02:00
Grovy311
db4a6c3a2e Add back arrow to the circuit list page
The arrow next to the "Stromkreisliste" heading returns to the project's
distribution board overview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 11:38:16 +02:00
5 changed files with 159 additions and 15 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

@ -44,6 +44,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 +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. 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 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

@ -1230,6 +1230,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

@ -135,6 +135,8 @@ 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 { TableProperties } from "lucide-react";
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 {
@ -276,6 +278,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[]>([]);
@ -1829,22 +1833,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)
) { ) {
@ -1852,7 +1860,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") {
@ -1875,7 +1883,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;
@ -1892,7 +1900,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();
}, },
}; };
@ -1906,7 +1914,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();
}, },
}; };
@ -1921,7 +1929,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,
@ -1941,7 +1949,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();
}, },
}; };
@ -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. // Cancels current edit session and restores grid keyboard focus.
function cancelEdit() { function cancelEdit() {
if (!editingCell) { if (!editingCell) {
@ -2893,6 +2937,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();
@ -2960,6 +3012,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);
@ -3122,6 +3179,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={
@ -4423,6 +4483,13 @@ 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={() => {
@ -4435,6 +4502,25 @@ 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"
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)
)} )}