Files
leistungsbilanz-ts/src/frontend/components/circuit-tree-editor.tsx
T
2026-07-25 21:09:02 +02:00

3711 lines
140 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 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,
reorderSectionCircuits,
renumberCircuitSection,
redoProjectCommand,
updateSectionEquipmentIdentifiers,
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<SelectionIntent | null | void>;
undo: () => Promise<SelectionIntent | null | void>;
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<HTMLElement>) {
return event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey;
}
function createValuesFromEditPatch(
patch: Record<string, unknown>
): Record<string, unknown> {
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<CircuitTreeResponseDto | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// selectedCell is spreadsheet-style keyboard focus target within normalized grid.
// It can exist without an active edit input.
const [selectedCell, setSelectedCell] = useState<SelectedCell | null>(null);
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const [anchorRowKey, setAnchorRowKey] = useState<string | null>(null);
// editingCell is mounted input session state (draft + mode + focus token).
// It should only exist for currently editable grid cells.
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
const [activeSectionId, setActiveSectionId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
const [projectDeviceSearch, setProjectDeviceSearch] = useState("");
const [selectedProjectDeviceId, setSelectedProjectDeviceId] = useState<string | null>(null);
const [targetSectionId, setTargetSectionId] = useState<string | null>(null);
const [targetCircuitId, setTargetCircuitId] = useState<string | null>(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<string | null>(null);
const [dropIntent, setDropIntent] = useState<ProjectDeviceDropIntent | null>(null);
const [draggingDeviceRowId, setDraggingDeviceRowId] = useState<string | null>(null);
const [draggingDeviceRowIds, setDraggingDeviceRowIds] = useState<string[]>([]);
const [deviceMoveIntent, setDeviceMoveIntent] = useState<DeviceRowMoveDropIntent | null>(null);
const [draggingCircuitId, setDraggingCircuitId] = useState<string | null>(null);
const [draggingCircuitIds, setDraggingCircuitIds] = useState<string[]>([]);
const [circuitReorderIntent, setCircuitReorderIntent] = useState<CircuitReorderDropIntent | null>(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<HistoryCommand[]>([]);
const [redoStack, setRedoStack] = useState<HistoryCommand[]>([]);
const [historyBusy, setHistoryBusy] = useState(false);
const [sortState, setSortState] = useState<{ key: CellKey; direction: SortDirection } | null>(null);
const [columnFilters, setColumnFilters] = useState<Partial<Record<CellKey, string[]>>>({});
const [openFilterColumn, setOpenFilterColumn] = useState<CellKey | null>(null);
const [filterDraftValues, setFilterDraftValues] = useState<string[]>([]);
const [filterValueSearch, setFilterValueSearch] = useState("");
const [visibleColumnKeys, setVisibleColumnKeys] = useState<CellKey[]>(defaultVisibleColumnKeys);
const [columnOrder, setColumnOrder] = useState<CellKey[]>(allColumns.map((column) => column.key));
const [isColumnMenuOpen, setIsColumnMenuOpen] = useState(false);
const [columnSearch, setColumnSearch] = useState("");
const [draggingColumnKey, setDraggingColumnKey] = useState<CellKey | null>(null);
const [columnDropTargetKey, setColumnDropTargetKey] = useState<CellKey | null>(null);
const [pendingFocus, setPendingFocus] = useState<SelectedCell | null>(null);
const pendingSelectionAfterReload = useRef<SelectionIntent | null>(null);
const pendingSelectedDeviceRowIdsAfterReload = useRef<string[] | null>(null);
const pendingSelectedCircuitIdsAfterReload = useRef<string[] | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const focusTokenRef = useRef(1);
const projectRevisionRef = useRef<number | null>(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<Record<CellKey, string[]>> = {};
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 (
<div className="active-view-summary" aria-label="Aktive Sortierung und Filter">
<span className="active-view-label">Sortierung und Filter</span>
{sortState ? (
<button
type="button"
className="active-view-chip"
onClick={() => setSortState(null)}
title="Sortierung entfernen"
>
Sortierung: {sortedColumn?.label ?? sortState.key} ({sortState.direction === "asc" ? "aufsteigend" : "absteigend"})
<span aria-hidden="true"> ×</span>
</button>
) : null}
{activeColumnFilters.map(({ column, values }) => {
const shownValues = values.slice(0, 2).join(", ");
const valueSummary = values.length > 2 ? `${shownValues} +${values.length - 2}` : shownValues;
return (
<button
key={`active-filter-${column.key}`}
type="button"
className="active-view-chip"
onClick={() => clearColumnFilter(column.key)}
title={`Filter für ${column.label} entfernen`}
>
{column.label}: {valueSummary}
<span aria-hidden="true"> ×</span>
</button>
);
})}
<button type="button" className="active-view-reset" onClick={clearSortAndFilters}>
Ansicht zurücksetzen
</button>
</div>
);
}
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 (
<div className="column-settings-menu">
<div className="column-settings-title-row">
<strong>Spalten auswählen</strong>
<button type="button" className="column-settings-close" onClick={closeColumnSettingsMenu} aria-label="Spaltenmenü schließen">
×
</button>
</div>
<div className="column-settings-explanation">
Spalten ein- oder ausblenden. Die Reihenfolge lässt sich mit den Pfeilen oder per Ziehen ändern.
</div>
<input
type="search"
className="column-settings-search"
value={columnSearch}
onChange={(event) => setColumnSearch(event.target.value)}
placeholder="Spalten suchen …"
aria-label="Spalten suchen"
autoFocus
/>
<div className="column-settings-list">
{matchingColumns.map((column) => {
const visible = visibleColumnKeys.includes(column.key);
return (
<div
key={`${keyPrefix}-${column.key}`}
className={`column-settings-item ${visible ? "selected" : ""} ${draggingColumnKey === column.key ? "dragging" : ""} ${columnDropTargetKey === column.key ? "drop-target" : ""} ${column.locked ? "locked" : ""}`}
draggable={!column.locked}
onDragStart={(event) => handleColumnDragStart(event, column.key)}
onDragOver={(event) => handleColumnDragOver(event, column.key)}
onDrop={(event) => handleColumnDrop(event, column.key)}
onDragEnd={() => handleColumnDragEnd()}
>
<button
type="button"
className="column-visibility-button"
aria-pressed={visible}
disabled={column.locked}
onClick={() => toggleColumnVisibility(column.key)}
>
<span className="selection-marker" aria-hidden="true">{visible ? "✓" : ""}</span>
<span>{column.label}</span>
</button>
<div className="column-settings-order">
<button type="button" onClick={() => moveColumn(column.key, -1)} disabled={column.locked} title="Spalte nach links verschieben">
</button>
<button type="button" onClick={() => moveColumn(column.key, 1)} disabled={column.locked} title="Spalte nach rechts verschieben">
</button>
</div>
</div>
);
})}
{matchingColumns.length === 0 ? <div className="column-settings-empty">Keine passende Spalte gefunden.</div> : null}
</div>
<div className="column-settings-footer">
<button type="button" onClick={() => resetColumns()}>Standard wiederherstellen</button>
<button type="button" className="primary" onClick={closeColumnSettingsMenu}>Fertig</button>
</div>
</div>
);
}
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<string, CellKey[]>();
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;
}
// Sorting is view-only until explicitly applied; this avoids accidental persisted reorders.
await runCommand({
label: "Sortierte Reihenfolge übernehmen",
redo: async () => {
for (const section of changedSections) {
await reorderSectionCircuits(section.sectionId, section.order);
}
setSortState(null);
setOpenFilterColumn(null);
return null;
},
undo: async () => {
for (const section of beforeBySection) {
await reorderSectionCircuits(section.sectionId, section.order);
}
return 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<HTMLDivElement>, 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<HTMLDivElement>, targetKey: CellKey) {
if (!draggingColumnKey || targetKey === "equipmentIdentifier" || draggingColumnKey === targetKey) {
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = "move";
setColumnDropTargetKey(targetKey);
}
function handleColumnDrop(event: DragEvent<HTMLDivElement>, 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<SelectedCell> {
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<HTMLElement>) {
return event.dataTransfer.getData("application/x-project-device-id") || null;
}
function parseDraggedDeviceRowId(event: DragEvent<HTMLElement>) {
return event.dataTransfer.getData("application/x-circuit-device-row-id") || null;
}
function parseDraggedDeviceRowIds(event: DragEvent<HTMLElement>) {
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<HTMLElement>) {
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<string>();
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<string>();
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.
async function applyCircuitReorder(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);
}
await reorderSectionCircuits(section.id, 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<HTMLElement>, 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<HTMLElement>, 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<string, string>();
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<HTMLElement>, 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 beforeOrder = section.circuits.map((circuit) => circuit.id);
const primaryCircuitId = sourceCircuitIds[0];
await runCommand({
label: sourceCircuitIds.length > 1 ? `${sourceCircuitIds.length} Stromkreise neu anordnen` : "Stromkreise neu anordnen",
redo: async () => {
await applyCircuitReorder(intent, sourceCircuitIds);
pendingSelectedCircuitIdsAfterReload.current = sourceCircuitIds;
return {
rowKey: `circuitSummary:${primaryCircuitId}`,
cellKey: "equipmentIdentifier",
rowType: "circuitSummary",
sectionId: intent.sectionId,
circuitId: primaryCircuitId,
};
},
undo: async () => {
await reorderSectionCircuits(intent.sectionId, beforeOrder);
pendingSelectedCircuitIdsAfterReload.current = sourceCircuitIds;
return {
rowKey: `circuitSummary:${primaryCircuitId}`,
cellKey: "equipmentIdentifier",
rowType: "circuitSummary",
sectionId: intent.sectionId,
circuitId: primaryCircuitId,
};
},
});
}
// 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. Undo restores previous BMKs via safe section identifier update.
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 before = section.circuits.map((circuit) => ({
id: circuit.id,
equipmentIdentifier: circuit.equipmentIdentifier,
}));
await runCommand({
label: "Bereich neu nummerieren",
redo: async () => {
await renumberCircuitSection(sectionId);
return null;
},
undo: async () => {
// Restore prior BMKs through safe bulk endpoint to avoid transient unique collisions.
await updateSectionEquipmentIdentifiers(
sectionId,
before.map((entry) => ({
circuitId: entry.id,
equipmentIdentifier: entry.equipmentIdentifier,
}))
);
return 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<HTMLDivElement>) {
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 <div className="notice info">Stromkreislisteneditor wird geladen </div>;
}
if (error && !data) {
return <div className="notice error">{error}</div>;
}
if (!data || data.sections.length === 0) {
return <div className="notice muted">Keine Bereiche oder Stromkreise vorhanden.</div>;
}
if (filteredSortedSections.length === 0) {
return (
<div className="tree-editor-shell">
<div className="editor-toolbar">
<button type="button" onClick={() => void handleUndo()} disabled={undoStack.length === 0 || historyBusy || isSaving}>
Rückgängig
</button>
<button type="button" onClick={() => void handleRedo()} disabled={redoStack.length === 0 || historyBusy || isSaving}>
Wiederholen
</button>
<button
type="button"
onClick={clearSortAndFilters}
disabled={!Boolean(sortState) && !hasActiveFilters}
>
Sortierung/Filter zurücksetzen
</button>
<button type="button" onClick={toggleColumnSettingsMenu} aria-expanded={isColumnMenuOpen}>
Spalten
</button>
{renderColumnSettingsMenu("col-empty")}
</div>
{renderActiveViewSummary()}
{error ? (
<div className="notice error editor-error-notice" role="alert">
<span>{error}</span>
<button type="button" onClick={() => setError(null)}>Schließen</button>
</div>
) : null}
<div className="notice muted">Für die aktiven Filter wurden keine passenden Stromkreise gefunden.</div>
</div>
);
}
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 (
<div className="tree-editor-shell">
<div className="editor-toolbar">
<button type="button" onClick={() => void handleUndo()} disabled={undoStack.length === 0 || historyBusy || isSaving}>
Rückgängig
</button>
<button type="button" onClick={() => void handleRedo()} disabled={redoStack.length === 0 || historyBusy || isSaving}>
Wiederholen
</button>
{isSortedView ? (
<button
type="button"
onClick={() => void handleApplySortedOrder()}
disabled={hasActiveFilters || isSaving || historyBusy}
title={hasActiveFilters ? "Vor dem Übernehmen der Sortierung zuerst die Filter zurücksetzen." : undefined}
>
Sortierung übernehmen
</button>
) : null}
<button type="button" onClick={toggleColumnSettingsMenu} aria-expanded={isColumnMenuOpen}>
Spalten
</button>
<button
type="button"
onClick={clearSortAndFilters}
disabled={!isSortedView && !hasActiveFilters}
>
Sortierung/Filter zurücksetzen
</button>
{renderColumnSettingsMenu("col")}
</div>
{renderActiveViewSummary()}
{hasActiveSortOrFilter ? (
<div className="notice muted">Sortierung und Filter verändern nur die Ansicht. Die Neunummerierung ist währenddessen deaktiviert.</div>
) : null}
{isSortedView && hasActiveFilters ? (
<div className="notice muted">Die sortierte Reihenfolge kann erst nach dem Zurücksetzen der Filter übernommen werden.</div>
) : null}
{error ? (
<div className="notice error editor-error-notice" role="alert">
<span>{error}</span>
<button type="button" onClick={() => setError(null)}>Schließen</button>
</div>
) : null}
{duplicateEquipmentIdentifierCircuitIds.size > 0 ? (
<div className="notice warning" role="alert">
Doppelte Betriebsmittelkennzeichen sind hervorgehoben. Eine Neunummerierung erfolgt weiterhin nur ausdrücklich.
</div>
) : null}
{isSaving ? <div className="notice info">Wird gespeichert </div> : null}
<div className="tree-editor-layout">
<aside className="project-device-sidebar">
<h3>Projektgeräte</h3>
<input
type="text"
placeholder="Name oder Anzeigename suchen …"
value={projectDeviceSearch}
onChange={(event) => setProjectDeviceSearch(event.target.value)}
/>
<div className="project-device-list">
{searchableProjectDevices.map((device) => (
<button
key={device.id}
type="button"
className={`project-device-item ${selectedProjectDeviceId === device.id ? "selected" : ""} ${draggingProjectDeviceId === device.id ? "dragging" : ""}`}
onClick={() => setSelectedProjectDeviceId(device.id)}
draggable
onDragStart={(event) => {
setSelectedProjectDeviceId(device.id);
setDraggingProjectDeviceId(device.id);
setDraggingDeviceRowId(null);
setDraggingDeviceRowIds([]);
setDraggingCircuitId(null);
setDraggingCircuitIds([]);
setDeviceMoveIntent(null);
setCircuitReorderIntent(null);
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData("application/x-project-device-id", device.id);
}}
onDragEnd={() => {
setDraggingProjectDeviceId(null);
setDropIntent(null);
setDraggingDeviceRowIds([]);
setDraggingCircuitIds([]);
}}
>
<strong>{device.displayName || device.name}</strong>
<span>Name: {device.name}</span>
<span>Phasenart: {formatPhaseTypeLabel(device.phaseType)}</span>
<span>Anzahl: {device.quantity}</span>
<span>Leistung/Gerät: {device.powerPerUnit}</span>
<span>Gleichzeitigkeit: {device.simultaneityFactor}</span>
<span>Gesamtleistung: {device.totalPower}</span>
<span>Kostengruppe: {device.costGroup || "-"}</span>
<span>Kategorie: {device.category || "-"}</span>
</button>
))}
{searchableProjectDevices.length === 0 ? <p className="notice muted">Keine passenden Projektgeräte gefunden.</p> : null}
</div>
<div className="sidebar-actions">
<label>
Zielbereich
<select
value={targetSectionId ?? ""}
onChange={(event) => setTargetSectionId(event.target.value || null)}
>
<option value="">Bereich auswählen </option>
{data.sections.map((section) => {
const valid = !selectedProjectDevice || isProjectDevicePlacementValid(selectedProjectDevice, section);
return (
<option key={section.id} value={section.id} disabled={!valid}>
{section.displayName}{valid ? "" : " (nicht zulässig)"}
</option>
);
})}
</select>
</label>
{selectedProjectDevice && suggestedSection ? (
<p className="notice muted">Vorgeschlagener Bereich: {suggestedSection.displayName}</p>
) : null}
<button
type="button"
disabled={!canAddToSelectedSection}
onClick={() => void handleAddProjectDeviceAsNewCircuit()}
>
Als neuen Stromkreis hinzufügen
</button>
<label>
Zielstromkreis
<select
value={targetCircuitId ?? ""}
onChange={(event) => setTargetCircuitId(event.target.value || null)}
>
<option value="">Stromkreis der ausgewählten Zeile verwenden </option>
{circuitOptions.map((option) => {
const valid = !selectedProjectDevice || isProjectDeviceTargetValid(selectedProjectDevice, option.sectionId);
return (
<option key={option.id} value={option.id} disabled={!valid}>
{option.label}{valid ? "" : " (nicht zulässig)"}
</option>
);
})}
</select>
</label>
<button
type="button"
disabled={!canAddToSelectedCircuit}
onClick={() => void handleAddProjectDeviceToCircuit()}
>
Zum ausgewählten Stromkreis hinzufügen
</button>
</div>
</aside>
<div
className="tree-grid-wrap"
ref={containerRef}
tabIndex={0}
onFocus={handleContainerFocus}
onKeyDown={handleContainerKeyDown}
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
setSelectedRowKeys([]);
setAnchorRowKey(null);
}
}}
>
<table className="tree-grid">
<thead>
<tr>
{visibleColumns.map((column) => (
<th key={column.key} className={column.numeric ? "num" : ""}>
<div className="header-cell">
<button
type="button"
className="header-sort-btn"
aria-label={`${column.label} sortieren`}
title="Zum Sortieren klicken"
onClick={() => {
setSortState((current) => {
if (!current || current.key !== column.key) {
return { key: column.key, direction: "asc" };
}
if (current.direction === "asc") {
return { key: column.key, direction: "desc" };
}
return null;
});
}}
>
<span>{column.label}</span>
{sortState?.key === column.key ? (
<span className="sort-indicator" aria-label={sortState.direction === "asc" ? "aufsteigend" : "absteigend"}>
{sortState.direction === "asc" ? "↑" : "↓"}
</span>
) : null}
</button>
<button
type="button"
className={`header-filter-btn ${(columnFilters[column.key]?.length ?? 0) > 0 ? "active" : ""}`}
onClick={() => toggleColumnFilterMenu(column.key)}
aria-pressed={(columnFilters[column.key]?.length ?? 0) > 0}
title={
(columnFilters[column.key]?.length ?? 0) > 0
? `${columnFilters[column.key]?.length} Filterwert(e) ausgewählt`
: `${column.label} filtern`
}
>
Filtern{(columnFilters[column.key]?.length ?? 0) > 0 ? ` (${columnFilters[column.key]?.length})` : ""}
</button>
</div>
{openFilterColumn === column.key ? (
<div className="header-filter-menu">
<div className="header-filter-title-row">
<strong>{column.label} filtern</strong>
<button type="button" className="header-filter-close" onClick={closeColumnFilterMenu} aria-label="Filter schließen">
×
</button>
</div>
<div className="header-filter-explanation">
Werte auswählen, die sichtbar bleiben sollen. Die Änderung wird erst beim Anwenden übernommen.
</div>
<input
type="search"
className="header-filter-search"
value={filterValueSearch}
onChange={(event) => setFilterValueSearch(event.target.value)}
placeholder="Werte suchen …"
aria-label={`Werte für ${column.label} suchen`}
autoFocus
/>
<div className="header-filter-selection-actions">
<button
type="button"
onClick={() => setFilterDraftValues(distinctValuesByColumn[column.key] ?? [])}
>
Alle auswählen
</button>
<button type="button" onClick={() => setFilterDraftValues([])}>
Keine auswählen
</button>
<span>{filterDraftValues.length} / {(distinctValuesByColumn[column.key] ?? []).length}</span>
</div>
<div className="header-filter-values">
{(distinctValuesByColumn[column.key] ?? [])
.filter((value) => value.toLocaleLowerCase("de-DE").includes(filterValueSearch.trim().toLocaleLowerCase("de-DE")))
.map((value) => {
const selected = filterDraftValues.includes(value);
return (
<button
key={`${column.key}-${value}`}
type="button"
className={`header-filter-item ${selected ? "selected" : ""}`}
aria-pressed={selected}
onClick={() =>
setFilterDraftValues((current) =>
selected
? current.filter((entry) => entry !== value)
: [...current, value]
)
}
>
<span className="selection-marker" aria-hidden="true">{selected ? "✓" : ""}</span>
<span>{value}</span>
</button>
);
})}
{(distinctValuesByColumn[column.key] ?? []).filter((value) =>
value.toLocaleLowerCase("de-DE").includes(filterValueSearch.trim().toLocaleLowerCase("de-DE"))
).length === 0 ? (
<div className="header-filter-empty">Keine passenden Werte gefunden.</div>
) : null}
</div>
<div className="header-filter-footer">
{filterDraftValues.length === 0 ? (
<span className="header-filter-warning">Mindestens einen Wert auswählen.</span>
) : (
<span />
)}
<div className="header-filter-footer-actions">
<button type="button" onClick={closeColumnFilterMenu}>Abbrechen</button>
<button
type="button"
className="primary"
onClick={() => applyColumnFilter(column.key)}
disabled={filterDraftValues.length === 0}
>
{filterDraftValues.length === (distinctValuesByColumn[column.key] ?? []).length
? "Alle anzeigen"
: "Filter anwenden"}
</button>
</div>
</div>
</div>
) : null}
</th>
))}
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{visibleRows.map((row) => {
if (row.rowType === "section") {
const section = data.sections.find((entry) => entry.id === row.sectionId)!;
return (
<tr
key={row.rowKey}
className={`section-row ${
dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id && dropIntent.valid
? "drop-target-active"
: ""
} ${
dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id && !dropIntent.valid
? "drop-target-invalid"
: ""
}`}
onDragOver={(event) => {
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,
});
}
}}
>
<td colSpan={visibleColumns.length + 1} className="section-drop-cell">
<div className="section-content">
<strong>{section.displayName}</strong>
<div className="section-actions">
<button type="button" tabIndex={-1} onClick={() => void handleAddReserveCircuit(section.id)}>
Stromkreis hinzufügen
</button>
<button
type="button"
tabIndex={-1}
disabled={hasActiveSortOrFilter}
onClick={() => void handleRenumberSection(section.id)}
title={hasActiveSortOrFilter ? "Vor der Neunummerierung Sortierung und Filter zurücksetzen." : undefined}
>
Bereich neu nummerieren
</button>
</div>
</div>
{dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? (
<span className="drop-hint">
{dropIntent.valid ? "Hier ablegen, um einen neuen Stromkreis zu erstellen" : "Das Gerät passt nicht in diesen Bereich"}
</span>
) : null}
</td>
</tr>
);
}
return (
<tr
key={row.rowKey}
className={`${
row.rowType === "circuitSummary"
? "summary-row"
: row.rowType === "deviceRow"
? "device-row"
: row.rowType === "reserveCircuit"
? "empty-circuit-row"
: row.rowType === "placeholder"
? "placeholder-row"
: ""
} ${
row.rowType === "placeholder" &&
((dropIntent?.kind === "new-circuit" && dropIntent.sectionId === row.sectionId && dropIntent.valid) ||
(deviceMoveIntent?.kind === "move-to-new-circuit" &&
deviceMoveIntent.sectionId === row.sectionId &&
!deviceMoveIntent.requiresConfirmation) ||
(circuitReorderIntent?.kind === "section-end" && circuitReorderIntent.sectionId === row.sectionId))
? "drop-target-active"
: ""
} ${
row.rowType === "placeholder" &&
deviceMoveIntent?.kind === "move-to-new-circuit" &&
deviceMoveIntent.sectionId === row.sectionId &&
deviceMoveIntent.requiresConfirmation
? "drop-target-confirm"
: ""
} ${
row.circuit &&
deviceMoveIntent?.kind === "move-to-circuit" &&
deviceMoveIntent.circuitId === row.circuit.id &&
deviceMoveIntent.requiresConfirmation
? "drop-target-confirm"
: ""
} ${
row.rowType === "placeholder" &&
dropIntent?.kind === "new-circuit" &&
dropIntent.sectionId === row.sectionId &&
!dropIntent.valid
? "drop-target-invalid"
: ""
} ${
row.rowType === "placeholder" &&
circuitReorderIntent?.kind === "section-end" &&
circuitReorderIntent.sectionId === row.sectionId &&
!circuitReorderIntent.valid
? "drop-target-invalid"
: ""
} ${
row.circuit &&
dropIntent?.kind === "add-to-circuit" &&
dropIntent.circuitId === row.circuit.id &&
dropIntent.valid
? "drop-target-active"
: ""
} ${
row.circuit &&
dropIntent?.kind === "add-to-circuit" &&
dropIntent.circuitId === row.circuit.id &&
!dropIntent.valid
? "drop-target-invalid"
: ""
} ${
row.circuit &&
circuitReorderIntent &&
(circuitReorderIntent.kind === "before-circuit" || circuitReorderIntent.kind === "after-circuit") &&
circuitReorderIntent.targetCircuitId === row.circuit.id &&
circuitReorderIntent.valid
? "drop-target-active"
: ""
} ${
row.circuit &&
circuitReorderIntent?.kind === "before-circuit" &&
circuitReorderIntent.targetCircuitId === row.circuit.id &&
circuitReorderIntent.valid
? "circuit-insert-before"
: ""
} ${
row.circuit &&
circuitReorderIntent?.kind === "after-circuit" &&
circuitReorderIntent.targetCircuitId === row.circuit.id &&
circuitReorderIntent.valid
? "circuit-insert-after"
: ""
} ${
row.circuit &&
circuitReorderIntent &&
(circuitReorderIntent.kind === "before-circuit" || circuitReorderIntent.kind === "after-circuit") &&
circuitReorderIntent.targetCircuitId === row.circuit.id &&
!circuitReorderIntent.valid
? "drop-target-invalid"
: ""
} ${
row.circuit?.id &&
((draggingCircuitIds.length > 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 (
<td
key={column.key}
className={`${column.numeric ? "num" : ""} ${cell.editable ? "cell-editable" : ""} ${isSelected ? "cell-selected" : ""} ${hasIdentifierConflict ? "cell-invalid" : ""} ${
Boolean(row.device) && column.key === "displayName" && (row.rowType === "deviceRow" || row.rowType === "circuitCompact")
? "device-drag-handle"
: ""
} ${
column.key === "equipmentIdentifier" &&
Boolean(row.circuit) &&
(row.rowType === "circuitCompact" || row.rowType === "circuitSummary" || row.rowType === "reserveCircuit")
? "circuit-drag-handle"
: ""
} ${
row.device?.id &&
((draggingDeviceRowIds.length > 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 ? (
<input
ref={inputRef}
value={editingCell.draft}
aria-invalid={hasDraftIdentifierConflict}
title={hasDraftIdentifierConflict ? "Dieses Betriebsmittelkennzeichen ist bereits vorhanden." : undefined}
onChange={(event) =>
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)
)}
</td>
);
})}
<td
className={`action-cell ${
(dropIntent?.kind === "add-to-circuit" &&
dropIntent.circuitId === row.circuit?.id &&
dropIntent.valid) ||
(deviceMoveIntent?.kind === "move-to-circuit" &&
deviceMoveIntent.circuitId === row.circuit?.id &&
!deviceMoveIntent.requiresConfirmation)
? "drop-target-active"
: ""
} ${
dropIntent?.kind === "add-to-circuit" &&
dropIntent.circuitId === row.circuit?.id &&
!dropIntent.valid
? "drop-target-invalid"
: ""
} ${
deviceMoveIntent?.kind === "move-to-circuit" &&
deviceMoveIntent.circuitId === row.circuit?.id &&
deviceMoveIntent.requiresConfirmation
? "drop-target-confirm"
: ""
}`}
>
{row.circuit && row.rowType !== "deviceRow" ? (
<>
<button
type="button"
tabIndex={-1}
onClick={() => void handleAddManualDevice(row.circuit!, row.sectionId)}
>
Manuelles Gerät hinzufügen
</button>
<button
type="button"
tabIndex={-1}
onClick={() => void handleDeleteCircuit(row.circuit!.id)}
>
Stromkreis löschen
</button>
</>
) : null}
{row.device ? (
<button type="button" tabIndex={-1} onClick={() => void handleDeleteDevice(row.device!.id)}>
Gerät löschen
</button>
) : null}
{dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? (
<span className="drop-hint">
{dropIntent.valid ? "Neuer Stromkreis in diesem Bereich" : "Das Gerät passt nicht in diesen Bereich"}
</span>
) : null}
{dropIntent?.kind === "add-to-circuit" && dropIntent.circuitId === row.circuit?.id ? (
<span className="drop-hint">
{dropIntent.valid ? "Zu diesem Stromkreis hinzufügen" : "Das Gerät passt nicht in den Bereich dieses Stromkreises"}
</span>
) : null}
{deviceMoveIntent?.kind === "move-to-new-circuit" &&
row.rowType === "placeholder" &&
deviceMoveIntent.sectionId === row.sectionId ? (
<span className="drop-hint">
{`${draggingDeviceCount || 1} Gerätezeile(n) in einen neuen Stromkreis verschieben${deviceMoveIntent.requiresConfirmation ? " (Bestätigung erforderlich)" : ""}`}
</span>
) : null}
{deviceMoveIntent?.kind === "move-to-circuit" && deviceMoveIntent.circuitId === row.circuit?.id ? (
<span className="drop-hint">
{`${draggingDeviceCount || 1} Gerätezeile(n) in diesen Stromkreis verschieben${deviceMoveIntent.requiresConfirmation ? " (Bestätigung erforderlich)" : ""}`}
</span>
) : null}
{circuitReorderIntent?.kind === "section-end" &&
row.rowType === "placeholder" &&
circuitReorderIntent.sectionId === row.sectionId ? (
<span className="drop-hint">
{circuitReorderIntent.valid
? `${draggingCircuitCount || 1} Stromkreis(e) ans Bereichsende verschieben`
: "Bereichsübergreifendes Verschieben nicht zulässig"}
</span>
) : null}
{circuitReorderIntent &&
(circuitReorderIntent.kind === "before-circuit" || circuitReorderIntent.kind === "after-circuit") &&
circuitReorderIntent.targetCircuitId === row.circuit?.id ? (
<span className="drop-hint">
{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"}
</span>
) : null}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<p className="todo-hint">
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.
</p>
</div>
);
}