Files
leistungsbilanz-ts/src/frontend/components/circuit-tree-editor.tsx
T
2026-07-31 07:32:46 +02:00

4113 lines
155 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 {
resolveCircuitPhaseType,
resolveProjectVoltage,
} from "../../domain/services/project-voltage.service";
import {
getInsertionSortOrder,
isGridInsertionRowType,
resolveGridInsertionIntent,
} from "../utils/circuit-grid-insertion";
import {
findDuplicateEquipmentIdentifierCircuitIds,
hasEquipmentIdentifierConflict,
requiresCrossSectionMoveConfirmation,
resolveGridDeleteIntent,
} from "../utils/circuit-grid-safety";
import {
allColumns,
buildCircuitEditPatch,
buildDeviceRowEditPatch,
defaultVisibleColumnKeys,
deviceFieldKeys,
formatPhaseTypeLabel,
formatValue,
getCircuitSectionLabel,
getProjectColumnLayoutStorageKey,
isGridEditorControlTarget,
normalizeColumnOrder,
parseStoredColumnLayout,
} from "../utils/circuit-grid-model";
import {
buildCircuitDeviceRowInsertSnapshot,
buildCircuitInsertSnapshot,
} from "../utils/circuit-structure-command";
import {
buildCircuitDeviceRowMoveAssignments,
} from "../utils/circuit-device-row-move-command";
import {
buildCircuitSectionReorderAssignments,
} from "../utils/circuit-section-reorder-command";
import {
buildCircuitSectionRenumberAssignments,
} from "../utils/circuit-section-renumber-command";
import {
buildNewCircuitGroupSnapshot,
canDeleteCircuitGroup,
renameCircuitGroupSnapshot,
toCircuitGroupSnapshot,
} from "../utils/circuit-group-editing";
import {
buildDistributionBoardComponentSnapshot,
getNextComponentSortOrder,
getSuggestedGroupComponentIdentifier,
toDistributionBoardComponentSnapshot,
updateDistributionBoardComponentSnapshot,
type DistributionBoardComponentEditorValues,
type MutableDistributionBoardComponentRole,
} from "../utils/distribution-board-component-editing";
import { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history";
import type {
CellKey,
ColumnDef,
RowType,
} from "../utils/circuit-grid-model";
import {
buildVisibleGridRowsWithStructure,
filterAndSortCircuitSections,
getDistinctFilterValues,
} from "../utils/circuit-grid-projection";
import type { VisibleGridRow } from "../utils/circuit-grid-projection";
import {
deleteCircuitCommand,
deleteCircuitDeviceRowCommand,
deleteCircuitGroupCommand,
deleteDistributionBoardComponentCommand,
getCircuitTree,
getNextCircuitIdentifier,
getProjectHistory,
insertCircuitCommand,
insertCircuitDeviceRowCommand,
insertCircuitGroupCommand,
insertDistributionBoardComponentCommand,
listProjectDevices,
moveCircuitDeviceRowsCommand,
moveCircuitDeviceRowsToNewCircuitCommand,
reorderCircuitSectionCommand,
reorderCircuitSectionsCommand,
renumberCircuitSectionCommand,
redoProjectCommand,
updateCircuitById,
updateCircuitDeviceRowById,
updateCircuitGroupCommand,
updateDistributionBoardComponentCommand,
undoProjectCommand,
} from "../utils/api";
import type {
CircuitTreeCircuitDto,
CircuitTreeComponentDto,
CircuitTreeResponseDto,
CreateCircuitDeviceRowInputDto,
CreateCircuitInputDto,
ProjectCommandResultDto,
ProjectDeviceDto,
ProjectHistoryStateDto,
} 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";
import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
import { CircuitGroupModal } from "./circuit-group-modal";
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group";
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>;
}
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 LEGACY_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;
}
type StructureComponentEditorIntent =
| {
kind: "create";
role: MutableDistributionBoardComponentRole;
sectionId?: string;
initialEquipmentIdentifier?: string;
}
| {
kind: "edit";
component: CircuitTreeComponentDto;
};
type CircuitGroupEditorIntent =
| { kind: "create" }
| { kind: "edit"; section: CircuitTreeResponseDto["sections"][number] };
function getFullColumnLabel(column: ColumnDef): string {
return column.fullLabel ?? column.label;
}
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 [componentEditorIntent, setComponentEditorIntent] =
useState<StructureComponentEditorIntent | null>(null);
const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] =
useState<CircuitGroupEditorIntent | null>(null);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] =
useState(false);
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);
const [historyState, setHistoryState] = useState<ProjectHistoryStateDto | null>(null);
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 [
loadedColumnLayoutProjectId,
setLoadedColumnLayoutProjectId,
] = useState<string | null>(null);
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 | HTMLSelectElement | 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, history } = await loadCircuitEditorSnapshot(
() => getCircuitTree(projectId, circuitListId),
() => getProjectHistory(projectId)
);
projectRevisionRef.current = history.currentRevision;
setHistoryState(history);
setData(tree);
} catch (err) {
setError(normalizeUiError(err));
} finally {
if (showLoading) {
setIsLoading(false);
}
}
}
// Initial/identity-change tree load for current route context.
useEffect(() => {
void loadTree({ showLoading: true });
}, [projectId, circuitListId]);
async function loadProjectDeviceList() {
const list = await listProjectDevices(projectId);
setProjectDevices(list);
}
// Loads project-device palette used for sidebar drag/drop and quick inserts.
useEffect(() => {
void loadProjectDeviceList().catch((err) => {
setError(normalizeUiError(err));
});
}, [projectId]);
useEffect(() => {
setLoadedColumnLayoutProjectId(null);
setColumnOrder(allColumns.map((column) => column.key));
setVisibleColumnKeys(defaultVisibleColumnKeys);
try {
const parsed = parseStoredColumnLayout(
localStorage.getItem(
getProjectColumnLayoutStorageKey(projectId)
) ??
localStorage.getItem(
LEGACY_COLUMN_LAYOUT_STORAGE_KEY
)
);
if (!parsed) {
return;
}
setColumnOrder(parsed.order);
setVisibleColumnKeys(parsed.visible);
} catch {
// ignore invalid local storage and use defaults
} finally {
setLoadedColumnLayoutProjectId(projectId);
}
}, [projectId]);
useEffect(() => {
if (loadedColumnLayoutProjectId !== projectId) {
return;
}
const payload = {
order: columnOrder,
visible: visibleColumnKeys,
};
localStorage.setItem(
getProjectColumnLayoutStorageKey(projectId),
JSON.stringify(payload)
);
}, [
columnOrder,
loadedColumnLayoutProjectId,
projectId,
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 buildVisibleGridRowsWithStructure(filteredSortedSections, {
headerComponents: data?.headerComponents ?? [],
footerComponents: data?.footerComponents ?? [],
});
}, [data, 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)
.map((value) =>
column.key === "phaseType"
? formatPhaseTypeLabel(value)
: value
)
.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 renderDistributionPowerSummary() {
if (!data) {
return null;
}
return (
<section
className="distribution-power-summary"
aria-label="Leistungszusammenfassung des Verteilers"
>
<div>
<span>Gesamtleistung Verteiler</span>
<strong>
{formatValue(
data.distributionBoardTotalPower,
"rowTotalPower"
)}{" "}
kW
</strong>
</div>
<div>
<span>Verteilerweiter Gleichzeitigkeitsfaktor</span>
<strong>
{formatValue(
data.distributionBoardSimultaneityFactor,
"simultaneityFactor"
)}
</strong>
</div>
<div>
<span>
Gesamtleistung Verteiler unter Berücksichtigung des
Gleichzeitigkeitsfaktors
</span>
<strong>
{formatValue(
data.distributionBoardTotalPowerWithSimultaneityFactor,
"rowTotalPower"
)}{" "}
kW
</strong>
</div>
</section>
);
}
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} ${getFullColumnLabel(column)}`
.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>{getFullColumnLabel(column)}</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;
setHistoryState(result.history);
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 section = data?.sections.find(
(entry) => entry.id === values.sectionId
);
if (!section || !data) {
throw new Error("Bereich wurde nicht gefunden.");
}
const voltage = resolveProjectVoltage(
resolveCircuitPhaseType(
section.key,
deviceRowValues.map((row) => row.phaseType)
),
data
);
const circuitId = crypto.randomUUID();
const deviceRows = deviceRowValues.map((row, index) =>
createDeviceRowSnapshot(circuitId, row, (index + 1) * 10)
);
return buildCircuitInsertSnapshot({
id: circuitId,
circuitListId,
values: { ...values, voltage },
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. The server records it in project-wide history.
async function runCommand(command: HistoryCommand) {
try {
setError(null);
setIsSaving(true);
const intent = await command.redo();
await reloadWithIntent(intent ?? null);
} catch (err) {
const message = normalizeUiError(err);
await loadTree({ showLoading: false });
setError(message);
} finally {
setIsSaving(false);
}
}
// Applies the next eligible project-wide history operation. Selection is only
// a best-effort local hint; command eligibility and data changes stay server-owned.
async function applyHistory(mode: "undo" | "redo") {
try {
setError(null);
setHistoryBusy(true);
setIsSaving(true);
const intent = selectedCell ? buildSelectionIntent(selectedCell) : null;
const result =
mode === "undo"
? await undoProjectCommand(
projectId,
getExpectedProjectRevision()
)
: await redoProjectCommand(
projectId,
getExpectedProjectRevision()
);
applyProjectCommandResult(result);
await Promise.all([
reloadWithIntent(intent),
loadProjectDeviceList(),
]);
} catch (err) {
const message = normalizeUiError(err);
await loadTree({ showLoading: false });
setError(message);
} finally {
setIsSaving(false);
setHistoryBusy(false);
}
}
async function handleUndo() {
if (historyBusy || isSaving || !historyState || historyState.undoDepth === 0) {
return;
}
await applyHistory("undo");
}
async function handleSaveDistributionBoardComponent(
values: DistributionBoardComponentEditorValues
) {
const intent = componentEditorIntent;
if (!intent || !data) {
return;
}
await runCommand({
label:
intent.kind === "edit"
? "Verteilerkomponente bearbeiten"
: "Verteilerkomponente hinzufügen",
redo: async () => {
const result =
intent.kind === "edit"
? await (() => {
const expected = toDistributionBoardComponentSnapshot(
intent.component
);
return updateDistributionBoardComponentCommand(
projectId,
getExpectedProjectRevision(),
expected,
updateDistributionBoardComponentSnapshot(expected, values)
);
})()
: await (() => {
const components =
intent.role === "auxiliary"
? data.footerComponents
: data.sections.find(
(section) => section.id === intent.sectionId
)?.components ?? [];
const snapshot = buildDistributionBoardComponentSnapshot({
id: crypto.randomUUID(),
circuitListId,
role: intent.role,
sectionId: intent.sectionId,
sortOrder: getNextComponentSortOrder(components),
values,
});
return insertDistributionBoardComponentCommand(
projectId,
getExpectedProjectRevision(),
snapshot
);
})();
applyProjectCommandResult(result);
setComponentEditorIntent(null);
return null;
},
});
}
async function handleDeleteDistributionBoardComponent(
component: CircuitTreeComponentDto
) {
if (
!confirm(
`Verteilerkomponente „${component.equipmentIdentifier} ${component.name}“ entfernen?`
)
) {
return;
}
await runCommand({
label: "Verteilerkomponente entfernen",
redo: async () => {
const result = await deleteDistributionBoardComponentCommand(
projectId,
getExpectedProjectRevision(),
toDistributionBoardComponentSnapshot(component)
);
applyProjectCommandResult(result);
setComponentEditorIntent(null);
return null;
},
});
}
async function handleSaveCircuitGroup(values: {
category: CircuitGroupCategory;
displayName: string;
}) {
const intent = circuitGroupEditorIntent;
if (!intent || !data) {
return;
}
await runCommand({
label:
intent.kind === "create"
? "Stromkreisgruppe hinzufügen"
: "Stromkreisgruppe bearbeiten",
redo: async () => {
const result =
intent.kind === "create"
? await insertCircuitGroupCommand(
projectId,
getExpectedProjectRevision(),
buildNewCircuitGroupSnapshot({
id: crypto.randomUUID(),
circuitListId,
category: values.category,
displayName: values.displayName,
sections: data.sections,
})
)
: await (() => {
const expected = toCircuitGroupSnapshot(
intent.section,
circuitListId
);
return updateCircuitGroupCommand(
projectId,
getExpectedProjectRevision(),
expected,
renameCircuitGroupSnapshot(expected, values.displayName)
);
})();
applyProjectCommandResult(result);
setCircuitGroupEditorIntent(null);
return null;
},
});
}
async function handleDeleteEmptyCircuitGroup(
section: CircuitTreeResponseDto["sections"][number]
) {
if (!canDeleteCircuitGroup(section)) {
return;
}
if (!confirm(`Leere Stromkreisgruppe „${section.displayName}“ entfernen?`)) {
return;
}
await runCommand({
label: "Leere Stromkreisgruppe entfernen",
redo: async () => {
const result = await deleteCircuitGroupCommand(
projectId,
getExpectedProjectRevision(),
toCircuitGroupSnapshot(section, circuitListId)
);
applyProjectCommandResult(result);
setCircuitGroupEditorIntent(null);
return null;
},
});
}
async function handleRedo() {
if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) {
return;
}
await applyHistory("redo");
}
// Persists currently sorted block order into section sortOrder values.
async function handleApplySortedOrder() {
if (!sortState || hasActiveFilters || !data) {
return;
}
const beforeBySection = data.sections.map((section) => ({
sectionId: section.id,
order: section.circuits.map((circuit) => circuit.id),
}));
const afterBySection = filteredSortedSections.map((section) => ({
sectionId: section.id,
order: section.circuits.map((circuit) => circuit.id),
}));
const changedSections = afterBySection.filter((after) => {
const before = beforeBySection.find((entry) => entry.sectionId === after.sectionId);
if (!before) {
return false;
}
if (before.order.length !== after.order.length) {
return false;
}
return before.order.some((id, index) => id !== after.order[index]);
});
if (changedSections.length === 0) {
setSortState(null);
return;
}
const reorderedSections = changedSections.map((changedSection) => {
const currentSection = data.sections.find(
(section) => section.id === changedSection.sectionId
);
if (!currentSection) {
throw new Error("Ein sortierter Bereich wurde nicht gefunden.");
}
return {
sectionId: currentSection.id,
assignments: buildCircuitSectionReorderAssignments(
currentSection.circuits,
changedSection.order
),
};
});
// Sorting is view-only until explicitly applied. All affected sections are
// then committed as one atomic project-history entry.
await runCommand({
label: "Sortierte Reihenfolge übernehmen",
redo: async () => {
const result = await reorderCircuitSectionsCommand(
projectId,
getExpectedProjectRevision(),
reorderedSections,
"Sortierte Reihenfolge übernehmen"
);
applyProjectCommandResult(result);
setSortState(null);
setOpenFilterColumn(null);
return null;
},
});
}
// 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 (
inputRef.current instanceof HTMLInputElement &&
editingCell.mode === "selectExisting"
) {
inputRef.current.select();
} else if (inputRef.current instanceof HTMLInputElement) {
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" && cell.cellKey !== "phaseType"
? 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",
draftOverride?: string
) {
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;
}
const draft = draftOverride ?? editingCell.draft;
if (
editingCell.cellKey === "equipmentIdentifier" &&
row.circuit &&
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, 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") {
const command: HistoryCommand = {
label: "Aus freier Zeile erstellen",
redo: async () => {
const selection = await createFromPlaceholder(
row.sectionId,
editingCell.cellKey,
draft
);
targetSelection = selection;
return nextSelectionIntent();
},
};
setEditingCell(null);
await runCommand(command);
return;
}
if (cell.kind === "circuitField" && row.circuit) {
const next = draft;
const command: HistoryCommand = {
label: "Stromkreisfeld bearbeiten",
redo: async () => {
await patchCircuit(row.circuit!.id, editingCell.cellKey, next);
return nextSelectionIntent();
},
};
setEditingCell(null);
await runCommand(command);
return;
}
if (cell.kind === "deviceField" && row.device) {
const next = draft;
const command: HistoryCommand = {
label: "Gerätefeld bearbeiten",
redo: async () => {
await patchDeviceRow(row.device!.id, editingCell.cellKey, next);
return nextSelectionIntent();
},
};
setEditingCell(null);
await runCommand(command);
return;
}
if (cell.kind === "deviceField" && row.rowType === "reserveCircuit" && row.circuit) {
const next = draft;
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"
);
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey };
return nextSelectionIntent();
},
};
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;
}
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");
setActiveSectionId(sectionId);
return {
rowKey: `reserveCircuit:${circuit.id}`,
cellKey: "equipmentIdentifier",
rowType: "reserveCircuit",
sectionId,
circuitId: circuit.id,
};
},
});
}
async function handleAddManualDevice(
circuit: CircuitTreeCircuitDto,
sectionId: string,
afterDeviceRowId?: string
) {
const originalDeviceCount = circuit.deviceRows.length;
const section = data?.sections.find((entry) => entry.id === sectionId);
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"
);
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,
};
},
});
}
async function handleInsertBelowSelection() {
const row = selectedCell ? findRow(selectedCell.rowKey) : null;
const cell = row?.cells.find((entry) => entry.cellKey === selectedCell?.cellKey);
const selection =
row && cell && isGridInsertionRowType(row.rowType)
? {
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;
}
await runCommand({
label: "Projektgerät als neuen Stromkreis hinzufügen",
redo: async () => {
const created = await insertProjectDeviceAsNewCircuit(device, targetSectionId);
return {
rowKey: `device:${created.rowId}`,
cellKey: "displayName",
rowType: "deviceRow",
sectionId: targetSectionId,
circuitId: created.circuitId,
deviceId: created.rowId,
};
},
});
}
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;
}
await runCommand({
label: "Projektgerät zum Stromkreis hinzufügen",
redo: async () => {
const created = await insertProjectDeviceToCircuit(device, circuitId);
return {
rowKey: `device:${created.rowId}`,
cellKey: "displayName",
rowType: "deviceRow",
sectionId: findCircuitSectionId(circuitId) ?? "",
circuitId,
deviceId: created.rowId,
};
},
});
}
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.
function resolveCircuitReorderOrder(intent: CircuitReorderDropIntent, sourceCircuitIds: string[]) {
const section = data?.sections.find((entry) => entry.id === intent.sectionId);
if (!section) {
throw new Error("Der Bereich ist ungültig.");
}
const ids = section.circuits.map((circuit) => circuit.id);
const block = sourceCircuitIds.filter((id) => ids.includes(id));
if (block.length === 0) {
throw new Error("Der Quellstromkreis ist ungültig.");
}
const blockSet = new Set(block);
const nextIds = ids.filter((id) => !blockSet.has(id));
if (intent.kind === "section-end") {
nextIds.push(...block);
} else {
const targetIndex = nextIds.indexOf(intent.targetCircuitId);
if (targetIndex < 0) {
throw new Error("Der Zielstromkreis ist ungültig.");
}
const insertIndex = intent.kind === "after-circuit" ? targetIndex + 1 : targetIndex;
nextIds.splice(insertIndex, 0, ...block);
}
return nextIds;
}
// Handles project-device drag intent. This path creates linked rows/circuits and
// must remain separate from move handlers that operate on existing entities.
async function handleDropWithIntent(event: DragEvent<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") {
await runCommand({
label: "Projektgerät in neuen Stromkreis ziehen",
redo: async () => {
const created = await insertProjectDeviceAsNewCircuit(device, intent.sectionId);
return {
rowKey: `device:${created.rowId}`,
cellKey: "displayName",
rowType: "deviceRow",
sectionId: intent.sectionId,
circuitId: created.circuitId,
deviceId: created.rowId,
};
},
});
return;
}
await runCommand({
label: "Projektgerät in Stromkreis ziehen",
redo: async () => {
const created = await insertProjectDeviceToCircuit(device, intent.circuitId);
return {
rowKey: `device:${created.rowId}`,
cellKey: "displayName",
rowType: "deviceRow",
sectionId: intent.sectionId,
circuitId: intent.circuitId,
deviceId: created.rowId,
};
},
});
}
// 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;
},
});
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;
},
});
}
// 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 primaryCircuitId = sourceCircuitIds[0];
const targetOrder = resolveCircuitReorderOrder(
intent,
sourceCircuitIds
);
const assignments = buildCircuitSectionReorderAssignments(
section.circuits,
targetOrder
);
if (assignments.length === 0) {
return;
}
const label =
sourceCircuitIds.length > 1
? `${sourceCircuitIds.length} Stromkreise neu anordnen`
: "Stromkreise neu anordnen";
const selectionIntent: SelectionIntent = {
rowKey: `circuitSummary:${primaryCircuitId}`,
cellKey: "equipmentIdentifier",
rowType: "circuitSummary",
sectionId: intent.sectionId,
circuitId: primaryCircuitId,
};
await runCommand({
label,
redo: async () => {
const result = await reorderCircuitSectionCommand(
projectId,
getExpectedProjectRevision(),
intent.sectionId,
assignments,
label
);
applyProjectCommandResult(result);
pendingSelectedCircuitIdsAfterReload.current = sourceCircuitIds;
return selectionIntent;
},
});
}
// 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;
},
});
}
// 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;
},
});
}
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 && isGridInsertionRowType(row.rowType)
? {
rowType: row.rowType,
cellKind: cell.kind,
circuitId: row.circuit?.id,
deviceId: row.device?.id,
}
: null
);
if (!intent) {
return;
}
if (intent.kind === "device") {
await handleDeleteDevice(intent.deviceId);
return;
}
await handleDeleteCircuit(intent.circuitId);
}
// Explicit renumber action through project history. Sorting and device rows
// remain unchanged; the server handles collision-safe identifier swaps.
async function handleRenumberSection(sectionId: string) {
if (!confirm("Diesen Bereich neu nummerieren? Nur die Stromkreise in diesem Bereich werden geändert.")) {
return;
}
const section = data?.sections.find((entry) => entry.id === sectionId);
if (!section) {
return;
}
const assignments = buildCircuitSectionRenumberAssignments(
data?.sections ?? [],
sectionId
);
if (assignments.length === 0) {
return;
}
const label = "Bereich neu nummerieren";
await runCommand({
label,
redo: async () => {
const result = await renumberCircuitSectionCommand(
projectId,
getExpectedProjectRevision(),
sectionId,
assignments,
label
);
applyProjectCommandResult(result);
return null;
},
});
}
// 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={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
>
Rückgängig
</button>
<button
type="button"
onClick={() => void handleRedo()}
disabled={!historyState || historyState.redoDepth === 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()}
{renderDistributionPowerSummary()}
{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">
{componentEditorIntent ? (
<DistributionBoardComponentModal
initialComponent={
componentEditorIntent.kind === "edit"
? componentEditorIntent.component
: undefined
}
initialEquipmentIdentifier={
componentEditorIntent.kind === "create"
? componentEditorIntent.initialEquipmentIdentifier
: undefined
}
isSaving={isSaving}
onClose={() => setComponentEditorIntent(null)}
onSave={handleSaveDistributionBoardComponent}
role={
componentEditorIntent.kind === "edit"
? (componentEditorIntent.component
.role as MutableDistributionBoardComponentRole)
: componentEditorIntent.role
}
/>
) : null}
{circuitGroupEditorIntent ? (
<CircuitGroupModal
initialSection={
circuitGroupEditorIntent.kind === "edit"
? circuitGroupEditorIntent.section
: undefined
}
isSaving={isSaving}
onClose={() => setCircuitGroupEditorIntent(null)}
onSave={handleSaveCircuitGroup}
/>
) : null}
<div className="editor-toolbar">
<button
type="button"
onClick={() => void handleUndo()}
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
title={
historyState?.undoDepth
? `${historyState.undoDepth} Änderung(en) können rückgängig gemacht werden.`
: undefined
}
>
Rückgängig
</button>
<button
type="button"
onClick={() => void handleRedo()}
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
title={
historyState?.redoDepth
? `${historyState.redoDepth} Änderung(en) können wiederholt werden.`
: undefined
}
>
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
aria-controls="project-device-drawer"
aria-expanded={isProjectDeviceDrawerOpen}
className="project-device-drawer-toggle"
type="button"
onClick={() =>
setIsProjectDeviceDrawerOpen((current) => !current)
}
>
{isProjectDeviceDrawerOpen
? "Projektgeräte schließen"
: `Projektgeräte öffnen (${projectDevices.length})`}
</button>
<button
type="button"
disabled={isSaving || historyBusy}
onClick={() =>
setComponentEditorIntent({
kind: "create",
role: "auxiliary",
})
}
>
Verteilergerät hinzufügen
</button>
<button
type="button"
disabled={isSaving || historyBusy}
onClick={() => setCircuitGroupEditorIntent({ kind: "create" })}
>
Stromkreisgruppe hinzufügen
</button>
<button
type="button"
onClick={clearSortAndFilters}
disabled={!isSortedView && !hasActiveFilters}
>
Sortierung/Filter zurücksetzen
</button>
{renderColumnSettingsMenu("col")}
</div>
{renderActiveViewSummary()}
{renderDistributionPowerSummary()}
{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">
{isProjectDeviceDrawerOpen ? (
<aside
className="project-device-sidebar project-device-drawer"
id="project-device-drawer"
>
<div className="project-device-drawer-header">
<div>
<h3>Projektgeräte</h3>
<span>{projectDevices.length} verfügbar</span>
</div>
<button
aria-label="Projektgeräte schließen"
disabled={draggingProjectDeviceId !== null}
onClick={() => setIsProjectDeviceDrawerOpen(false)}
type="button"
>
Schließen
</button>
</div>
<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: {formatValue(device.quantity, "quantity")}</span>
<span>
Leistung/Gerät: {formatValue(device.powerPerUnit, "powerPerUnit")} kW
</span>
<span>
Gleichzeitigkeit:{" "}
{formatValue(device.simultaneityFactor, "simultaneityFactor")}
</span>
<span>
Gesamtleistung: {formatValue(device.totalPower, "rowTotalPower")} kW
</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}>
{getCircuitSectionLabel(section)}
{valid ? "" : " (nicht zulässig)"}
</option>
);
})}
</select>
</label>
{selectedProjectDevice && suggestedSection ? (
<p className="notice muted">
Vorgeschlagener Bereich:{" "}
{getCircuitSectionLabel(suggestedSection)}
</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>
) : null}
<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={`${getFullColumnLabel(column)} sortieren`}
title={`${getFullColumnLabel(column)} 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`
: `${getFullColumnLabel(column)} 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>{getFullColumnLabel(column)} 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 ${getFullColumnLabel(column)} 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) =>
(column.key === "phaseType"
? formatPhaseTypeLabel(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>
{column.key === "phaseType"
? formatPhaseTypeLabel(value)
: value}
</span>
</button>
);
})}
{(distinctValuesByColumn[column.key] ?? []).filter(
(value) =>
(column.key === "phaseType"
? formatPhaseTypeLabel(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 === "headerComponent" ||
row.rowType === "groupComponent" ||
row.rowType === "footerComponent"
) {
const component = row.component!;
const isMutableComponent =
component.role === "group_upstream_protection" ||
component.role === "group_residual_current_protection" ||
component.role === "auxiliary";
const protection = component.protectionDevice;
const protectionSummary = protection
? [
protection.type,
`${formatValue(
protection.ratedCurrentA,
"protectionRatedCurrent"
)} A`,
protection.fuseUtilizationCategory,
protection.tripCharacteristic,
protection.rcdType
? `Typ ${protection.rcdType}`
: undefined,
protection.ratedResidualCurrentMa !== undefined
? `${formatValue(
protection.ratedResidualCurrentMa,
"protectionRatedCurrent"
)} mA`
: undefined,
]
.filter(Boolean)
.join(" · ")
: null;
return (
<tr
key={row.rowKey}
className={`structure-component-row ${row.rowType}`}
>
<td colSpan={visibleColumns.length + 1}>
<div className="structure-component-content">
<strong>{component.equipmentIdentifier}</strong>
<span>{component.name}</span>
{protectionSummary ? (
<span className="structure-component-protection">
{protectionSummary}
</span>
) : null}
<span className="structure-component-actions">
{isMutableComponent ? (
<>
<button
type="button"
disabled={isSaving}
onClick={() =>
setComponentEditorIntent({
kind: "edit",
component,
})
}
>
Bearbeiten
</button>
<button
type="button"
disabled={isSaving}
onClick={() =>
void handleDeleteDistributionBoardComponent(
component
)
}
>
Entfernen
</button>
</>
) : (
<span className="structure-component-fixed">
Feste Verteilerkomponente
</span>
)}
</span>
</div>
</td>
</tr>
);
}
if (row.rowType === "section") {
const section = data.sections.find((entry) => entry.id === row.sectionId)!;
const hasUpstreamProtection = section.components.some(
(component) =>
component.role === "group_upstream_protection"
);
const hasGroupRcd = section.components.some(
(component) =>
component.role === "group_residual_current_protection"
);
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">
<div className="section-title">
<strong>
{getCircuitSectionLabel(section)}
</strong>
<span>
Gesamtleistung Abschnitt:{" "}
{formatValue(
section.sectionTotalPower,
"rowTotalPower"
)}{" "}
kW
</span>
</div>
<div className="section-actions">
<button
type="button"
tabIndex={-1}
disabled={!section.category || !section.groupNumber}
onClick={() =>
setCircuitGroupEditorIntent({
kind: "edit",
section,
})
}
>
Gruppe bearbeiten
</button>
<button
type="button"
tabIndex={-1}
disabled={!canDeleteCircuitGroup(section)}
title={
canDeleteCircuitGroup(section)
? undefined
: "Befüllte Gruppen werden später über einen eigenen Warn- und Löschdialog entfernt."
}
onClick={() =>
void handleDeleteEmptyCircuitGroup(section)
}
>
Gruppe entfernen
</button>
<button
type="button"
tabIndex={-1}
disabled={
hasUpstreamProtection ||
!section.category ||
!section.groupNumber
}
onClick={() =>
setComponentEditorIntent({
kind: "create",
role: "group_upstream_protection",
sectionId: section.id,
initialEquipmentIdentifier:
getSuggestedGroupComponentIdentifier(
section,
"group_upstream_protection"
),
})
}
>
Vorsicherung hinzufügen
</button>
<button
type="button"
tabIndex={-1}
disabled={
hasGroupRcd ||
!section.category ||
!section.groupNumber
}
onClick={() =>
setComponentEditorIntent({
kind: "create",
role: "group_residual_current_protection",
sectionId: section.id,
initialEquipmentIdentifier:
getSuggestedGroupComponentIdentifier(
section,
"group_residual_current_protection"
),
})
}
>
Gruppen-FI hinzufügen
</button>
<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 (isGridEditorControlTarget(event.target)) {
return;
}
if (cell.editable) {
handleRowSelectionClick(row, column.key, {
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
});
}
}}
onDoubleClick={(event) => {
if (isGridEditorControlTarget(event.target)) {
return;
}
if (cell.editable) {
startEdit({ rowKey: row.rowKey, cellKey: column.key }, "selectExisting");
}
}}
>
{isEditing && column.key === "phaseType" ? (
<select
ref={(element) => {
inputRef.current = element;
}}
value={editingCell.draft}
aria-label="Phasenart auswählen"
onChange={(event) =>
void commitEdit("stay", event.target.value)
}
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();
}
});
}}
>
<option value="single_phase">1-phasig</option>
<option value="three_phase">3-phasig</option>
</select>
) : isEditing ? (
<input
ref={(element) => {
inputRef.current = element;
}}
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, column.key)
)}
</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>
);
}