diff --git a/src/app/globals.css b/src/app/globals.css index e6b1d72..0d68376 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -169,6 +169,67 @@ body { position: sticky; top: 0; z-index: 2; + vertical-align: top; +} + +.tree-grid .header-cell { + display: flex; + align-items: center; + gap: 0.3rem; +} + +.tree-grid .header-sort-btn, +.tree-grid .header-filter-btn { + border: 1px solid #c4cddc; + background: #fff; + border-radius: 3px; + font-size: 0.75rem; + padding: 0.15rem 0.35rem; +} + +.tree-grid .header-sort-btn { + text-align: left; +} + +.tree-grid .header-filter-btn.active { + border-color: #2563eb; + color: #2563eb; +} + +.tree-grid .header-filter-menu { + position: absolute; + z-index: 7; + margin-top: 0.2rem; + border: 1px solid #cfd7e5; + background: #fff; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); + width: 220px; + max-height: 260px; + overflow: auto; + padding: 0.35rem; +} + +.tree-grid .header-filter-clear { + border: 1px solid #c4cddc; + background: #fff; + border-radius: 3px; + font-size: 0.72rem; + padding: 0.12rem 0.3rem; + margin-bottom: 0.25rem; +} + +.tree-grid .header-filter-values { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.tree-grid .header-filter-item { + display: flex; + align-items: center; + gap: 0.25rem; + font-weight: 400; + font-size: 0.75rem; } .tree-grid .num { diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 485de4d..7b23e51 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -40,6 +40,7 @@ type SaveDirection = "stay" | "next" | "prev"; type StartEditMode = "selectExisting" | "replaceWithTypedChar"; type RowType = "section" | "circuitCompact" | "circuitSummary" | "deviceRow" | "reserveCircuit" | "placeholder"; type CellKind = "circuitField" | "deviceField" | "computed" | "readonly"; +type SortDirection = "asc" | "desc"; interface SelectedCell { rowKey: string; @@ -131,6 +132,9 @@ const columns: Array<{ key: CellKey; label: string; numeric?: boolean }> = [ { key: "remark", label: "Remark" }, ]; +const deviceOnlyColumns = new Set(["quantity", "powerPerUnit", "simultaneityFactor", "rowTotalPower", "roomSummary"]); +const circuitOnlyColumns = new Set(["equipmentIdentifier", "protectionSummary", "cableSummary", "circuitTotalPower"]); + const deviceFieldKeys = new Set([ "displayName", "roomSummary", @@ -243,6 +247,36 @@ function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): string | } } +function normalizeFilterValue(value: string | number | boolean | undefined) { + return formatValue(value); +} + +function getBlockSortValue(circuit: CircuitTreeCircuitDto, key: CellKey) { + const firstRow = circuit.deviceRows[0]; + if (circuitOnlyColumns.has(key)) { + return getCircuitValue(circuit, key); + } + if (deviceOnlyColumns.has(key)) { + return firstRow ? getDeviceValue(firstRow, key) : undefined; + } + if (key === "displayName" || key === "remark") { + if (circuit.displayName || circuit.remark) { + return getCircuitValue(circuit, key); + } + return firstRow ? getDeviceValue(firstRow, key) : undefined; + } + return getCircuitValue(circuit, key); +} + +function compareSortValues(a: string | number | boolean | undefined, b: string | number | boolean | undefined) { + const av = a === undefined || a === null ? "" : a; + const bv = b === undefined || b === null ? "" : b; + if (typeof av === "number" && typeof bv === "number") { + return av - bv; + } + return String(av).localeCompare(String(bv), undefined, { sensitivity: "base", numeric: true }); +} + function getCellKind(rowType: RowType, key: CellKey): CellKind { if (key === "rowTotalPower" || key === "circuitTotalPower") { return "computed"; @@ -313,6 +347,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const [undoStack, setUndoStack] = useState([]); const [redoStack, setRedoStack] = useState([]); const [historyBusy, setHistoryBusy] = useState(false); + const [sortState, setSortState] = useState<{ key: CellKey; direction: SortDirection } | null>(null); + const [columnFilters, setColumnFilters] = useState>>({}); + const [openFilterColumn, setOpenFilterColumn] = useState(null); const [pendingFocus, setPendingFocus] = useState(null); const pendingSelectionAfterReload = useRef(null); const containerRef = useRef(null); @@ -353,12 +390,104 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str void loadProjectDeviceList(); }, [projectId]); - const visibleRows = useMemo(() => { + const hasActiveFilters = useMemo( + () => Object.values(columnFilters).some((values) => (values?.length ?? 0) > 0), + [columnFilters] + ); + + const distinctValuesByColumn = useMemo(() => { + const result = {} as Record; + for (const column of columns) { + const set = new Set(); + for (const section of data?.sections ?? []) { + for (const circuit of section.circuits) { + set.add(normalizeFilterValue(getCircuitValue(circuit, column.key))); + for (const row of circuit.deviceRows) { + set.add(normalizeFilterValue(getDeviceValue(row, column.key))); + } + } + } + result[column.key] = [...set].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + } + return result; + }, [data]); + + const filteredSortedSections = useMemo(() => { if (!data) { + return [] as CircuitTreeResponseDto["sections"]; + } + const matchesColumnFilter = ( + circuit: CircuitTreeCircuitDto, + row: CircuitTreeDeviceRowDto | null, + key: CellKey, + selected: string[] + ) => { + if (!selected.length) { + return true; + } + const values = new Set(); + values.add(normalizeFilterValue(getCircuitValue(circuit, key))); + if (row) { + values.add(normalizeFilterValue(getDeviceValue(row, key))); + } else { + for (const device of circuit.deviceRows) { + values.add(normalizeFilterValue(getDeviceValue(device, key))); + } + } + return selected.some((entry) => values.has(entry)); + }; + + const sections = data.sections + .map((section) => { + let circuits = section.circuits + .map((circuit) => { + const selectedFilters = Object.entries(columnFilters).filter(([, values]) => (values?.length ?? 0) > 0); + const circuitMatchesAll = selectedFilters.every(([key, values]) => + matchesColumnFilter(circuit, null, key as CellKey, values ?? []) + ); + if (!circuitMatchesAll) { + return null; + } + + if (!hasActiveFilters || circuit.deviceRows.length <= 1) { + return circuit; + } + + const matchingRows = circuit.deviceRows.filter((row) => + selectedFilters.every(([key, values]) => + matchesColumnFilter(circuit, row, key as CellKey, values ?? []) + ) + ); + + if (matchingRows.length > 0) { + return { ...circuit, deviceRows: matchingRows }; + } + if (selectedFilters.some(([key]) => circuitOnlyColumns.has(key as CellKey))) { + return circuit; + } + return null; + }) + .filter(Boolean) as CircuitTreeCircuitDto[]; + + if (sortState) { + circuits = [...circuits].sort((a, b) => { + const cmp = compareSortValues(getBlockSortValue(a, sortState.key), getBlockSortValue(b, sortState.key)); + return sortState.direction === "asc" ? cmp : -cmp; + }); + } + return { ...section, circuits }; + }) + .filter((section) => section.circuits.length > 0); + + return sections; + }, [data, columnFilters, hasActiveFilters, sortState]); + + const visibleRows = useMemo(() => { + if (!filteredSortedSections.length) { return [] as VisibleGridRow[]; } const rows: VisibleGridRow[] = []; - for (const section of data.sections) { + for (const section of filteredSortedSections) { rows.push({ rowKey: `section:${section.id}`, rowType: "section", @@ -382,7 +511,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str rows.push(makeRow("placeholder", section.id)); } return rows; - }, [data]); + }, [filteredSortedSections]); const searchableProjectDevices = useMemo(() => { const term = projectDeviceSearch.trim().toLowerCase(); @@ -605,6 +734,55 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str await applyHistory(command, "redo"); } + 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; + } + + await runCommand({ + label: "Apply sorted order", + 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; + }, + }); + } + useEffect(() => { const intent = pendingSelectionAfterReload.current; if (!intent || !data) { @@ -1658,6 +1836,35 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str if (!data || data.sections.length === 0) { return
No sections/circuits available.
; } + if (filteredSortedSections.length === 0) { + return ( +
+
+ + + +
+
No matching circuits for active filters.
+
+ ); + } + + const isSortedView = Boolean(sortState); + const hasActiveSortOrFilter = isSortedView || hasActiveFilters; return (
@@ -1668,7 +1875,34 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str + {isSortedView ? ( + + ) : null} +
+ {hasActiveSortOrFilter ? ( +
Sorting/filtering is view-only. Renumber is disabled while sort/filter is active.
+ ) : null} + {isSortedView && hasActiveFilters ? ( +
Apply sorted order is disabled while filters are active.
+ ) : null} {isSaving ?
Saving...
: null}