"use client"; import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"; import { inferProjectDeviceSectionKey, isProjectDevicePlacementValid, } from "../../domain/services/project-device-placement.service"; import { getInsertionSortOrder, resolveGridInsertionIntent, } from "../utils/circuit-grid-insertion"; import { findDuplicateEquipmentIdentifierCircuitIds, hasEquipmentIdentifierConflict, requiresCrossSectionMoveConfirmation, resolveGridDeleteIntent, } from "../utils/circuit-grid-safety"; import { allColumns, buildCircuitEditPatch, buildDeviceRowEditPatch, defaultVisibleColumnKeys, deviceFieldKeys, formatValue, } from "../utils/circuit-grid-model"; import { buildCircuitDeviceRowInsertSnapshot, buildCircuitInsertSnapshot, } from "../utils/circuit-structure-command"; import { buildCircuitDeviceRowMoveAssignments, } from "../utils/circuit-device-row-move-command"; import { buildCircuitSectionReorderAssignments, } from "../utils/circuit-section-reorder-command"; import { buildCircuitSectionRenumberAssignments, } from "../utils/circuit-section-renumber-command"; import type { CellKey, CellKind, ColumnDef, RowType, } from "../utils/circuit-grid-model"; import { buildVisibleGridRows, filterAndSortCircuitSections, getDistinctFilterValues, } from "../utils/circuit-grid-projection"; import type { VisibleGridRow } from "../utils/circuit-grid-projection"; import { deleteCircuitCommand, deleteCircuitDeviceRowCommand, getCircuitTree, getNextCircuitIdentifier, insertCircuitCommand, insertCircuitDeviceRowCommand, listProjectDevices, moveCircuitDeviceRowsCommand, moveCircuitDeviceRowsToNewCircuitCommand, reorderCircuitSectionCommand, reorderCircuitSectionsCommand, renumberCircuitSectionCommand, redoProjectCommand, updateCircuitById, updateCircuitDeviceRowById, undoProjectCommand, } from "../utils/api"; import type { CircuitTreeCircuitDto, CircuitTreeResponseDto, CreateCircuitDeviceRowInputDto, CreateCircuitInputDto, ProjectCommandResultDto, ProjectDeviceDto, } from "../types"; import type { CircuitDeviceRowSnapshot, } from "../../domain/models/circuit-device-row-structure-project-command.model"; import type { CircuitSnapshot, } from "../../domain/models/circuit-structure-project-command.model"; type SaveDirection = "stay" | "next" | "prev"; type StartEditMode = "selectExisting" | "replaceWithTypedChar"; type SortDirection = "asc" | "desc"; interface SelectedCell { rowKey: string; cellKey: CellKey; } interface EditingCell extends SelectedCell { draft: string; mode: StartEditMode; focusToken: number; } interface SelectionIntent { rowKey: string; cellKey: CellKey; rowType: RowType; sectionId: string; circuitId?: string; deviceId?: string; } interface HistoryCommand { label: string; redo: () => Promise; undo: () => Promise; persistent?: { undoIntent: () => SelectionIntent | null; redoIntent: () => SelectionIntent | null; }; } type ProjectDeviceDropIntent = | { kind: "new-circuit"; sectionId: string; valid: boolean } | { kind: "add-to-circuit"; circuitId: string; sectionId: string; valid: boolean }; type DeviceRowMoveDropIntent = | { kind: "move-to-circuit"; circuitId: string; sectionId: string; requiresConfirmation: boolean } | { kind: "move-to-new-circuit"; sectionId: string; requiresConfirmation: boolean }; type CircuitReorderDropIntent = | { kind: "before-circuit"; sectionId: string; targetCircuitId: string; valid: boolean } | { kind: "after-circuit"; sectionId: string; targetCircuitId: string; valid: boolean } | { kind: "section-end"; sectionId: string; valid: boolean }; const COLUMN_LAYOUT_STORAGE_KEY = "circuitTreeEditor.columnLayout.v1"; function normalizeUiError(err: unknown): string { const message = err instanceof Error ? err.message : "Der Vorgang ist fehlgeschlagen."; try { const parsed = JSON.parse(message) as { error?: string }; if (parsed.error?.includes("Duplicate equipmentIdentifier")) { return "Das Betriebsmittelkennzeichen ist in dieser Stromkreisliste bereits vorhanden."; } if (parsed.error) { return parsed.error; } } catch { // ignore } if (message.includes("Duplicate equipmentIdentifier")) { return "Das Betriebsmittelkennzeichen ist in dieser Stromkreisliste bereits vorhanden."; } if (message.includes("Invalid number") || message.includes("Ungültiger Zahlenwert")) { return "Bitte einen gültigen Zahlenwert eingeben."; } return message; } function formatPhaseTypeLabel(value: string): string { if (value === "three_phase") { return "Dreiphasig"; } if (value === "single_phase") { return "Einphasig"; } return value; } function isPrintableKey(event: KeyboardEvent) { return event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey; } function createValuesFromEditPatch( patch: Record ): Record { return Object.fromEntries( Object.entries(patch).filter( ([, value]) => value !== null && value !== "" ) ); } export function CircuitTreeEditor(props: { projectId: string; circuitListId: string }) { /* Circuit-tree editor invariant overview: - Data model is circuit-first (section -> circuit -> device rows). - Rendered table rows are virtual projection rows, not direct DB rows. - A circuit may appear as compact row, summary+device rows, reserve row, or placeholder row. - Keyboard navigation and drag/drop targeting must use normalized visible rows, not raw tree nesting. */ const { projectId, circuitListId } = props; const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); // selectedCell is spreadsheet-style keyboard focus target within normalized grid. // It can exist without an active edit input. const [selectedCell, setSelectedCell] = useState(null); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [anchorRowKey, setAnchorRowKey] = useState(null); // editingCell is mounted input session state (draft + mode + focus token). // It should only exist for currently editable grid cells. const [editingCell, setEditingCell] = useState(null); const [activeSectionId, setActiveSectionId] = useState(null); const [isSaving, setIsSaving] = useState(false); const [projectDevices, setProjectDevices] = useState([]); const [projectDeviceSearch, setProjectDeviceSearch] = useState(""); const [selectedProjectDeviceId, setSelectedProjectDeviceId] = useState(null); const [targetSectionId, setTargetSectionId] = useState(null); const [targetCircuitId, setTargetCircuitId] = useState(null); // Drag intent states stay separated by domain intent to avoid cross-type accidental operations. // project-device drag => create/append linked rows, device-row drag => move rows, // circuit drag => reorder circuit blocks, column drag => layout-only changes. const [draggingProjectDeviceId, setDraggingProjectDeviceId] = useState(null); const [dropIntent, setDropIntent] = useState(null); const [draggingDeviceRowId, setDraggingDeviceRowId] = useState(null); const [draggingDeviceRowIds, setDraggingDeviceRowIds] = useState([]); const [deviceMoveIntent, setDeviceMoveIntent] = useState(null); const [draggingCircuitId, setDraggingCircuitId] = useState(null); const [draggingCircuitIds, setDraggingCircuitIds] = useState([]); const [circuitReorderIntent, setCircuitReorderIntent] = useState(null); // The visible stack is session-local during the cutover. Circuit/device field // commands already use the persistent project-wide server history. const [undoStack, setUndoStack] = useState([]); const [redoStack, setRedoStack] = useState([]); const [historyBusy, setHistoryBusy] = useState(false); const [sortState, setSortState] = useState<{ key: CellKey; direction: SortDirection } | null>(null); const [columnFilters, setColumnFilters] = useState>>({}); const [openFilterColumn, setOpenFilterColumn] = useState(null); const [filterDraftValues, setFilterDraftValues] = useState([]); const [filterValueSearch, setFilterValueSearch] = useState(""); const [visibleColumnKeys, setVisibleColumnKeys] = useState(defaultVisibleColumnKeys); const [columnOrder, setColumnOrder] = useState(allColumns.map((column) => column.key)); const [isColumnMenuOpen, setIsColumnMenuOpen] = useState(false); const [columnSearch, setColumnSearch] = useState(""); const [draggingColumnKey, setDraggingColumnKey] = useState(null); const [columnDropTargetKey, setColumnDropTargetKey] = useState(null); const [pendingFocus, setPendingFocus] = useState(null); const pendingSelectionAfterReload = useRef(null); const pendingSelectedDeviceRowIdsAfterReload = useRef(null); const pendingSelectedCircuitIdsAfterReload = useRef(null); const containerRef = useRef(null); const inputRef = useRef(null); const focusTokenRef = useRef(1); const projectRevisionRef = useRef(null); const orderedColumns = useMemo( () => columnOrder.map((key) => allColumns.find((column) => column.key === key)).filter(Boolean) as ColumnDef[], [columnOrder] ); const visibleColumns = useMemo( () => orderedColumns.filter((column) => visibleColumnKeys.includes(column.key)), [orderedColumns, visibleColumnKeys] ); async function loadTree(options?: { showLoading?: boolean }) { const showLoading = options?.showLoading ?? false; if (showLoading) { setIsLoading(true); } setError(null); try { const tree = await getCircuitTree(projectId, circuitListId); projectRevisionRef.current = tree.currentRevision; setData(tree); } catch (err) { setError(normalizeUiError(err)); } finally { if (showLoading) { setIsLoading(false); } } } function normalizeColumnOrder(keys: CellKey[]) { const unique = [...new Set(keys)]; const allKeys = allColumns.map((column) => column.key); const merged = [...unique, ...allKeys.filter((key) => !unique.includes(key))]; return ["equipmentIdentifier" as CellKey, ...merged.filter((key) => key !== "equipmentIdentifier")]; } // Initial/identity-change tree load for current route context. useEffect(() => { void loadTree({ showLoading: true }); }, [projectId, circuitListId]); // Loads project-device palette used for sidebar drag/drop and quick inserts. useEffect(() => { async function loadProjectDeviceList() { try { const list = await listProjectDevices(projectId); setProjectDevices(list); } catch (err) { setError(normalizeUiError(err)); } } void loadProjectDeviceList(); }, [projectId]); useEffect(() => { try { const raw = localStorage.getItem(COLUMN_LAYOUT_STORAGE_KEY); if (!raw) { return; } const parsed = JSON.parse(raw) as { order?: string[]; visible?: string[] }; const validKeys = new Set(allColumns.map((column) => column.key)); const parsedOrder = (parsed.order ?? []).filter((key): key is CellKey => validKeys.has(key as CellKey)); const parsedVisible = (parsed.visible ?? []).filter((key): key is CellKey => validKeys.has(key as CellKey)); if (!parsedOrder.length || !parsedVisible.length || !parsedVisible.includes("equipmentIdentifier")) { return; } setColumnOrder(normalizeColumnOrder(parsedOrder)); setVisibleColumnKeys(parsedVisible); } catch { // ignore invalid local storage and use defaults } }, []); useEffect(() => { const payload = { order: columnOrder, visible: visibleColumnKeys, }; localStorage.setItem(COLUMN_LAYOUT_STORAGE_KEY, JSON.stringify(payload)); }, [columnOrder, visibleColumnKeys]); useEffect(() => { const visible = new Set(visibleColumnKeys); setSortState((current) => { if (!current) { return current; } return visible.has(current.key) ? current : null; }); setColumnFilters((current) => { const next: Partial> = {}; for (const [key, values] of Object.entries(current)) { if (visible.has(key as CellKey) && values && values.length > 0) { next[key as CellKey] = values; } } return next; }); setOpenFilterColumn((current) => (current && visible.has(current) ? current : null)); }, [visibleColumnKeys]); const hasActiveFilters = useMemo( () => Object.values(columnFilters).some((values) => (values?.length ?? 0) > 0), [columnFilters] ); const activeColumnFilters = useMemo( () => visibleColumns.flatMap((column) => { const values = columnFilters[column.key] ?? []; return values.length > 0 ? [{ column, values }] : []; }), [columnFilters, visibleColumns] ); const distinctValuesByColumn = useMemo(() => { // Filter options are generated only for visible columns to match current header UI. // Hidden columns still exist in row model, but are intentionally not filterable from header. return getDistinctFilterValues(data?.sections ?? [], visibleColumns); }, [data, visibleColumns]); const filteredSortedSections = useMemo(() => { // Filtering and sorting are frontend view state. Backend sort order remains unchanged // until user explicitly applies sorted order. return filterAndSortCircuitSections(data?.sections ?? [], columnFilters, sortState); }, [data, columnFilters, sortState]); // visibleRows is the single normalized grid used by render + navigation + editability. // This avoids selected/rendered/editable state drifting apart after filters/sorts/reloads. const visibleRows = useMemo(() => { return buildVisibleGridRows(filteredSortedSections); }, [filteredSortedSections]); function clearColumnFilter(key: CellKey) { setColumnFilters((current) => { const next = { ...current }; delete next[key]; return next; }); setOpenFilterColumn((current) => (current === key ? null : current)); } function closeColumnFilterMenu() { setOpenFilterColumn(null); setFilterDraftValues([]); setFilterValueSearch(""); } function toggleColumnFilterMenu(key: CellKey) { if (openFilterColumn === key) { closeColumnFilterMenu(); return; } const availableValues = distinctValuesByColumn[key] ?? []; const activeValues = columnFilters[key] ?? []; setFilterDraftValues(activeValues.length > 0 ? activeValues : availableValues); setFilterValueSearch(""); setOpenFilterColumn(key); } function applyColumnFilter(key: CellKey) { const availableValues = distinctValuesByColumn[key] ?? []; const availableSet = new Set(availableValues); const selectedValues = [...new Set(filterDraftValues)].filter((value) => availableSet.has(value)); if (selectedValues.length === 0) { return; } if (selectedValues.length === availableValues.length) { clearColumnFilter(key); } else { setColumnFilters((current) => ({ ...current, [key]: selectedValues })); } closeColumnFilterMenu(); } function clearSortAndFilters() { setSortState(null); setColumnFilters({}); closeColumnFilterMenu(); } function renderActiveViewSummary() { if (!sortState && activeColumnFilters.length === 0) { return null; } const sortedColumn = sortState ? allColumns.find((column) => column.key === sortState.key) : undefined; return (
Sortierung und Filter {sortState ? ( ) : null} {activeColumnFilters.map(({ column, values }) => { const shownValues = values.slice(0, 2).join(", "); const valueSummary = values.length > 2 ? `${shownValues} +${values.length - 2}` : shownValues; return ( ); })}
); } function closeColumnSettingsMenu() { setIsColumnMenuOpen(false); setColumnSearch(""); } function toggleColumnSettingsMenu() { setIsColumnMenuOpen((current) => { if (current) { setColumnSearch(""); } return !current; }); } function renderColumnSettingsMenu(keyPrefix: string) { if (!isColumnMenuOpen) { return null; } const search = columnSearch.trim().toLocaleLowerCase("de-DE"); const matchingColumns = orderedColumns.filter((column) => column.label.toLocaleLowerCase("de-DE").includes(search) ); return (
Spalten auswählen
Spalten ein- oder ausblenden. Die Reihenfolge lässt sich mit den Pfeilen oder per Ziehen ändern.
setColumnSearch(event.target.value)} placeholder="Spalten suchen …" aria-label="Spalten suchen" autoFocus />
{matchingColumns.map((column) => { const visible = visibleColumnKeys.includes(column.key); return (
handleColumnDragStart(event, column.key)} onDragOver={(event) => handleColumnDragOver(event, column.key)} onDrop={(event) => handleColumnDrop(event, column.key)} onDragEnd={() => handleColumnDragEnd()} >
); })} {matchingColumns.length === 0 ?
Keine passende Spalte gefunden.
: null}
); } const searchableProjectDevices = useMemo(() => { const term = projectDeviceSearch.trim().toLowerCase(); if (!term) { return projectDevices; } return projectDevices.filter((device) => { const haystack = `${device.name} ${device.displayName}`.toLowerCase(); return haystack.includes(term); }); }, [projectDeviceSearch, projectDevices]); const circuitOptions = useMemo(() => { if (!data) { return [] as Array<{ id: string; label: string; sectionId: string }>; } return data.sections.flatMap((section) => section.circuits.map((circuit) => ({ id: circuit.id, sectionId: section.id, label: `${circuit.equipmentIdentifier} - ${circuit.displayName || "Stromkreis"}`, })) ); }, [data]); const allCircuits = useMemo( () => data?.sections.flatMap((section) => section.circuits) ?? [], [data] ); const duplicateEquipmentIdentifierCircuitIds = useMemo( () => new Set(findDuplicateEquipmentIdentifierCircuitIds(allCircuits)), [allCircuits] ); const editableCells = useMemo( () => visibleRows.flatMap((row) => row.cells .filter((cell) => cell.editable) .map((cell) => ({ rowKey: row.rowKey, cellKey: cell.cellKey as CellKey })) ), [visibleRows] ); // Map each visible row to editable cells currently on screen. // This keeps Tab/arrow navigation aligned with hidden/reordered columns. const rowCellMap = useMemo(() => { const visibleKeySet = new Set(visibleColumnKeys); const map = new Map(); for (const row of visibleRows) { const keys = row.cells .filter((cell) => cell.editable && visibleKeySet.has(cell.cellKey)) .map((cell) => cell.cellKey as CellKey); if (keys.length) { map.set(row.rowKey, keys); } } return map; }, [visibleRows, visibleColumnKeys]); const editableRowOrder = useMemo( () => visibleRows.filter((row) => rowCellMap.has(row.rowKey)).map((row) => row.rowKey), [visibleRows, rowCellMap] ); const selectableRowOrder = useMemo( () => visibleRows .filter( (row) => row.rowType === "circuitCompact" || row.rowType === "circuitSummary" || row.rowType === "deviceRow" || row.rowType === "reserveCircuit" ) .map((row) => row.rowKey), [visibleRows] ); useEffect(() => { if (!selectedCell && editableCells.length > 0) { setSelectedCell(editableCells[0]); return; } if (selectedCell) { const valid = editableCells.some( (cell) => cell.rowKey === selectedCell.rowKey && cell.cellKey === selectedCell.cellKey ); if (!valid) { setSelectedCell(editableCells[0] ?? null); } } }, [editableCells, selectedCell]); useEffect(() => { setSelectedRowKeys((current) => current.filter((rowKey) => selectableRowOrder.includes(rowKey))); setAnchorRowKey((current) => (current && selectableRowOrder.includes(current) ? current : null)); }, [selectableRowOrder]); function isSelectableRowType(rowType: RowType) { return ( rowType === "circuitCompact" || rowType === "circuitSummary" || rowType === "deviceRow" || rowType === "reserveCircuit" ); } function selectRowRange(targetRowKey: string) { const anchor = anchorRowKey ?? selectedRowKeys[selectedRowKeys.length - 1] ?? targetRowKey; const start = selectableRowOrder.indexOf(anchor); const end = selectableRowOrder.indexOf(targetRowKey); if (start < 0 || end < 0) { setSelectedRowKeys([targetRowKey]); setAnchorRowKey(targetRowKey); return; } const from = Math.min(start, end); const to = Math.max(start, end); setSelectedRowKeys(selectableRowOrder.slice(from, to + 1)); setAnchorRowKey(anchor); } // Applies cell/row selection semantics. Row selection is UI-only state and later interpreted // as either device-row batch selection or circuit batch selection depending on drag context. function handleRowSelectionClick( row: VisibleGridRow, cellKey: CellKey, options?: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean } ) { if (isSelectableRowType(row.rowType)) { const ctrlOrMeta = Boolean(options?.ctrlKey || options?.metaKey); if (options?.shiftKey) { selectRowRange(row.rowKey); } else if (ctrlOrMeta) { setSelectedRowKeys((current) => { if (current.includes(row.rowKey)) { return current.filter((key) => key !== row.rowKey); } return [...current, row.rowKey]; }); setAnchorRowKey(row.rowKey); } else { setSelectedRowKeys([row.rowKey]); setAnchorRowKey(row.rowKey); } } else if (!options?.ctrlKey && !options?.metaKey && !options?.shiftKey) { setSelectedRowKeys([]); setAnchorRowKey(null); } setSelectedCell({ rowKey: row.rowKey, cellKey }); focusGridWithoutScroll(); } // Captures semantic identity for post-reload focus restoration. function buildSelectionIntent(cell: SelectedCell): SelectionIntent | null { const row = findRow(cell.rowKey); if (!row) { return null; } return { rowKey: cell.rowKey, cellKey: cell.cellKey, rowType: row.rowType, sectionId: row.sectionId, circuitId: row.circuit?.id, deviceId: row.device?.id, }; } // Resolves selection after writes with fallback chain: // exact cell -> same device -> same circuit rowType -> same circuit -> same section -> first editable. function resolveSelectionIntent(intent: SelectionIntent): SelectedCell | null { const direct = editableCells.find( (cell) => cell.rowKey === intent.rowKey && cell.cellKey === intent.cellKey ); if (direct) { return direct; } const rowById = visibleRows.find((row) => { if (intent.deviceId && row.device?.id === intent.deviceId) { return true; } if (intent.circuitId && row.circuit?.id === intent.circuitId && row.rowType === intent.rowType) { return true; } return false; }); if (rowById) { const rowCells = rowCellMap.get(rowById.rowKey) ?? []; if (rowCells.includes(intent.cellKey)) { return { rowKey: rowById.rowKey, cellKey: intent.cellKey }; } if (rowCells.length > 0) { return { rowKey: rowById.rowKey, cellKey: rowCells[0] }; } } const inSameCircuit = visibleRows.find((row) => intent.circuitId && row.circuit?.id === intent.circuitId); if (inSameCircuit) { const cells = rowCellMap.get(inSameCircuit.rowKey) ?? []; if (cells.length > 0) { return { rowKey: inSameCircuit.rowKey, cellKey: cells[0] }; } } const inSameSection = visibleRows.find((row) => row.sectionId === intent.sectionId && rowCellMap.has(row.rowKey)); if (inSameSection) { const cells = rowCellMap.get(inSameSection.rowKey) ?? []; if (cells.length > 0) { return { rowKey: inSameSection.rowKey, cellKey: cells[0] }; } } return editableCells[0] ?? null; } // Reload helper for write paths. Stores pending intent because row/cell keys can change after reload. async function reloadWithIntent(intent?: SelectionIntent | null) { if (intent) { pendingSelectionAfterReload.current = intent; } await loadTree({ showLoading: false }); if (!intent) { focusGridWithoutScroll(); } } function getExpectedProjectRevision() { if (projectRevisionRef.current === null) { throw new Error("Der Projektstand ist noch nicht geladen."); } return projectRevisionRef.current; } function applyProjectCommandResult(result: ProjectCommandResultDto) { projectRevisionRef.current = result.history.currentRevision; setData((current) => current ? { ...current, currentRevision: result.history.currentRevision, } : current ); } function createDeviceRowSnapshot( circuitId: string, values: CreateCircuitDeviceRowInputDto, defaultSortOrder: number ) { return buildCircuitDeviceRowInsertSnapshot({ id: crypto.randomUUID(), circuitId, values, defaultSortOrder, }); } function createCircuitSnapshot( values: CreateCircuitInputDto, deviceRowValues: CreateCircuitDeviceRowInputDto[] = [] ) { const circuitId = crypto.randomUUID(); const deviceRows = deviceRowValues.map((row, index) => createDeviceRowSnapshot(circuitId, row, (index + 1) * 10) ); return buildCircuitInsertSnapshot({ id: circuitId, circuitListId, values, deviceRows, }); } async function persistCircuitInsert( circuit: CircuitSnapshot, description: string ) { const result = await insertCircuitCommand( projectId, getExpectedProjectRevision(), circuit, description ); applyProjectCommandResult(result); } async function persistCircuitDelete( circuitId: string, description: string ) { const result = await deleteCircuitCommand( projectId, getExpectedProjectRevision(), circuitId, circuitListId, description ); applyProjectCommandResult(result); } async function persistDeviceRowInsert( row: CircuitDeviceRowSnapshot, description: string ) { const result = await insertCircuitDeviceRowCommand( projectId, getExpectedProjectRevision(), row, description ); applyProjectCommandResult(result); } async function persistDeviceRowDelete( rowId: string, circuitId: string, description: string ) { const result = await deleteCircuitDeviceRowCommand( projectId, getExpectedProjectRevision(), rowId, circuitId, description ); applyProjectCommandResult(result); } // Runs a normal command and records it in session-local history. async function runCommand(command: HistoryCommand) { try { setError(null); setIsSaving(true); const intent = await command.redo(); await reloadWithIntent(intent ?? null); // Any new forward command invalidates redo history branch. setUndoStack((current) => [...current, command]); setRedoStack([]); } catch (err) { setError(normalizeUiError(err)); } finally { setIsSaving(false); } } // Replays command in undo/redo mode. Commands encapsulate their own inverse logic. async function applyHistory(command: HistoryCommand, mode: "undo" | "redo") { try { setError(null); setHistoryBusy(true); setIsSaving(true); let intent: SelectionIntent | null | void; if (command.persistent) { const result = mode === "undo" ? await undoProjectCommand( projectId, getExpectedProjectRevision() ) : await redoProjectCommand( projectId, getExpectedProjectRevision() ); applyProjectCommandResult(result); intent = mode === "undo" ? command.persistent.undoIntent() : command.persistent.redoIntent(); } else { intent = mode === "undo" ? await command.undo() : await command.redo(); } await reloadWithIntent(intent ?? null); if (mode === "undo") { setUndoStack((current) => current.slice(0, -1)); setRedoStack((current) => [...current, command]); } else { setRedoStack((current) => current.slice(0, -1)); setUndoStack((current) => [...current, command]); } } catch (err) { setError(normalizeUiError(err)); } finally { setIsSaving(false); setHistoryBusy(false); } } // Undo command from session-local history stack. async function handleUndo() { if (historyBusy || isSaving || undoStack.length === 0) { return; } const command = undoStack[undoStack.length - 1]; await applyHistory(command, "undo"); } // Redo command from session-local history stack. async function handleRedo() { if (historyBusy || isSaving || redoStack.length === 0) { return; } const command = redoStack[redoStack.length - 1]; await applyHistory(command, "redo"); } // Persists currently sorted block order into section sortOrder values. async function handleApplySortedOrder() { if (!sortState || hasActiveFilters || !data) { return; } const beforeBySection = data.sections.map((section) => ({ sectionId: section.id, order: section.circuits.map((circuit) => circuit.id), })); const afterBySection = filteredSortedSections.map((section) => ({ sectionId: section.id, order: section.circuits.map((circuit) => circuit.id), })); const changedSections = afterBySection.filter((after) => { const before = beforeBySection.find((entry) => entry.sectionId === after.sectionId); if (!before) { return false; } if (before.order.length !== after.order.length) { return false; } return before.order.some((id, index) => id !== after.order[index]); }); if (changedSections.length === 0) { setSortState(null); return; } const reorderedSections = changedSections.map((changedSection) => { const currentSection = data.sections.find( (section) => section.id === changedSection.sectionId ); if (!currentSection) { throw new Error("Ein sortierter Bereich wurde nicht gefunden."); } return { sectionId: currentSection.id, assignments: buildCircuitSectionReorderAssignments( currentSection.circuits, changedSection.order ), }; }); // Sorting is view-only until explicitly applied. All affected sections are // then committed as one atomic project-history entry. await runCommand({ label: "Sortierte Reihenfolge übernehmen", redo: async () => { const result = await reorderCircuitSectionsCommand( projectId, getExpectedProjectRevision(), reorderedSections, "Sortierte Reihenfolge übernehmen" ); applyProjectCommandResult(result); setSortState(null); setOpenFilterColumn(null); return null; }, undo: async () => null, persistent: { undoIntent: () => null, redoIntent: () => null, }, }); } // Toggles visible columns while keeping full column model intact for data/edit mapping. function toggleColumnVisibility(key: CellKey) { const column = allColumns.find((entry) => entry.key === key); if (!column || column.locked) { return; } setVisibleColumnKeys((current) => { if (current.includes(key)) { return current.filter((entry) => entry !== key); } return [...current, key]; }); } function moveColumn(key: CellKey, direction: -1 | 1) { if (key === "equipmentIdentifier") { return; } setColumnOrder((current) => { const index = current.indexOf(key); if (index < 0) { return current; } const nextIndex = index + direction; if (nextIndex <= 0 || nextIndex >= current.length) { return current; } const clone = [...current]; const [item] = clone.splice(index, 1); clone.splice(nextIndex, 0, item); return normalizeColumnOrder(clone); }); } function resetColumns() { setVisibleColumnKeys(defaultVisibleColumnKeys); setColumnOrder(normalizeColumnOrder(allColumns.map((column) => column.key))); setOpenFilterColumn(null); } // Column drag affects layout state only and never mutates circuit/device data. function moveColumnByDrag(dragKey: CellKey, targetKey: CellKey) { if (dragKey === "equipmentIdentifier" || targetKey === "equipmentIdentifier") { return; } setColumnOrder((current) => { const from = current.indexOf(dragKey); const to = current.indexOf(targetKey); if (from < 1 || to < 1 || from === to) { return current; } const clone = [...current]; const [item] = clone.splice(from, 1); const targetIndex = clone.indexOf(targetKey); if (targetIndex < 1) { return current; } clone.splice(targetIndex, 0, item); return normalizeColumnOrder(clone); }); } function handleColumnDragStart(event: DragEvent, key: CellKey) { if (key === "equipmentIdentifier") { event.preventDefault(); return; } setDraggingColumnKey(key); setColumnDropTargetKey(null); event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("application/x-column-key", key); } function handleColumnDragOver(event: DragEvent, targetKey: CellKey) { if (!draggingColumnKey || targetKey === "equipmentIdentifier" || draggingColumnKey === targetKey) { return; } event.preventDefault(); event.dataTransfer.dropEffect = "move"; setColumnDropTargetKey(targetKey); } function handleColumnDrop(event: DragEvent, targetKey: CellKey) { event.preventDefault(); const dragKey = draggingColumnKey; if (!dragKey || dragKey === targetKey) { setColumnDropTargetKey(null); return; } moveColumnByDrag(dragKey, targetKey); setDraggingColumnKey(null); setColumnDropTargetKey(null); } function handleColumnDragEnd() { setDraggingColumnKey(null); setColumnDropTargetKey(null); } // Apply deferred selection after async reload only once visible grid is rebuilt. useEffect(() => { const intent = pendingSelectionAfterReload.current; if (!intent || !data) { return; } const resolved = resolveSelectionIntent(intent); pendingSelectionAfterReload.current = null; if (resolved) { setSelectedCell(resolved); } focusGridWithoutScroll(); }, [data, editableCells, visibleRows]); // Restores multi-selected device rows after operations that rebuild row keys. useEffect(() => { const pending = pendingSelectedDeviceRowIdsAfterReload.current; if (!pending || pending.length === 0) { return; } const rowKeys: string[] = []; for (const row of visibleRows) { if ((row.rowType === "deviceRow" || row.rowType === "circuitCompact") && row.device?.id && pending.includes(row.device.id)) { rowKeys.push(row.rowKey); } } if (rowKeys.length > 0) { setSelectedRowKeys(rowKeys); setAnchorRowKey(rowKeys[0]); } pendingSelectedDeviceRowIdsAfterReload.current = null; }, [visibleRows]); // Restores multi-selected circuits after reorder/delete/recreate operations. useEffect(() => { const pending = pendingSelectedCircuitIdsAfterReload.current; if (!pending || pending.length === 0) { return; } const rowKeys: string[] = []; for (const row of visibleRows) { if ( (row.rowType === "circuitCompact" || row.rowType === "circuitSummary" || row.rowType === "reserveCircuit") && row.circuit?.id && pending.includes(row.circuit.id) ) { rowKeys.push(row.rowKey); } } if (rowKeys.length > 0) { setSelectedRowKeys(rowKeys); setAnchorRowKey(rowKeys[0]); } pendingSelectedCircuitIdsAfterReload.current = null; }, [visibleRows]); // PendingFocus is used by toolbar actions that should immediately enter edit mode on a target cell. useEffect(() => { if (!pendingFocus) { return; } setSelectedCell(pendingFocus); startEdit(pendingFocus, "selectExisting"); setPendingFocus(null); }, [pendingFocus]); useEffect(() => { if (!isColumnMenuOpen) { setDraggingColumnKey(null); setColumnDropTargetKey(null); } }, [isColumnMenuOpen]); useEffect(() => { if (!editingCell) { return; } requestAnimationFrame(() => { if (!inputRef.current) { return; } inputRef.current.focus(); if (editingCell.mode === "selectExisting") { inputRef.current.select(); } else { const len = inputRef.current.value.length; inputRef.current.setSelectionRange(len, len); } }); }, [editingCell?.focusToken]); // Finds row in normalized grid by virtual row key. function findRow(rowKey: string) { return visibleRows.find((row) => row.rowKey === rowKey); } // Finds cell in normalized grid row. function findCell(rowKey: string, cellKey: CellKey) { const row = findRow(rowKey); return row?.cells.find((cell) => cell.cellKey === cellKey); } function focusGridWithoutScroll() { requestAnimationFrame(() => containerRef.current?.focus({ preventScroll: true })); } // Opens edit session for selected cell. // Enter/F2/double-click use selectExisting; type-to-edit uses replaceWithTypedChar. function startEdit(cell: SelectedCell, mode: StartEditMode, typedChar?: string) { const visibleCell = findCell(cell.rowKey, cell.cellKey); if (!visibleCell || !visibleCell.editable) { return; } // Spreadsheet behavior: typing on a selected cell starts overwrite mode. const currentDisplay = mode === "replaceWithTypedChar" ? typedChar ?? "" : String(visibleCell.value ?? ""); focusTokenRef.current += 1; setEditingCell({ ...cell, mode, draft: currentDisplay === "-" ? "" : currentDisplay, focusToken: focusTokenRef.current, }); } // Horizontal keyboard navigation constrained to editable cells in current row. function moveHorizontal(direction: 1 | -1) { if (!selectedCell) { return; } const rowCells = rowCellMap.get(selectedCell.rowKey) ?? []; const idx = rowCells.indexOf(selectedCell.cellKey); if (idx < 0) { return; } const next = rowCells[Math.min(rowCells.length - 1, Math.max(0, idx + direction))]; setSelectedCell({ rowKey: selectedCell.rowKey, cellKey: next }); } // Vertical keyboard navigation preserves nearest visible column when row cell sets differ. function moveVertical(direction: 1 | -1) { if (!selectedCell) { return; } const rowIndex = editableRowOrder.indexOf(selectedCell.rowKey); if (rowIndex < 0) { return; } const targetIndex = rowIndex + direction; if (targetIndex < 0 || targetIndex >= editableRowOrder.length) { return; } const targetRowKey = editableRowOrder[targetIndex]; const targetCells = rowCellMap.get(targetRowKey) ?? []; if (!targetCells.length) { return; } const preferred = visibleColumns.findIndex((col) => col.key === selectedCell.cellKey); let best = targetCells[0]; let bestDistance = Number.POSITIVE_INFINITY; for (const key of targetCells) { const idx = visibleColumns.findIndex((col) => col.key === key); const dist = Math.abs(idx - preferred); if (dist < bestDistance) { bestDistance = dist; best = key; } } setSelectedCell({ rowKey: targetRowKey, cellKey: best }); } // Maps grid edits to typed persistent project commands. async function patchCircuit(circuitId: string, key: CellKey, draft: string) { const result = await updateCircuitById( projectId, getExpectedProjectRevision(), circuitId, buildCircuitEditPatch(key, draft) ); applyProjectCommandResult(result); } // Device-row commands derive local ProjectDevice override metadata server-side. async function patchDeviceRow(rowId: string, key: CellKey, draft: string) { const result = await updateCircuitDeviceRowById( projectId, getExpectedProjectRevision(), rowId, buildDeviceRowEditPatch(key, draft) ); applyProjectCommandResult(result); } // Creates a real circuit from virtual placeholder row. // Device-field edits create circuit + first manual row; circuit-field edits patch circuit directly. async function createFromPlaceholder( sectionId: string, key: CellKey, draft: string ): Promise { const section = data?.sections.find((entry) => entry.id === sectionId); if (!section) { throw new Error("Bereich wurde nicht gefunden."); } const next = await getNextCircuitIdentifier(sectionId); const sortOrder = section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10; const isDeviceField = deviceFieldKeys.has(key); if (isDeviceField) { const editValues = createValuesFromEditPatch( buildDeviceRowEditPatch(key, draft) ); const circuit = createCircuitSnapshot( { sectionId, equipmentIdentifier: next.nextIdentifier, displayName: "Neuer Stromkreis", sortOrder, isReserve: false, }, [ { name: "Manuelles Gerät", displayName: "Manuelles Gerät", phaseType: "single_phase", quantity: 1, powerPerUnit: 0, simultaneityFactor: 1, cosPhi: 1, ...editValues, }, ] ); await persistCircuitInsert(circuit, "Aus freier Zeile erstellen"); return { rowKey: `circuitCompact:${circuit.id}`, cellKey: key }; } const editValues = createValuesFromEditPatch( buildCircuitEditPatch(key, draft) ); const circuit = createCircuitSnapshot({ sectionId, equipmentIdentifier: next.nextIdentifier, displayName: "Neuer Stromkreis", sortOrder, isReserve: true, ...editValues, }); await persistCircuitInsert(circuit, "Aus freier Zeile erstellen"); return { rowKey: `reserveCircuit:${circuit.id}`, cellKey: key }; } // Commits the active editor input through command history so edits remain undoable. async function commitEdit(direction: SaveDirection = "stay") { if (!editingCell) { return; } const row = findRow(editingCell.rowKey); const cell = row?.cells.find((entry) => entry.cellKey === editingCell.cellKey); if (!row || !cell || !cell.editable) { 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 = () => { let selected = targetSelection; if (direction !== "stay") { const idx = editableCells.findIndex( (entry) => entry.rowKey === selected.rowKey && entry.cellKey === selected.cellKey ); if (idx >= 0) { const targetIdx = direction === "next" ? Math.min(editableCells.length - 1, idx + 1) : Math.max(0, idx - 1); selected = editableCells[targetIdx]; } } return buildSelectionIntent(selected); }; // Placeholder rows are not persisted entities; editing them materializes a real circuit first. if (row.rowType === "placeholder") { let createdCircuitId: string | null = null; const command: HistoryCommand = { label: "Aus freier Zeile erstellen", redo: async () => { const selection = await createFromPlaceholder(row.sectionId, editingCell.cellKey, editingCell.draft); targetSelection = selection; const createdRow = selection.rowKey.startsWith("circuitCompact:") ? selection.rowKey.replace("circuitCompact:", "") : selection.rowKey.replace("reserveCircuit:", ""); createdCircuitId = createdRow; return nextSelectionIntent(); }, undo: async () => { return { rowKey: `placeholder:${row.sectionId}`, cellKey: editingCell.cellKey, rowType: "placeholder", sectionId: row.sectionId, }; }, persistent: { undoIntent: () => ({ rowKey: `placeholder:${row.sectionId}`, cellKey: editingCell.cellKey, rowType: "placeholder", sectionId: row.sectionId, }), redoIntent: () => createdCircuitId ? { rowKey: targetSelection.rowKey, cellKey: editingCell.cellKey, rowType: targetSelection.rowKey.startsWith("circuitCompact:") ? "circuitCompact" : "reserveCircuit", sectionId: row.sectionId, circuitId: createdCircuitId, } : null, }, }; setEditingCell(null); await runCommand(command); return; } if (cell.kind === "circuitField" && row.circuit) { const next = editingCell.draft; const command: HistoryCommand = { label: "Stromkreisfeld bearbeiten", redo: async () => { await patchCircuit(row.circuit!.id, editingCell.cellKey, next); return nextSelectionIntent(); }, undo: async () => buildSelectionIntent({ rowKey: row.rowKey, cellKey: editingCell.cellKey, }), persistent: { undoIntent: () => buildSelectionIntent({ rowKey: row.rowKey, cellKey: editingCell.cellKey, }), redoIntent: nextSelectionIntent, }, }; setEditingCell(null); await runCommand(command); return; } if (cell.kind === "deviceField" && row.device) { const next = editingCell.draft; const command: HistoryCommand = { label: "Gerätefeld bearbeiten", redo: async () => { await patchDeviceRow(row.device!.id, editingCell.cellKey, next); return nextSelectionIntent(); }, undo: async () => buildSelectionIntent({ rowKey: `device:${row.device!.id}`, cellKey: editingCell.cellKey, }), persistent: { undoIntent: () => buildSelectionIntent({ rowKey: `device:${row.device!.id}`, cellKey: editingCell.cellKey, }), redoIntent: nextSelectionIntent, }, }; setEditingCell(null); await runCommand(command); return; } if (cell.kind === "deviceField" && row.rowType === "reserveCircuit" && row.circuit) { const next = editingCell.draft; let createdRowId: string | null = null; const command: HistoryCommand = { label: "Gerätezeile im Reservestromkreis erstellen", redo: async () => { const editValues = createValuesFromEditPatch( buildDeviceRowEditPatch(editingCell.cellKey, next) ); const snapshot = createDeviceRowSnapshot( row.circuit!.id, { name: "Reserveverbraucher", displayName: "Reserveverbraucher", phaseType: "single_phase", quantity: 1, powerPerUnit: 0, simultaneityFactor: 1, cosPhi: 1, ...editValues, }, 10 ); await persistDeviceRowInsert( snapshot, "Gerätezeile im Reservestromkreis erstellen" ); createdRowId = snapshot.id; targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey }; return nextSelectionIntent(); }, undo: async () => ({ rowKey: `reserveCircuit:${row.circuit!.id}`, cellKey: editingCell.cellKey, rowType: "reserveCircuit", sectionId: row.sectionId, circuitId: row.circuit!.id, }), persistent: { undoIntent: () => ({ rowKey: `reserveCircuit:${row.circuit!.id}`, cellKey: editingCell.cellKey, rowType: "reserveCircuit", sectionId: row.sectionId, circuitId: row.circuit!.id, }), redoIntent: () => createdRowId ? { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey, rowType: "circuitCompact", sectionId: row.sectionId, circuitId: row.circuit!.id, deviceId: createdRowId, } : null, }, }; setEditingCell(null); await runCommand(command); } } // Cancels current edit session and restores grid keyboard focus. function cancelEdit() { if (!editingCell) { return; } setEditingCell(null); setSelectedCell({ rowKey: editingCell.rowKey, cellKey: editingCell.cellKey }); focusGridWithoutScroll(); } async function handleAddReserveCircuit(sectionId: string, afterCircuitId?: string) { const section = data?.sections.find((entry) => entry.id === sectionId); if (!section) { return; } let createdCircuitId: string | null = null; await runCommand({ label: "Stromkreis hinzufügen", redo: async () => { const next = await getNextCircuitIdentifier(sectionId); const sortOrder = getInsertionSortOrder(section.circuits, afterCircuitId); const circuit = createCircuitSnapshot({ sectionId, equipmentIdentifier: next.nextIdentifier, displayName: "Reserve", sortOrder, isReserve: true, }); await persistCircuitInsert(circuit, "Stromkreis hinzufügen"); createdCircuitId = circuit.id; setActiveSectionId(sectionId); return { rowKey: `reserveCircuit:${circuit.id}`, cellKey: "equipmentIdentifier", rowType: "reserveCircuit", sectionId, circuitId: circuit.id, }; }, undo: async () => ({ rowKey: `placeholder:${sectionId}`, cellKey: "equipmentIdentifier", rowType: "placeholder", sectionId, }), persistent: { undoIntent: () => ({ rowKey: `placeholder:${sectionId}`, cellKey: "equipmentIdentifier", rowType: "placeholder", sectionId, }), redoIntent: () => createdCircuitId ? { rowKey: `reserveCircuit:${createdCircuitId}`, cellKey: "equipmentIdentifier", rowType: "reserveCircuit", sectionId, circuitId: createdCircuitId, } : null, }, }); } async function handleAddManualDevice( circuit: CircuitTreeCircuitDto, sectionId: string, afterDeviceRowId?: string ) { const originalDeviceCount = circuit.deviceRows.length; const section = data?.sections.find((entry) => entry.id === sectionId); let createdRowId: string | null = null; const getUndoIntent = (): SelectionIntent => { const rowKey = originalDeviceCount === 0 ? `reserveCircuit:${circuit.id}` : originalDeviceCount === 1 ? `circuitCompact:${circuit.id}` : afterDeviceRowId ? `device:${afterDeviceRowId}` : `circuitSummary:${circuit.id}`; return { rowKey, cellKey: "displayName", rowType: originalDeviceCount === 0 ? "reserveCircuit" : originalDeviceCount === 1 ? "circuitCompact" : afterDeviceRowId ? "deviceRow" : "circuitSummary", sectionId, circuitId: circuit.id, deviceId: afterDeviceRowId, }; }; await runCommand({ label: "Manuelles Gerät hinzufügen", redo: async () => { const rowSnapshot = createDeviceRowSnapshot(circuit.id, { name: "Manuelles Gerät", displayName: "Manuelles Gerät", phaseType: section?.key === "three_phase" ? "three_phase" : "single_phase", quantity: 1, powerPerUnit: 0, simultaneityFactor: 1, cosPhi: 1, sortOrder: getInsertionSortOrder(circuit.deviceRows, afterDeviceRowId), }, 10); await persistDeviceRowInsert( rowSnapshot, "Manuelles Gerät hinzufügen" ); createdRowId = rowSnapshot.id; setActiveSectionId(sectionId); return { rowKey: originalDeviceCount === 0 ? `circuitCompact:${circuit.id}` : `device:${rowSnapshot.id}`, cellKey: "displayName", rowType: originalDeviceCount === 0 ? "circuitCompact" : "deviceRow", sectionId, circuitId: circuit.id, deviceId: rowSnapshot.id, }; }, undo: async () => getUndoIntent(), persistent: { undoIntent: getUndoIntent, redoIntent: () => createdRowId ? { rowKey: originalDeviceCount === 0 ? `circuitCompact:${circuit.id}` : `device:${createdRowId}`, cellKey: "displayName", rowType: originalDeviceCount === 0 ? "circuitCompact" : "deviceRow", sectionId, circuitId: circuit.id, deviceId: createdRowId, } : null, }, }); } async function handleInsertBelowSelection() { const row = selectedCell ? findRow(selectedCell.rowKey) : null; const cell = row?.cells.find((entry) => entry.cellKey === selectedCell?.cellKey); const selection = row && cell && row.rowType !== "section" ? { rowType: row.rowType, cellKind: cell.kind, sectionId: row.sectionId, circuitId: row.circuit?.id, deviceId: row.device?.id, } : null; const intent = resolveGridInsertionIntent( selection, activeSectionId ?? data?.sections[0]?.id ); if (!intent) { setError("Zum Einfügen ist kein Bereich verfügbar."); return; } if (intent.kind === "circuit") { await handleAddReserveCircuit(intent.sectionId, intent.afterCircuitId); return; } const circuit = data?.sections .flatMap((sectionEntry) => sectionEntry.circuits) .find((entry) => entry.id === intent.circuitId); if (!circuit) { setError("Der Stromkreis ist ungültig."); return; } await handleAddManualDevice(circuit, intent.sectionId, intent.afterDeviceRowId); } function resolvePhaseType(device: ProjectDeviceDto): string { return device.phaseType; } function resolveSelectedProjectDevice() { if (!selectedProjectDeviceId) { return null; } return projectDevices.find((device) => device.id === selectedProjectDeviceId) ?? null; } function isProjectDeviceTargetValid(device: ProjectDeviceDto, sectionId: string) { const section = data?.sections.find((entry) => entry.id === sectionId); return Boolean(section && isProjectDevicePlacementValid(device, section)); } function getProjectDeviceTargetError(device: ProjectDeviceDto, sectionId: string) { const target = data?.sections.find((entry) => entry.id === sectionId); const expectedKey = inferProjectDeviceSectionKey(device); const expected = data?.sections.find((entry) => entry.key === expectedKey); if (!target) { return "Der Zielbereich ist ungültig."; } return `${device.displayName || device.name} gehört in den Bereich ${expected?.displayName ?? expectedKey}, nicht in ${target.displayName}.`; } async function handleAddProjectDeviceAsNewCircuit() { const device = resolveSelectedProjectDevice(); if (!device) { setError("Bitte ein Projektgerät auswählen."); return; } if (!targetSectionId) { setError("Bitte einen Zielbereich auswählen."); return; } if (!isProjectDeviceTargetValid(device, targetSectionId)) { setError(getProjectDeviceTargetError(device, targetSectionId)); return; } let createdCircuitId: string | null = null; let createdRowId: string | null = null; await runCommand({ label: "Projektgerät als neuen Stromkreis hinzufügen", redo: async () => { const created = await insertProjectDeviceAsNewCircuit(device, targetSectionId); createdCircuitId = created.circuitId; createdRowId = created.rowId; return { rowKey: `device:${created.rowId}`, cellKey: "displayName", rowType: "deviceRow", sectionId: targetSectionId, circuitId: created.circuitId, deviceId: created.rowId, }; }, undo: async () => ({ rowKey: `placeholder:${targetSectionId}`, cellKey: "displayName", rowType: "placeholder", sectionId: targetSectionId, }), persistent: { undoIntent: () => ({ rowKey: `placeholder:${targetSectionId}`, cellKey: "displayName", rowType: "placeholder", sectionId: targetSectionId, }), redoIntent: () => createdCircuitId && createdRowId ? { rowKey: `device:${createdRowId}`, cellKey: "displayName", rowType: "deviceRow", sectionId: targetSectionId, circuitId: createdCircuitId, deviceId: createdRowId, } : null, }, }); } async function handleAddProjectDeviceToCircuit() { const device = resolveSelectedProjectDevice(); if (!device) { setError("Bitte ein Projektgerät auswählen."); return; } let circuitId = targetCircuitId; if (!circuitId && selectedCell) { const row = findRow(selectedCell.rowKey); if (row?.circuit?.id) { circuitId = row.circuit.id; } } if (!circuitId) { setError("Bitte einen Zielstromkreis auswählen."); return; } const sectionId = findCircuitSectionId(circuitId); if (!sectionId || !isProjectDeviceTargetValid(device, sectionId)) { setError(getProjectDeviceTargetError(device, sectionId ?? "")); return; } let createdRowId: string | null = null; await runCommand({ label: "Projektgerät zum Stromkreis hinzufügen", redo: async () => { const created = await insertProjectDeviceToCircuit(device, circuitId); createdRowId = created.rowId; return { rowKey: `device:${created.rowId}`, cellKey: "displayName", rowType: "deviceRow", sectionId: findCircuitSectionId(circuitId) ?? "", circuitId, deviceId: created.rowId, }; }, undo: async () => ({ rowKey: `circuitSummary:${circuitId}`, cellKey: "displayName", rowType: "circuitSummary", sectionId, circuitId, }), persistent: { undoIntent: () => ({ rowKey: `circuitSummary:${circuitId}`, cellKey: "displayName", rowType: "circuitSummary", sectionId, circuitId, }), redoIntent: () => createdRowId ? { rowKey: `device:${createdRowId}`, cellKey: "displayName", rowType: "deviceRow", sectionId, circuitId, deviceId: createdRowId, } : null, }, }); } async function insertProjectDeviceAsNewCircuit(device: ProjectDeviceDto, sectionId: string) { const section = data?.sections.find((entry) => entry.id === sectionId); if (!section) { throw new Error("Der Zielbereich ist ungültig."); } const next = await getNextCircuitIdentifier(sectionId); const sortOrder = section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10; const circuit = createCircuitSnapshot( { sectionId, equipmentIdentifier: next.nextIdentifier, displayName: device.displayName || device.name, sortOrder, isReserve: false, }, [ { linkedProjectDeviceId: device.id, name: device.name, displayName: device.displayName, phaseType: resolvePhaseType(device), connectionKind: device.connectionKind ?? undefined, costGroup: device.costGroup ?? undefined, quantity: device.quantity, powerPerUnit: device.powerPerUnit, simultaneityFactor: device.simultaneityFactor, cosPhi: device.cosPhi ?? undefined, category: device.category ?? undefined, remark: device.remark ?? undefined, }, ] ); await persistCircuitInsert( circuit, "Projektgerät als neuen Stromkreis hinzufügen" ); const createdRow = circuit.deviceRows[0]; setActiveSectionId(sectionId); setTargetCircuitId(circuit.id); return { circuitId: circuit.id, rowId: createdRow.id }; } async function insertProjectDeviceToCircuit(device: ProjectDeviceDto, circuitId: string) { const circuit = allCircuits.find((entry) => entry.id === circuitId); if (!circuit) { throw new Error("Der Zielstromkreis ist ungültig."); } const row = createDeviceRowSnapshot( circuitId, { linkedProjectDeviceId: device.id, name: device.name, displayName: device.displayName, phaseType: resolvePhaseType(device), connectionKind: device.connectionKind ?? undefined, costGroup: device.costGroup ?? undefined, quantity: device.quantity, powerPerUnit: device.powerPerUnit, simultaneityFactor: device.simultaneityFactor, cosPhi: device.cosPhi ?? undefined, category: device.category ?? undefined, remark: device.remark ?? undefined, }, getInsertionSortOrder(circuit.deviceRows) ); await persistDeviceRowInsert( row, "Projektgerät zum Stromkreis hinzufügen" ); return { rowId: row.id }; } function parseDraggedProjectDeviceId(event: DragEvent) { return event.dataTransfer.getData("application/x-project-device-id") || null; } function parseDraggedDeviceRowId(event: DragEvent) { return event.dataTransfer.getData("application/x-circuit-device-row-id") || null; } function parseDraggedDeviceRowIds(event: DragEvent) { const raw = event.dataTransfer.getData("application/x-circuit-device-row-ids"); if (!raw) { return []; } try { const parsed = JSON.parse(raw) as unknown; if (!Array.isArray(parsed)) { return []; } return parsed.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); } catch { return []; } } function parseDraggedCircuitIds(event: DragEvent) { const raw = event.dataTransfer.getData("application/x-circuit-ids"); if (!raw) { return []; } try { const parsed = JSON.parse(raw) as unknown; if (!Array.isArray(parsed)) { return []; } return parsed.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); } catch { return []; } } function findDeviceRowCircuitId(deviceRowId: string) { for (const section of data?.sections ?? []) { for (const circuit of section.circuits) { if (circuit.deviceRows.some((row) => row.id === deviceRowId)) { return circuit.id; } } } return null; } // Converts current row selection to device-row ids for bulk row moves. // Section and placeholder rows are intentionally ignored. function getSelectedEligibleDeviceRowIds(primaryRowId?: string | null) { const selectedSet = new Set(selectedRowKeys); const ids: string[] = []; for (const row of visibleRows) { if (!selectedSet.has(row.rowKey)) { continue; } if ((row.rowType === "deviceRow" || row.rowType === "circuitCompact") && row.device?.id) { ids.push(row.device.id); } } if (ids.length > 1 && primaryRowId && ids.includes(primaryRowId)) { return ids; } return primaryRowId ? [primaryRowId] : ids; } // Converts current row selection to unique circuit ids for circuit block reorder. function getSelectedEligibleCircuitIds(primaryCircuitId?: string | null) { const selectedSet = new Set(selectedRowKeys); const ids: string[] = []; const seen = new Set(); for (const row of visibleRows) { if (!selectedSet.has(row.rowKey)) { continue; } if ( (row.rowType === "circuitCompact" || row.rowType === "circuitSummary" || row.rowType === "reserveCircuit") && row.circuit?.id && !seen.has(row.circuit.id) ) { seen.add(row.circuit.id); ids.push(row.circuit.id); } } if (ids.length > 1 && primaryCircuitId && ids.includes(primaryCircuitId)) { return ids; } return primaryCircuitId ? [primaryCircuitId] : ids; } function findCircuitSectionId(circuitId: string) { for (const section of data?.sections ?? []) { if (section.circuits.some((circuit) => circuit.id === circuitId)) { return section.id; } } return null; } function getDeviceRowSourceSectionIds(rowIds: string[]) { const sectionIds = new Set(); for (const rowId of rowIds) { const circuitId = findDeviceRowCircuitId(rowId); const sectionId = circuitId ? findCircuitSectionId(circuitId) : null; if (sectionId) { sectionIds.add(sectionId); } } return [...sectionIds]; } function confirmCrossSectionDeviceMove(rowIds: string[], targetSectionId: string) { const sourceSectionIds = getDeviceRowSourceSectionIds(rowIds); if (!requiresCrossSectionMoveConfirmation(sourceSectionIds, targetSectionId)) { return true; } const sourceNames = sourceSectionIds .map((sectionId) => data?.sections.find((section) => section.id === sectionId)?.displayName ?? sectionId) .join(", "); const targetName = data?.sections.find((section) => section.id === targetSectionId)?.displayName ?? targetSectionId; return confirm( `${rowIds.length} Gerätezeile(n) von ${sourceNames} nach ${targetName} verschieben?\n\n` + "Dabei wird eine Bereichsgrenze überschritten. Phasenart, Kategorie, Projektgeräte-Verknüpfungen und lokale Werte bleiben unverändert. " + "Die technische und nummerische Zuordnung im Zielbereich muss daher möglicherweise manuell geprüft werden.\n\n" + "Bestehende Betriebsmittelkennzeichen werden nicht neu nummeriert." ); } // Applies same-section circuit reorder as explicit id ordering. // No implicit renumbering is performed here. function resolveCircuitReorderOrder(intent: CircuitReorderDropIntent, sourceCircuitIds: string[]) { const section = data?.sections.find((entry) => entry.id === intent.sectionId); if (!section) { throw new Error("Der Bereich ist ungültig."); } const ids = section.circuits.map((circuit) => circuit.id); const block = sourceCircuitIds.filter((id) => ids.includes(id)); if (block.length === 0) { throw new Error("Der Quellstromkreis ist ungültig."); } const blockSet = new Set(block); const nextIds = ids.filter((id) => !blockSet.has(id)); if (intent.kind === "section-end") { nextIds.push(...block); } else { const targetIndex = nextIds.indexOf(intent.targetCircuitId); if (targetIndex < 0) { throw new Error("Der Zielstromkreis ist ungültig."); } const insertIndex = intent.kind === "after-circuit" ? targetIndex + 1 : targetIndex; nextIds.splice(insertIndex, 0, ...block); } return nextIds; } // Handles project-device drag intent. This path creates linked rows/circuits and // must remain separate from move handlers that operate on existing entities. async function handleDropWithIntent(event: DragEvent, intent: ProjectDeviceDropIntent) { event.preventDefault(); event.stopPropagation(); const draggedId = parseDraggedProjectDeviceId(event) ?? draggingProjectDeviceId; setDropIntent(null); setDraggingProjectDeviceId(null); setDraggingDeviceRowIds([]); setDraggingCircuitIds([]); if (!draggedId) { setError("Das gezogene Projektgerät fehlt."); return; } const device = projectDevices.find((entry) => entry.id === draggedId); if (!device) { setError("Die Quelle des gezogenen Projektgeräts ist ungültig."); return; } if (!intent.valid || !isProjectDeviceTargetValid(device, intent.sectionId)) { setError(getProjectDeviceTargetError(device, intent.sectionId)); return; } // Project-device drop logic is isolated from row/circuit move logic because // it can create new rows/circuits instead of moving existing ones. if (intent.kind === "new-circuit") { let createdCircuitId: string | null = null; let createdRowId: string | null = null; await runCommand({ label: "Projektgerät in neuen Stromkreis ziehen", redo: async () => { const created = await insertProjectDeviceAsNewCircuit(device, intent.sectionId); createdCircuitId = created.circuitId; createdRowId = created.rowId; return { rowKey: `device:${created.rowId}`, cellKey: "displayName", rowType: "deviceRow", sectionId: intent.sectionId, circuitId: created.circuitId, deviceId: created.rowId, }; }, undo: async () => null, persistent: { undoIntent: () => null, redoIntent: () => createdCircuitId && createdRowId ? { rowKey: `device:${createdRowId}`, cellKey: "displayName", rowType: "deviceRow", sectionId: intent.sectionId, circuitId: createdCircuitId, deviceId: createdRowId, } : null, }, }); return; } let createdRowId: string | null = null; await runCommand({ label: "Projektgerät in Stromkreis ziehen", redo: async () => { const created = await insertProjectDeviceToCircuit(device, intent.circuitId); createdRowId = created.rowId; return { rowKey: `device:${created.rowId}`, cellKey: "displayName", rowType: "deviceRow", sectionId: intent.sectionId, circuitId: intent.circuitId, deviceId: created.rowId, }; }, undo: async () => null, persistent: { undoIntent: () => null, redoIntent: () => createdRowId ? { rowKey: `device:${createdRowId}`, cellKey: "displayName", rowType: "deviceRow", sectionId: intent.sectionId, circuitId: intent.circuitId, deviceId: createdRowId, } : null, }, }); } // Handles single and bulk device-row drag through persistent project history. // Exact source and target positions make undo deterministic. async function handleDeviceRowDropWithIntent(event: DragEvent, intent: DeviceRowMoveDropIntent) { event.preventDefault(); event.stopPropagation(); const draggedRowIds = parseDraggedDeviceRowIds(event); const singleRowId = parseDraggedDeviceRowId(event) ?? draggingDeviceRowId; const rowIds = draggedRowIds.length > 0 ? draggedRowIds : singleRowId ? [singleRowId] : []; setDeviceMoveIntent(null); setDraggingDeviceRowId(null); setDraggingDeviceRowIds([]); setDraggingCircuitIds([]); if (rowIds.length === 0) { setError("Die gezogene Gerätezeile fehlt."); return; } const sourceByRowId = new Map(); for (const rowId of rowIds) { const sourceCircuitId = findDeviceRowCircuitId(rowId); if (!sourceCircuitId) { setError("Die gezogene Gerätezeile ist ungültig."); return; } sourceByRowId.set(rowId, sourceCircuitId); } const sourceCircuitId = sourceByRowId.get(rowIds[0]); if (!sourceCircuitId) { setError("Die Quelle der gezogenen Gerätezeile ist ungültig."); return; } if (intent.kind === "move-to-circuit" && rowIds.length === 1 && intent.circuitId === sourceCircuitId) { return; } if (!confirmCrossSectionDeviceMove(rowIds, intent.sectionId)) { return; } const circuits = (data?.sections ?? []).flatMap( (section) => section.circuits ); const label = rowIds.length > 1 ? `${rowIds.length} Gerätezeilen verschieben` : "Gerätezeile verschieben"; if (intent.kind === "move-to-circuit") { const targetCircuit = circuits.find( (circuit) => circuit.id === intent.circuitId ); if (!targetCircuit) { setError("Der Zielstromkreis ist ungültig."); return; } const moves = buildCircuitDeviceRowMoveAssignments({ circuits, rowIds, targetCircuitId: targetCircuit.id, targetDeviceRows: targetCircuit.deviceRows, }); if (moves.length === 0) { return; } await runCommand({ label, redo: async () => { const result = await moveCircuitDeviceRowsCommand( projectId, getExpectedProjectRevision(), moves, label ); applyProjectCommandResult(result); pendingSelectedDeviceRowIdsAfterReload.current = rowIds; return null; }, undo: async () => null, persistent: { undoIntent: () => { pendingSelectedDeviceRowIdsAfterReload.current = rowIds; return null; }, redoIntent: () => { pendingSelectedDeviceRowIdsAfterReload.current = rowIds; return null; }, }, }); return; } const targetSection = data?.sections.find( (section) => section.id === intent.sectionId ); if (!targetSection) { setError("Der Zielbereich ist ungültig."); return; } const newCircuitLabel = rowIds.length > 1 ? `${rowIds.length} Gerätezeilen in neuen Stromkreis verschieben` : "Gerätezeile in neuen Stromkreis verschieben"; await runCommand({ label: newCircuitLabel, redo: async () => { const next = await getNextCircuitIdentifier(intent.sectionId); const sortOrder = targetSection.circuits.length > 0 ? Math.max( ...targetSection.circuits.map( (circuit) => circuit.sortOrder ) ) + 10 : 10; const targetCircuit = createCircuitSnapshot({ sectionId: intent.sectionId, equipmentIdentifier: next.nextIdentifier, displayName: "Neuer Stromkreis", sortOrder, isReserve: true, }); const moves = buildCircuitDeviceRowMoveAssignments({ circuits, rowIds, targetCircuitId: targetCircuit.id, targetDeviceRows: [], }); const result = await moveCircuitDeviceRowsToNewCircuitCommand( projectId, getExpectedProjectRevision(), targetCircuit, moves, newCircuitLabel ); applyProjectCommandResult(result); pendingSelectedDeviceRowIdsAfterReload.current = rowIds; return null; }, undo: async () => null, persistent: { undoIntent: () => { pendingSelectedDeviceRowIdsAfterReload.current = rowIds; return null; }, redoIntent: () => { pendingSelectedDeviceRowIdsAfterReload.current = rowIds; return null; }, }, }); } // Handles circuit drag intent and reorders whole circuit blocks within one section only. async function handleCircuitReorderDrop(event: DragEvent, intent: CircuitReorderDropIntent) { event.preventDefault(); event.stopPropagation(); const draggedCircuitIds = parseDraggedCircuitIds(event); const singleCircuitId = event.dataTransfer.getData("application/x-circuit-id") || draggingCircuitId; const sourceCircuitIds = draggedCircuitIds.length > 0 ? draggedCircuitIds : singleCircuitId ? [singleCircuitId] : []; setCircuitReorderIntent(null); setDraggingCircuitId(null); setDraggingCircuitIds([]); if (sourceCircuitIds.length === 0) { setError("Der gezogene Stromkreis fehlt."); return; } if (!intent.valid) { setError("Stromkreise können derzeit nicht bereichsübergreifend verschoben werden."); return; } const section = data?.sections.find((entry) => entry.id === intent.sectionId); if (!section) { setError("Der Bereich ist ungültig."); return; } const sectionCircuitIds = new Set(section.circuits.map((circuit) => circuit.id)); if (sourceCircuitIds.some((id) => !sectionCircuitIds.has(id))) { setError("Stromkreise können derzeit nicht bereichsübergreifend verschoben werden."); return; } const primaryCircuitId = sourceCircuitIds[0]; const targetOrder = resolveCircuitReorderOrder( intent, sourceCircuitIds ); const assignments = buildCircuitSectionReorderAssignments( section.circuits, targetOrder ); if (assignments.length === 0) { return; } const label = sourceCircuitIds.length > 1 ? `${sourceCircuitIds.length} Stromkreise neu anordnen` : "Stromkreise neu anordnen"; const selectionIntent: SelectionIntent = { rowKey: `circuitSummary:${primaryCircuitId}`, cellKey: "equipmentIdentifier", rowType: "circuitSummary", sectionId: intent.sectionId, circuitId: primaryCircuitId, }; await runCommand({ label, redo: async () => { const result = await reorderCircuitSectionCommand( projectId, getExpectedProjectRevision(), intent.sectionId, assignments, label ); applyProjectCommandResult(result); pendingSelectedCircuitIdsAfterReload.current = sourceCircuitIds; return selectionIntent; }, undo: async () => null, persistent: { undoIntent: () => { pendingSelectedCircuitIdsAfterReload.current = sourceCircuitIds; return selectionIntent; }, redoIntent: () => { pendingSelectedCircuitIdsAfterReload.current = sourceCircuitIds; return selectionIntent; }, }, }); } // Persistent delete commands preserve the complete row and its stable id. async function handleDeleteDevice(rowId: string) { const sourceCircuitId = findDeviceRowCircuitId(rowId); const sourceCircuit = sourceCircuitId ? allCircuits.find((circuit) => circuit.id === sourceCircuitId) : null; const rowSnapshot = sourceCircuit?.deviceRows.find((row) => row.id === rowId); if (!sourceCircuitId || !sourceCircuit || !rowSnapshot) { setError("Die Gerätezeile ist ungültig."); return; } if (sourceCircuit.deviceRows.length === 1) { const keepAsReserve = confirm( `„${rowSnapshot.displayName}“ ist das letzte Gerät in ${sourceCircuit.equipmentIdentifier}.\n\n` + "OK: Gerät löschen und den leeren Stromkreis als Reserve behalten.\n" + "Abbrechen: anschließend entscheiden, ob stattdessen der gesamte Stromkreis gelöscht werden soll." ); if (!keepAsReserve) { const deleteCircuit = confirm( `Den vollständigen Stromkreis ${sourceCircuit.equipmentIdentifier} löschen?\n\n` + "OK: Stromkreis und Gerät löschen.\nAbbrechen: nichts löschen." ); if (deleteCircuit) { await handleDeleteCircuit(sourceCircuitId, false); } return; } } else if (!confirm(`Gerätezeile „${rowSnapshot.displayName}“ löschen?`)) { return; } await runCommand({ label: "Gerätezeile löschen", redo: async () => { await persistDeviceRowDelete( rowId, sourceCircuitId, "Gerätezeile löschen" ); return null; }, undo: async () => null, persistent: { undoIntent: () => null, redoIntent: () => null, }, }); } // Persistent circuit delete preserves the complete circuit block and all stable ids. async function handleDeleteCircuit(circuitId: string, askForConfirmation = true) { const circuit = allCircuits.find((entry) => entry.id === circuitId); if (!circuit) { setError("Der Stromkreis ist ungültig."); return; } if ( askForConfirmation && !confirm( `Stromkreis ${circuit.equipmentIdentifier} und ${circuit.deviceRows.length} zugeordnete Gerätezeile(n) löschen?\n\n` + "Bestehende Betriebsmittelkennzeichen werden nicht neu nummeriert." ) ) { return; } await runCommand({ label: "Stromkreis löschen", redo: async () => { await persistCircuitDelete(circuitId, "Stromkreis löschen"); return null; }, undo: async () => null, persistent: { undoIntent: () => null, redoIntent: () => null, }, }); } 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 through project history. Sorting and device rows // remain unchanged; the server handles collision-safe identifier swaps. async function handleRenumberSection(sectionId: string) { if (!confirm("Diesen Bereich neu nummerieren? Nur die Stromkreise in diesem Bereich werden geändert.")) { return; } const section = data?.sections.find((entry) => entry.id === sectionId); if (!section) { return; } const assignments = buildCircuitSectionRenumberAssignments( data?.sections ?? [], sectionId ); if (assignments.length === 0) { return; } const label = "Bereich neu nummerieren"; await runCommand({ label, redo: async () => { const result = await renumberCircuitSectionCommand( projectId, getExpectedProjectRevision(), sectionId, assignments, label ); applyProjectCommandResult(result); return null; }, undo: async () => null, persistent: { undoIntent: () => null, redoIntent: () => null, }, }); } // Keeps container-level keyboard focus model valid when focus returns from toolbar/popovers. function handleContainerFocus() { if (editingCell) { const row = findRow(editingCell.rowKey); const cell = row?.cells.find((entry) => entry.cellKey === editingCell.cellKey); if (!row || !cell || !cell.editable) { setEditingCell(null); } return; } if (!selectedCell && editableCells.length) { setSelectedCell(editableCells[0]); } } // Spreadsheet-like keyboard dispatcher: // - Enter/F2 starts edit with current value selected // - printable key starts overwrite edit (type-to-edit) // - Arrow/Tab move selectedCell when not editing function handleContainerKeyDown(event: KeyboardEvent) { if (editingCell) { if (event.key === "Tab") { event.preventDefault(); } 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(); setSelectedRowKeys([]); setAnchorRowKey(null); } return; } if (event.ctrlKey && event.key.toLowerCase() === "z") { event.preventDefault(); if (event.shiftKey) { void handleRedo(); } else { void handleUndo(); } return; } if (event.ctrlKey && event.key.toLowerCase() === "y") { event.preventDefault(); void handleRedo(); return; } const isCtrlPlus = event.ctrlKey && (event.key === "+" || (event.shiftKey && event.key === "=") || event.code === "NumpadAdd"); if (isCtrlPlus) { event.preventDefault(); void handleInsertBelowSelection(); return; } if (event.key === "Delete" && selectedCell) { event.preventDefault(); void handleDeleteSelection(); return; } if (event.key === "Tab" && selectedCell) { event.preventDefault(); const idx = editableCells.findIndex( (entry) => entry.rowKey === selectedCell.rowKey && entry.cellKey === selectedCell.cellKey ); if (idx >= 0) { const nextIdx = event.shiftKey ? Math.max(0, idx - 1) : Math.min(editableCells.length - 1, idx + 1); setSelectedCell(editableCells[nextIdx]); } return; } if (event.key === "ArrowRight") { event.preventDefault(); moveHorizontal(1); return; } if (event.key === "ArrowLeft") { event.preventDefault(); moveHorizontal(-1); return; } if (event.key === "ArrowDown") { event.preventDefault(); moveVertical(1); return; } if (event.key === "ArrowUp") { event.preventDefault(); moveVertical(-1); return; } if ((event.key === "Enter" || event.key === "F2") && selectedCell) { event.preventDefault(); startEdit(selectedCell, "selectExisting"); return; } if (isPrintableKey(event) && selectedCell) { // First printable key should become draft content directly; do not pre-select old text. event.preventDefault(); startEdit(selectedCell, "replaceWithTypedChar", event.key); } } if (isLoading && !data) { return
Stromkreislisteneditor wird geladen …
; } if (error && !data) { return
{error}
; } if (!data || data.sections.length === 0) { return
Keine Bereiche oder Stromkreise vorhanden.
; } if (filteredSortedSections.length === 0) { return (
{renderColumnSettingsMenu("col-empty")}
{renderActiveViewSummary()} {error ? (
{error}
) : null}
Für die aktiven Filter wurden keine passenden Stromkreise gefunden.
); } const isSortedView = Boolean(sortState); // Renumbering is intentionally blocked while sort/filter overlays are active so users // cannot renumber against a transformed view that has not been persisted yet. const hasActiveSortOrFilter = isSortedView || hasActiveFilters; const activeDraggedDeviceRowIds = draggingDeviceRowIds.length > 0 ? draggingDeviceRowIds : draggingDeviceRowId ? [draggingDeviceRowId] : []; const draggingDeviceCount = activeDraggedDeviceRowIds.length; const activeDraggedCircuitIds = draggingCircuitIds.length > 0 ? draggingCircuitIds : draggingCircuitId ? [draggingCircuitId] : []; const draggingCircuitCount = activeDraggedCircuitIds.length; const selectedProjectDevice = resolveSelectedProjectDevice(); const suggestedSection = selectedProjectDevice ? data.sections.find((section) => section.key === inferProjectDeviceSectionKey(selectedProjectDevice)) : null; const selectedRowCircuitId = selectedCell ? findRow(selectedCell.rowKey)?.circuit?.id ?? null : null; const resolvedSidebarTargetCircuitId = targetCircuitId ?? selectedRowCircuitId; const resolvedSidebarTargetSectionId = resolvedSidebarTargetCircuitId ? findCircuitSectionId(resolvedSidebarTargetCircuitId) : null; const canAddToSelectedSection = Boolean( selectedProjectDevice && targetSectionId && isProjectDeviceTargetValid(selectedProjectDevice, targetSectionId) ); const canAddToSelectedCircuit = Boolean( selectedProjectDevice && resolvedSidebarTargetSectionId && isProjectDeviceTargetValid(selectedProjectDevice, resolvedSidebarTargetSectionId) ); return (
{isSortedView ? ( ) : null} {renderColumnSettingsMenu("col")}
{renderActiveViewSummary()} {hasActiveSortOrFilter ? (
Sortierung und Filter verändern nur die Ansicht. Die Neunummerierung ist währenddessen deaktiviert.
) : null} {isSortedView && hasActiveFilters ? (
Die sortierte Reihenfolge kann erst nach dem Zurücksetzen der Filter übernommen werden.
) : null} {error ? (
{error}
) : null} {duplicateEquipmentIdentifierCircuitIds.size > 0 ? (
Doppelte Betriebsmittelkennzeichen sind hervorgehoben. Eine Neunummerierung erfolgt weiterhin nur ausdrücklich.
) : null} {isSaving ?
Wird gespeichert …
: null}
{ if (event.target === event.currentTarget) { setSelectedRowKeys([]); setAnchorRowKey(null); } }} > {visibleColumns.map((column) => ( ))} {visibleRows.map((row) => { if (row.rowType === "section") { const section = data.sections.find((entry) => entry.id === row.sectionId)!; return ( { if (draggingCircuitCount > 0) { event.preventDefault(); event.dataTransfer.dropEffect = "none"; setCircuitReorderIntent({ kind: "section-end", sectionId: section.id, valid: false, }); return; } if (!draggingProjectDeviceId) { return; } const device = projectDevices.find((entry) => entry.id === draggingProjectDeviceId); const valid = Boolean(device && isProjectDevicePlacementValid(device, section)); event.preventDefault(); event.dataTransfer.dropEffect = valid ? "copy" : "none"; setDropIntent({ kind: "new-circuit", sectionId: section.id, valid }); }} onDragLeave={() => { if (dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id) { setDropIntent(null); } if (circuitReorderIntent?.kind === "section-end" && circuitReorderIntent.sectionId === section.id) { setCircuitReorderIntent(null); } }} onDrop={(event) => { if (draggingProjectDeviceId) { const device = projectDevices.find((entry) => entry.id === draggingProjectDeviceId); void handleDropWithIntent(event, { kind: "new-circuit", sectionId: section.id, valid: Boolean(device && isProjectDevicePlacementValid(device, section)), }); return; } if (draggingCircuitCount > 0) { void handleCircuitReorderDrop(event, { kind: "section-end", sectionId: section.id, valid: false, }); } }} > ); } return ( 0 && draggingCircuitIds.includes(row.circuit.id)) || (draggingCircuitIds.length === 0 && draggingCircuitId && row.circuit.id === draggingCircuitId)) ? "circuit-dragging-block" : "" } ${selectedRowKeys.includes(row.rowKey) ? "row-selected" : ""} }`} onClick={() => setActiveSectionId(row.sectionId)} onDragOver={(event) => { if (draggingCircuitCount > 0) { const sourceSectionIds = activeDraggedCircuitIds .map((id) => findCircuitSectionId(id)) .filter((id): id is string => Boolean(id)); const valid = sourceSectionIds.length > 0 && sourceSectionIds.every((sectionId) => sectionId === row.sectionId); if (row.rowType === "placeholder") { event.preventDefault(); event.dataTransfer.dropEffect = valid ? "move" : "none"; setCircuitReorderIntent({ kind: "section-end", sectionId: row.sectionId, valid }); return; } if (row.circuit && row.rowType !== "deviceRow") { if (activeDraggedCircuitIds.includes(row.circuit.id)) { setCircuitReorderIntent(null); return; } const rect = (event.currentTarget as HTMLTableRowElement).getBoundingClientRect(); const isAfter = event.clientY > rect.top + rect.height / 2; event.preventDefault(); event.dataTransfer.dropEffect = valid ? "move" : "none"; setCircuitReorderIntent({ kind: isAfter ? "after-circuit" : "before-circuit", sectionId: row.sectionId, targetCircuitId: row.circuit.id, valid, }); } return; } if (draggingProjectDeviceId) { const device = projectDevices.find((entry) => entry.id === draggingProjectDeviceId); const section = data.sections.find((entry) => entry.id === row.sectionId); const valid = Boolean(device && section && isProjectDevicePlacementValid(device, section)); if (row.rowType === "placeholder") { event.preventDefault(); event.dataTransfer.dropEffect = valid ? "copy" : "none"; setDropIntent({ kind: "new-circuit", sectionId: row.sectionId, valid }); return; } if (row.circuit && row.rowType !== "deviceRow") { event.preventDefault(); event.dataTransfer.dropEffect = valid ? "copy" : "none"; setDropIntent({ kind: "add-to-circuit", circuitId: row.circuit.id, sectionId: row.sectionId, valid, }); } return; } if (draggingDeviceCount > 0) { const requiresConfirmation = requiresCrossSectionMoveConfirmation( getDeviceRowSourceSectionIds(activeDraggedDeviceRowIds), row.sectionId ); if (row.rowType === "placeholder") { event.preventDefault(); event.dataTransfer.dropEffect = "move"; setDeviceMoveIntent({ kind: "move-to-new-circuit", sectionId: row.sectionId, requiresConfirmation, }); return; } if (row.circuit && row.rowType !== "deviceRow") { const sourceCircuitIds = activeDraggedDeviceRowIds .map((id) => findDeviceRowCircuitId(id)) .filter((id): id is string => Boolean(id)); if (sourceCircuitIds.some((sourceCircuitId) => sourceCircuitId !== row.circuit!.id)) { event.preventDefault(); event.dataTransfer.dropEffect = "move"; setDeviceMoveIntent({ kind: "move-to-circuit", circuitId: row.circuit.id, sectionId: row.sectionId, requiresConfirmation, }); } } } }} onDragLeave={() => { setDropIntent((current) => { if (!current) { return null; } if (current.kind === "new-circuit" && row.rowType === "placeholder" && current.sectionId === row.sectionId) { return null; } if (current.kind === "add-to-circuit" && current.circuitId === row.circuit?.id) { return null; } return current; }); setDeviceMoveIntent((current) => { if (!current) { return null; } if (current.kind === "move-to-new-circuit" && row.rowType === "placeholder" && current.sectionId === row.sectionId) { return null; } if (current.kind === "move-to-circuit" && current.circuitId === row.circuit?.id) { return null; } return current; }); setCircuitReorderIntent((current) => { if (!current) { return null; } if (current.kind === "section-end" && row.rowType === "placeholder" && current.sectionId === row.sectionId) { return null; } if ( (current.kind === "before-circuit" || current.kind === "after-circuit") && current.targetCircuitId === row.circuit?.id ) { return null; } return current; }); }} onDrop={(event) => { if (draggingCircuitCount > 0) { if (row.rowType === "placeholder") { const sourceSectionIds = activeDraggedCircuitIds .map((id) => findCircuitSectionId(id)) .filter((id): id is string => Boolean(id)); void handleCircuitReorderDrop(event, { kind: "section-end", sectionId: row.sectionId, valid: sourceSectionIds.length > 0 && sourceSectionIds.every((sectionId) => sectionId === row.sectionId), }); return; } if (row.circuit && row.rowType !== "deviceRow") { if (activeDraggedCircuitIds.includes(row.circuit.id)) { return; } const sourceSectionIds = activeDraggedCircuitIds .map((id) => findCircuitSectionId(id)) .filter((id): id is string => Boolean(id)); const rect = (event.currentTarget as HTMLTableRowElement).getBoundingClientRect(); const isAfter = event.clientY > rect.top + rect.height / 2; void handleCircuitReorderDrop(event, { kind: isAfter ? "after-circuit" : "before-circuit", sectionId: row.sectionId, targetCircuitId: row.circuit.id, valid: sourceSectionIds.length > 0 && sourceSectionIds.every((sectionId) => sectionId === row.sectionId), }); } return; } if (draggingProjectDeviceId) { const device = projectDevices.find((entry) => entry.id === draggingProjectDeviceId); const section = data.sections.find((entry) => entry.id === row.sectionId); const valid = Boolean(device && section && isProjectDevicePlacementValid(device, section)); if (row.rowType === "placeholder") { void handleDropWithIntent(event, { kind: "new-circuit", sectionId: row.sectionId, valid }); return; } if (row.circuit && row.rowType !== "deviceRow") { void handleDropWithIntent(event, { kind: "add-to-circuit", circuitId: row.circuit.id, sectionId: row.sectionId, valid, }); } return; } if (draggingDeviceCount > 0) { const requiresConfirmation = requiresCrossSectionMoveConfirmation( getDeviceRowSourceSectionIds(activeDraggedDeviceRowIds), row.sectionId ); if (row.rowType === "placeholder") { void handleDeviceRowDropWithIntent(event, { kind: "move-to-new-circuit", sectionId: row.sectionId, requiresConfirmation, }); return; } if (row.circuit && row.rowType !== "deviceRow") { void handleDeviceRowDropWithIntent(event, { kind: "move-to-circuit", circuitId: row.circuit.id, sectionId: row.sectionId, requiresConfirmation, }); } } }} > {visibleColumns.map((column) => { const cell = row.cells.find((entry) => entry.cellKey === column.key)!; const isSelected = selectedCell?.rowKey === row.rowKey && selectedCell.cellKey === column.key; const isEditing = editingCell?.rowKey === row.rowKey && editingCell.cellKey === column.key; const hasDraftIdentifierConflict = Boolean( isEditing && column.key === "equipmentIdentifier" && row.circuit && hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, editingCell?.draft ?? "") ); const hasIdentifierConflict = Boolean( column.key === "equipmentIdentifier" && row.circuit && (duplicateEquipmentIdentifierCircuitIds.has(row.circuit.id) || hasDraftIdentifierConflict) ); return ( ); })} ); })}
{openFilterColumn === column.key ? (
{column.label} filtern
Werte auswählen, die sichtbar bleiben sollen. Die Änderung wird erst beim Anwenden übernommen.
setFilterValueSearch(event.target.value)} placeholder="Werte suchen …" aria-label={`Werte für ${column.label} suchen`} autoFocus />
{filterDraftValues.length} / {(distinctValuesByColumn[column.key] ?? []).length}
{(distinctValuesByColumn[column.key] ?? []) .filter((value) => value.toLocaleLowerCase("de-DE").includes(filterValueSearch.trim().toLocaleLowerCase("de-DE"))) .map((value) => { const selected = filterDraftValues.includes(value); return ( ); })} {(distinctValuesByColumn[column.key] ?? []).filter((value) => value.toLocaleLowerCase("de-DE").includes(filterValueSearch.trim().toLocaleLowerCase("de-DE")) ).length === 0 ? (
Keine passenden Werte gefunden.
) : null}
{filterDraftValues.length === 0 ? ( Mindestens einen Wert auswählen. ) : ( )}
) : null}
Aktionen
{section.displayName}
{dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? ( {dropIntent.valid ? "Hier ablegen, um einen neuen Stromkreis zu erstellen" : "Das Gerät passt nicht in diesen Bereich"} ) : null}
0 && draggingDeviceRowIds.includes(row.device.id)) || (draggingDeviceRowIds.length === 0 && draggingDeviceRowId === row.device.id)) ? "device-dragging" : "" }`} draggable={ !isEditing && ((Boolean(row.device) && column.key === "displayName" && (row.rowType === "deviceRow" || row.rowType === "circuitCompact")) || (Boolean(row.circuit) && column.key === "equipmentIdentifier" && (row.rowType === "circuitCompact" || row.rowType === "circuitSummary" || row.rowType === "reserveCircuit"))) } onDragStart={(event) => { if ( row.circuit && column.key === "equipmentIdentifier" && (row.rowType === "circuitCompact" || row.rowType === "circuitSummary" || row.rowType === "reserveCircuit") ) { const circuitIds = getSelectedEligibleCircuitIds(row.circuit.id); setDraggingProjectDeviceId(null); setDropIntent(null); setDraggingDeviceRowId(null); setDraggingDeviceRowIds([]); setDeviceMoveIntent(null); setDraggingCircuitId(row.circuit.id); setDraggingCircuitIds(circuitIds); setCircuitReorderIntent(null); event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("application/x-circuit-id", row.circuit.id); event.dataTransfer.setData("application/x-circuit-ids", JSON.stringify(circuitIds)); return; } if ( row.device && column.key === "displayName" && (row.rowType === "deviceRow" || row.rowType === "circuitCompact") ) { const rowIds = getSelectedEligibleDeviceRowIds(row.device.id); setDraggingProjectDeviceId(null); setDropIntent(null); setDraggingCircuitId(null); setDraggingCircuitIds([]); setCircuitReorderIntent(null); setDraggingDeviceRowId(row.device.id); setDraggingDeviceRowIds(rowIds); setDeviceMoveIntent(null); event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("application/x-circuit-device-row-id", row.device.id); event.dataTransfer.setData("application/x-circuit-device-row-ids", JSON.stringify(rowIds)); } }} onDragEnd={() => { setDraggingDeviceRowId(null); setDraggingDeviceRowIds([]); setDeviceMoveIntent(null); setDraggingCircuitId(null); setDraggingCircuitIds([]); setCircuitReorderIntent(null); }} onClick={(event) => { if (cell.editable) { handleRowSelectionClick(row, column.key, { ctrlKey: event.ctrlKey, metaKey: event.metaKey, shiftKey: event.shiftKey, }); } }} onDoubleClick={() => { if (cell.editable) { startEdit({ rowKey: row.rowKey, cellKey: column.key }, "selectExisting"); } }} > {isEditing ? ( setEditingCell((current) => current ? { ...current, draft: event.target.value } : current ) } onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void commitEdit("stay"); } else if (event.key === "Escape") { event.preventDefault(); cancelEdit(); } else if (event.key === "Tab") { event.preventDefault(); void commitEdit(event.shiftKey ? "prev" : "next"); } }} onBlur={() => { requestAnimationFrame(() => { const active = document.activeElement as HTMLElement | null; if (!active || !containerRef.current?.contains(active)) { setEditingCell(null); focusGridWithoutScroll(); } }); }} /> ) : ( formatValue(cell.value) )} {row.circuit && row.rowType !== "deviceRow" ? ( <> ) : null} {row.device ? ( ) : null} {dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? ( {dropIntent.valid ? "Neuer Stromkreis in diesem Bereich" : "Das Gerät passt nicht in diesen Bereich"} ) : null} {dropIntent?.kind === "add-to-circuit" && dropIntent.circuitId === row.circuit?.id ? ( {dropIntent.valid ? "Zu diesem Stromkreis hinzufügen" : "Das Gerät passt nicht in den Bereich dieses Stromkreises"} ) : null} {deviceMoveIntent?.kind === "move-to-new-circuit" && row.rowType === "placeholder" && deviceMoveIntent.sectionId === row.sectionId ? ( {`${draggingDeviceCount || 1} Gerätezeile(n) in einen neuen Stromkreis verschieben${deviceMoveIntent.requiresConfirmation ? " (Bestätigung erforderlich)" : ""}`} ) : null} {deviceMoveIntent?.kind === "move-to-circuit" && deviceMoveIntent.circuitId === row.circuit?.id ? ( {`${draggingDeviceCount || 1} Gerätezeile(n) in diesen Stromkreis verschieben${deviceMoveIntent.requiresConfirmation ? " (Bestätigung erforderlich)" : ""}`} ) : null} {circuitReorderIntent?.kind === "section-end" && row.rowType === "placeholder" && circuitReorderIntent.sectionId === row.sectionId ? ( {circuitReorderIntent.valid ? `${draggingCircuitCount || 1} Stromkreis(e) ans Bereichsende verschieben` : "Bereichsübergreifendes Verschieben nicht zulässig"} ) : null} {circuitReorderIntent && (circuitReorderIntent.kind === "before-circuit" || circuitReorderIntent.kind === "after-circuit") && circuitReorderIntent.targetCircuitId === row.circuit?.id ? ( {circuitReorderIntent.valid ? circuitReorderIntent.kind === "before-circuit" ? `${draggingCircuitCount || 1} Stromkreis(e) vor diesen Stromkreis verschieben` : `${draggingCircuitCount || 1} Stromkreis(e) hinter diesen Stromkreis verschieben` : "Bereichsübergreifendes Verschieben nicht zulässig"} ) : null}

Strg+Plus fügt unterhalb des ausgewählten Stromkreises oder Geräts ein. Mit „Rückgängig“ lässt sich das Einfügen zurücknehmen.

); }