Filter/Sort working with renumbering afterwards
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<CellKey>(["quantity", "powerPerUnit", "simultaneityFactor", "rowTotalPower", "roomSummary"]);
|
||||
const circuitOnlyColumns = new Set<CellKey>(["equipmentIdentifier", "protectionSummary", "cableSummary", "circuitTotalPower"]);
|
||||
|
||||
const deviceFieldKeys = new Set<CellKey>([
|
||||
"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<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 [pendingFocus, setPendingFocus] = useState<SelectedCell | null>(null);
|
||||
const pendingSelectionAfterReload = useRef<SelectionIntent | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(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<CellKey, string[]>;
|
||||
for (const column of columns) {
|
||||
const set = new Set<string>();
|
||||
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<string>();
|
||||
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 <div className="notice muted">No sections/circuits available.</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}>
|
||||
Undo
|
||||
</button>
|
||||
<button type="button" onClick={() => void handleRedo()} disabled={redoStack.length === 0 || historyBusy || isSaving}>
|
||||
Redo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSortState(null);
|
||||
setColumnFilters({});
|
||||
setOpenFilterColumn(null);
|
||||
}}
|
||||
disabled={!Boolean(sortState) && !hasActiveFilters}
|
||||
>
|
||||
Clear sort/filter
|
||||
</button>
|
||||
</div>
|
||||
<div className="notice muted">No matching circuits for active filters.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isSortedView = Boolean(sortState);
|
||||
const hasActiveSortOrFilter = isSortedView || hasActiveFilters;
|
||||
|
||||
return (
|
||||
<div className="tree-editor-shell">
|
||||
@@ -1668,7 +1875,34 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
<button type="button" onClick={() => void handleRedo()} disabled={redoStack.length === 0 || historyBusy || isSaving}>
|
||||
Redo
|
||||
</button>
|
||||
{isSortedView ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleApplySortedOrder()}
|
||||
disabled={hasActiveFilters || isSaving || historyBusy}
|
||||
title={hasActiveFilters ? "Clear filters first to apply sorted order." : undefined}
|
||||
>
|
||||
Apply sorted order
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSortState(null);
|
||||
setColumnFilters({});
|
||||
setOpenFilterColumn(null);
|
||||
}}
|
||||
disabled={!isSortedView && !hasActiveFilters}
|
||||
>
|
||||
Clear sort/filter
|
||||
</button>
|
||||
</div>
|
||||
{hasActiveSortOrFilter ? (
|
||||
<div className="notice muted">Sorting/filtering is view-only. Renumber is disabled while sort/filter is active.</div>
|
||||
) : null}
|
||||
{isSortedView && hasActiveFilters ? (
|
||||
<div className="notice muted">Apply sorted order is disabled while filters are active.</div>
|
||||
) : null}
|
||||
{isSaving ? <div className="notice info">Saving...</div> : null}
|
||||
<div className="tree-editor-layout">
|
||||
<aside className="project-device-sidebar">
|
||||
@@ -1763,7 +1997,73 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column.key} className={column.numeric ? "num" : ""}>
|
||||
<div className="header-cell">
|
||||
<button
|
||||
type="button"
|
||||
className="header-sort-btn"
|
||||
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;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{column.label}
|
||||
{sortState?.key === column.key ? (sortState.direction === "asc" ? " ↑" : " ↓") : ""}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`header-filter-btn ${(columnFilters[column.key]?.length ?? 0) > 0 ? "active" : ""}`}
|
||||
onClick={() => setOpenFilterColumn((current) => (current === column.key ? null : column.key))}
|
||||
>
|
||||
Filter
|
||||
</button>
|
||||
</div>
|
||||
{openFilterColumn === column.key ? (
|
||||
<div className="header-filter-menu">
|
||||
<button
|
||||
type="button"
|
||||
className="header-filter-clear"
|
||||
onClick={() =>
|
||||
setColumnFilters((current) => ({
|
||||
...current,
|
||||
[column.key]: [],
|
||||
}))
|
||||
}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<div className="header-filter-values">
|
||||
{distinctValuesByColumn[column.key].map((value) => {
|
||||
const selected = columnFilters[column.key] ?? [];
|
||||
const checked = selected.includes(value);
|
||||
return (
|
||||
<label key={`${column.key}-${value}`} className="header-filter-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => {
|
||||
setColumnFilters((current) => {
|
||||
const previous = current[column.key] ?? [];
|
||||
const next = event.target.checked
|
||||
? [...previous, value]
|
||||
: previous.filter((entry) => entry !== value);
|
||||
return { ...current, [column.key]: next };
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span>{value}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</th>
|
||||
))}
|
||||
<th>Actions</th>
|
||||
@@ -1826,7 +2126,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
<button type="button" tabIndex={-1} onClick={() => void handleAddReserveCircuit(section.id)}>
|
||||
Add circuit
|
||||
</button>
|
||||
<button type="button" tabIndex={-1} onClick={() => void handleRenumberSection(section.id)}>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
disabled={hasActiveSortOrFilter}
|
||||
onClick={() => void handleRenumberSection(section.id)}
|
||||
title={hasActiveSortOrFilter ? "Clear sort/filter first to renumber safely." : undefined}
|
||||
>
|
||||
Renumber section
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user