From 47dec0df396afd4bdbe8a93e1d05dcf3d20c3f67 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Tue, 5 May 2026 20:48:54 +0200 Subject: [PATCH] Added column sorting --- src/app/globals.css | 70 +++ .../components/circuit-tree-editor.tsx | 494 ++++++++++++++++-- 2 files changed, 522 insertions(+), 42 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 0d68376..6fb8f82 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -49,6 +49,7 @@ body { .editor-toolbar { display: flex; gap: 0.5rem; + position: relative; } .editor-toolbar button { @@ -63,6 +64,75 @@ body { opacity: 0.45; } +.column-settings-menu { + position: absolute; + z-index: 9; + margin-top: 2rem; + border: 1px solid #cfd7e5; + background: #fff; + border-radius: 4px; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); + padding: 0.45rem; + width: 300px; +} + +.column-settings-actions { + display: flex; + justify-content: flex-end; + margin-bottom: 0.35rem; +} + +.column-settings-list { + display: flex; + flex-direction: column; + gap: 0.25rem; + max-height: 340px; + overflow: auto; +} + +.column-settings-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.4rem; + border: 1px solid transparent; + border-radius: 4px; + padding: 0.2rem 0.25rem; +} + +.column-settings-item.dragging { + opacity: 0.55; +} + +.column-settings-item.drop-target { + border-color: #2b6cb0; + background: #ebf4ff; +} + +.column-settings-item.locked { + background: #f7fafc; +} + +.column-settings-item label { + display: flex; + gap: 0.3rem; + align-items: center; + font-size: 0.8rem; +} + +.column-settings-order { + display: flex; + gap: 0.2rem; +} + +.column-settings-order button { + border: 1px solid #c4cddc; + background: #fff; + border-radius: 3px; + font-size: 0.72rem; + padding: 0.08rem 0.28rem; +} + .tree-grid-wrap { overflow: auto; border: 1px solid #d9dee8; diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 7b23e51..24ac1ec 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -34,7 +34,26 @@ type CellKey = | "protectionSummary" | "cableSummary" | "roomSummary" - | "remark"; + | "remark" + | "technicalName" + | "connectionKind" + | "phaseType" + | "costGroup" + | "category" + | "level" + | "roomNumberSnapshot" + | "roomNameSnapshot" + | "cosPhi" + | "protectionType" + | "protectionRatedCurrent" + | "protectionCharacteristic" + | "cableType" + | "cableCrossSection" + | "cableLength" + | "rcdAssignment" + | "terminalDesignation" + | "status" + | "isReserve"; type SaveDirection = "stay" | "next" | "prev"; type StartEditMode = "selectExisting" | "replaceWithTypedChar"; @@ -118,37 +137,117 @@ type CircuitReorderDropIntent = | { kind: "after-circuit"; sectionId: string; targetCircuitId: string; valid: boolean } | { kind: "section-end"; sectionId: string; valid: boolean }; -const columns: Array<{ key: CellKey; label: string; numeric?: boolean }> = [ - { key: "equipmentIdentifier", label: "Equipment identifier" }, - { key: "displayName", label: "Display name" }, - { key: "quantity", label: "Quantity", numeric: true }, - { key: "powerPerUnit", label: "Power / unit", numeric: true }, - { key: "simultaneityFactor", label: "Simultaneity", numeric: true }, - { key: "rowTotalPower", label: "Row total", numeric: true }, - { key: "circuitTotalPower", label: "Circuit total", numeric: true }, - { key: "protectionSummary", label: "Protection summary" }, - { key: "cableSummary", label: "Cable summary" }, - { key: "roomSummary", label: "Room" }, - { key: "remark", label: "Remark" }, +const COLUMN_LAYOUT_STORAGE_KEY = "circuitTreeEditor.columnLayout.v1"; + +interface ColumnDef { + key: CellKey; + label: string; + numeric?: boolean; + defaultVisible?: boolean; + locked?: boolean; +} + +const allColumns: ColumnDef[] = [ + { key: "equipmentIdentifier", label: "Equipment identifier", defaultVisible: true, locked: true }, + { key: "displayName", label: "Display name", defaultVisible: true }, + { key: "quantity", label: "Quantity", numeric: true, defaultVisible: true }, + { key: "powerPerUnit", label: "Power / unit", numeric: true, defaultVisible: true }, + { key: "simultaneityFactor", label: "Simultaneity", numeric: true, defaultVisible: true }, + { key: "rowTotalPower", label: "Row total", numeric: true, defaultVisible: true }, + { key: "circuitTotalPower", label: "Circuit total", numeric: true, defaultVisible: true }, + { key: "protectionSummary", label: "Protection summary", defaultVisible: true }, + { key: "cableSummary", label: "Cable summary", defaultVisible: true }, + { key: "roomSummary", label: "Room", defaultVisible: true }, + { key: "remark", label: "Remark", defaultVisible: true }, + { key: "technicalName", label: "Technical name" }, + { key: "connectionKind", label: "Connection kind" }, + { key: "phaseType", label: "Phase type" }, + { key: "costGroup", label: "Cost group" }, + { key: "category", label: "Category" }, + { key: "level", label: "Level" }, + { key: "roomNumberSnapshot", label: "Room number" }, + { key: "roomNameSnapshot", label: "Room name" }, + { key: "cosPhi", label: "cosPhi", numeric: true }, + { key: "protectionType", label: "Protection type" }, + { key: "protectionRatedCurrent", label: "Protection rated current", numeric: true }, + { key: "protectionCharacteristic", label: "Protection characteristic" }, + { key: "cableType", label: "Cable type" }, + { key: "cableCrossSection", label: "Cable cross-section" }, + { key: "cableLength", label: "Cable length", numeric: true }, + { key: "rcdAssignment", label: "RCD assignment" }, + { key: "terminalDesignation", label: "Terminal designation" }, + { key: "status", label: "Status" }, + { key: "isReserve", label: "Reserve" }, ]; -const deviceOnlyColumns = new Set(["quantity", "powerPerUnit", "simultaneityFactor", "rowTotalPower", "roomSummary"]); -const circuitOnlyColumns = new Set(["equipmentIdentifier", "protectionSummary", "cableSummary", "circuitTotalPower"]); +const defaultVisibleColumnKeys = allColumns.filter((column) => column.defaultVisible).map((column) => column.key); -const deviceFieldKeys = new Set([ - "displayName", - "roomSummary", +const deviceOnlyColumns = new Set([ "quantity", "powerPerUnit", "simultaneityFactor", + "rowTotalPower", + "roomSummary", + "technicalName", + "connectionKind", + "phaseType", + "costGroup", + "category", + "level", + "roomNumberSnapshot", + "roomNameSnapshot", + "cosPhi", +]); +const circuitOnlyColumns = new Set([ + "equipmentIdentifier", + "protectionSummary", + "cableSummary", + "circuitTotalPower", + "protectionType", + "protectionRatedCurrent", + "protectionCharacteristic", + "cableType", + "cableCrossSection", + "cableLength", + "rcdAssignment", + "terminalDesignation", + "status", + "isReserve", +]); + +const deviceFieldKeys = new Set([ + "displayName", + "technicalName", + "connectionKind", + "phaseType", + "costGroup", + "category", + "level", + "roomSummary", + "roomNumberSnapshot", + "roomNameSnapshot", + "quantity", + "powerPerUnit", + "simultaneityFactor", + "cosPhi", "remark", ]); const circuitFieldKeys = new Set([ "equipmentIdentifier", "displayName", + "protectionType", + "protectionRatedCurrent", + "protectionCharacteristic", "protectionSummary", + "cableType", + "cableCrossSection", + "cableLength", "cableSummary", + "rcdAssignment", + "terminalDesignation", + "status", + "isReserve", "remark", ]); @@ -198,16 +297,34 @@ function parseNumeric(cellKey: CellKey, draft: string): number | undefined { function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): string | number | boolean | undefined { switch (key) { + case "technicalName": + return device.name; case "displayName": return device.displayName || device.name; + case "phaseType": + return device.phaseType; + case "connectionKind": + return device.connectionKind; + case "costGroup": + return device.costGroup; + case "category": + return device.category; + case "level": + return device.level; case "roomSummary": return [device.roomNumberSnapshot, device.roomNameSnapshot].filter(Boolean).join(" ").trim() || undefined; + case "roomNumberSnapshot": + return device.roomNumberSnapshot; + case "roomNameSnapshot": + return device.roomNameSnapshot; case "quantity": return device.quantity; case "powerPerUnit": return device.powerPerUnit; case "simultaneityFactor": return device.simultaneityFactor; + case "cosPhi": + return device.cosPhi; case "rowTotalPower": return device.rowTotalPower; case "remark": @@ -225,6 +342,12 @@ function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): string | return circuit.displayName; case "circuitTotalPower": return circuit.circuitTotalPower; + case "protectionType": + return circuit.protectionType; + case "protectionRatedCurrent": + return circuit.protectionRatedCurrent; + case "protectionCharacteristic": + return circuit.protectionCharacteristic; case "protectionSummary": { const current = circuit.protectionRatedCurrent !== undefined && circuit.protectionRatedCurrent !== null @@ -240,6 +363,20 @@ function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): string | circuit.cableLength !== undefined && circuit.cableLength !== null ? `${circuit.cableLength} m` : ""; return [circuit.cableType, circuit.cableCrossSection, length].filter(Boolean).join(", ").trim() || undefined; } + case "cableType": + return circuit.cableType; + case "cableCrossSection": + return circuit.cableCrossSection; + case "cableLength": + return circuit.cableLength; + case "rcdAssignment": + return circuit.rcdAssignment; + case "terminalDesignation": + return circuit.terminalDesignation; + case "status": + return circuit.status; + case "isReserve": + return circuit.isReserve; case "remark": return circuit.remark; default: @@ -350,12 +487,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const [sortState, setSortState] = useState<{ key: CellKey; direction: SortDirection } | null>(null); const [columnFilters, setColumnFilters] = useState>>({}); const [openFilterColumn, setOpenFilterColumn] = useState(null); + const [visibleColumnKeys, setVisibleColumnKeys] = useState(defaultVisibleColumnKeys); + const [columnOrder, setColumnOrder] = useState(allColumns.map((column) => column.key)); + const [isColumnMenuOpen, setIsColumnMenuOpen] = useState(false); + const [draggingColumnKey, setDraggingColumnKey] = useState(null); + const [columnDropTargetKey, setColumnDropTargetKey] = useState(null); const [pendingFocus, setPendingFocus] = useState(null); const pendingSelectionAfterReload = useRef(null); const containerRef = useRef(null); const inputRef = useRef(null); const focusTokenRef = useRef(1); + 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) { @@ -374,6 +525,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } } + 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")]; + } + useEffect(() => { void loadTree({ showLoading: true }); }, [projectId, circuitListId]); @@ -390,6 +548,54 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str void loadProjectDeviceList(); }, [projectId]); + useEffect(() => { + try { + const raw = localStorage.getItem(COLUMN_LAYOUT_STORAGE_KEY); + if (!raw) { + return; + } + const parsed = JSON.parse(raw) as { order?: string[]; visible?: string[] }; + const validKeys = new Set(allColumns.map((column) => column.key)); + const parsedOrder = (parsed.order ?? []).filter((key): key is CellKey => validKeys.has(key as CellKey)); + const parsedVisible = (parsed.visible ?? []).filter((key): key is CellKey => validKeys.has(key as CellKey)); + if (!parsedOrder.length || !parsedVisible.length || !parsedVisible.includes("equipmentIdentifier")) { + return; + } + setColumnOrder(normalizeColumnOrder(parsedOrder)); + setVisibleColumnKeys(parsedVisible); + } catch { + // ignore invalid local storage and use defaults + } + }, []); + + useEffect(() => { + const payload = { + order: columnOrder, + visible: visibleColumnKeys, + }; + localStorage.setItem(COLUMN_LAYOUT_STORAGE_KEY, JSON.stringify(payload)); + }, [columnOrder, visibleColumnKeys]); + + useEffect(() => { + const visible = new Set(visibleColumnKeys); + setSortState((current) => { + if (!current) { + return current; + } + return visible.has(current.key) ? current : null; + }); + setColumnFilters((current) => { + const next: Partial> = {}; + for (const [key, values] of Object.entries(current)) { + if (visible.has(key as CellKey) && values && values.length > 0) { + next[key as CellKey] = values; + } + } + return next; + }); + setOpenFilterColumn((current) => (current && visible.has(current) ? current : null)); + }, [visibleColumnKeys]); + const hasActiveFilters = useMemo( () => Object.values(columnFilters).some((values) => (values?.length ?? 0) > 0), [columnFilters] @@ -397,7 +603,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const distinctValuesByColumn = useMemo(() => { const result = {} as Record; - for (const column of columns) { + for (const column of visibleColumns) { const set = new Set(); for (const section of data?.sections ?? []) { for (const circuit of section.circuits) { @@ -410,7 +616,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str result[column.key] = [...set].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); } return result; - }, [data]); + }, [data, visibleColumns]); const filteredSortedSections = useMemo(() => { if (!data) { @@ -492,7 +698,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str rowKey: `section:${section.id}`, rowType: "section", sectionId: section.id, - cells: columns.map((col) => ({ cellKey: col.key, editable: false, kind: "readonly", value: undefined })), + cells: allColumns.map((col) => ({ cellKey: col.key, editable: false, kind: "readonly", value: undefined })), }); for (const circuit of section.circuits) { if (circuit.deviceRows.length === 0) { @@ -549,7 +755,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str : rowType === "deviceRow" && device ? `device:${device.id}` : `${rowType}:${circuit?.id ?? sectionId}`; - const cells = columns.map((col) => { + const cells = allColumns.map((col) => { const kind = getCellKind(rowType, col.key); const editable = kind === "circuitField" || kind === "deviceField"; let value: string | number | boolean | undefined; @@ -580,15 +786,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str ); const rowCellMap = useMemo(() => { + const visibleKeySet = new Set(visibleColumnKeys); const map = new Map(); for (const row of visibleRows) { - const keys = row.cells.filter((cell) => cell.editable).map((cell) => cell.cellKey as CellKey); + 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]); + }, [visibleRows, visibleColumnKeys]); const editableRowOrder = useMemo( () => visibleRows.filter((row) => rowCellMap.has(row.rowKey)).map((row) => row.rowKey), @@ -783,6 +992,103 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }); } + 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); + } + + function moveColumnByDrag(dragKey: CellKey, targetKey: CellKey) { + if (dragKey === "equipmentIdentifier" || targetKey === "equipmentIdentifier") { + return; + } + setColumnOrder((current) => { + const from = current.indexOf(dragKey); + const to = current.indexOf(targetKey); + if (from < 1 || to < 1 || from === to) { + return current; + } + const clone = [...current]; + const [item] = clone.splice(from, 1); + const targetIndex = clone.indexOf(targetKey); + if (targetIndex < 1) { + return current; + } + clone.splice(targetIndex, 0, item); + return normalizeColumnOrder(clone); + }); + } + + function handleColumnDragStart(event: DragEvent, key: CellKey) { + if (key === "equipmentIdentifier") { + event.preventDefault(); + return; + } + setDraggingColumnKey(key); + setColumnDropTargetKey(null); + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("application/x-column-key", key); + } + + function handleColumnDragOver(event: DragEvent, targetKey: CellKey) { + if (!draggingColumnKey || targetKey === "equipmentIdentifier" || draggingColumnKey === targetKey) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + setColumnDropTargetKey(targetKey); + } + + function handleColumnDrop(event: DragEvent, targetKey: CellKey) { + event.preventDefault(); + const dragKey = draggingColumnKey; + if (!dragKey || dragKey === targetKey) { + setColumnDropTargetKey(null); + return; + } + moveColumnByDrag(dragKey, targetKey); + setDraggingColumnKey(null); + setColumnDropTargetKey(null); + } + + function handleColumnDragEnd() { + setDraggingColumnKey(null); + setColumnDropTargetKey(null); + } + useEffect(() => { const intent = pendingSelectionAfterReload.current; if (!intent || !data) { @@ -805,6 +1111,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str setPendingFocus(null); }, [pendingFocus]); + useEffect(() => { + if (!isColumnMenuOpen) { + setDraggingColumnKey(null); + setColumnDropTargetKey(null); + } + }, [isColumnMenuOpen]); + useEffect(() => { if (!editingCell) { return; @@ -877,11 +1190,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str if (!targetCells.length) { return; } - const preferred = columns.findIndex((col) => col.key === selectedCell.cellKey); + 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 = columns.findIndex((col) => col.key === key); + const idx = visibleColumns.findIndex((col) => col.key === key); const dist = Math.abs(idx - preferred); if (dist < bestDistance) { bestDistance = dist; @@ -895,6 +1208,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const payload: Record = {}; if (key === "protectionSummary" || key === "cableSummary") { payload.remark = draft.trim() === "" ? undefined : draft; + } else if (["protectionRatedCurrent", "cableLength"].includes(key)) { + payload[key] = parseNumeric(key, draft); + } else if (key === "isReserve") { + const normalized = draft.trim().toLowerCase(); + payload.isReserve = ["1", "true", "yes", "ja"].includes(normalized); } else { payload[key] = draft.trim() === "" ? undefined : draft; } @@ -903,7 +1221,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str async function patchDeviceRow(rowId: string, key: CellKey, draft: string) { const payload: Record = {}; - if (["quantity", "powerPerUnit", "simultaneityFactor"].includes(key)) { + if (["quantity", "powerPerUnit", "simultaneityFactor", "cosPhi"].includes(key)) { payload[key] = parseNumeric(key, draft); } else if (key === "roomSummary") { const trimmed = draft.trim(); @@ -1846,18 +2164,64 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str - - + + + {isColumnMenuOpen ? ( +
+
+ +
+
+ {orderedColumns.map((column) => { + const visible = visibleColumnKeys.includes(column.key); + return ( +
handleColumnDragStart(event, column.key)} + onDragOver={(event) => handleColumnDragOver(event, column.key)} + onDrop={(event) => handleColumnDrop(event, column.key)} + onDragEnd={() => handleColumnDragEnd()} + > + +
+ + +
+
+ ); + })} +
+
+ ) : null} +
No matching circuits for active filters.
); @@ -1885,6 +2249,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str Apply sorted order ) : null} + + {isColumnMenuOpen ? ( +
+
+ +
+
+ {orderedColumns.map((column) => { + const visible = visibleColumnKeys.includes(column.key); + return ( +
handleColumnDragStart(event, column.key)} + onDragOver={(event) => handleColumnDragOver(event, column.key)} + onDrop={(event) => handleColumnDrop(event, column.key)} + onDragEnd={() => handleColumnDragEnd()} + > + +
+ + +
+
+ ); + })} +
+
+ ) : null} {hasActiveSortOrFilter ? (
Sorting/filtering is view-only. Renumber is disabled while sort/filter is active.
@@ -1995,7 +2405,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str - {columns.map((column) => ( + {visibleColumns.map((column) => (
+
{section.displayName}
@@ -2364,7 +2774,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } }} > - {columns.map((column) => { + {visibleColumns.map((column) => { const cell = row.cells.find((entry) => entry.cellKey === column.key)!; const isSelected = selectedCell?.rowKey === row.rowKey && selectedCell.cellKey === column.key;