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