Filter/Sort working with renumbering afterwards

This commit is contained in:
2026-05-05 18:50:59 +02:00
parent 9b9e67bf0c
commit 1aace105c7
2 changed files with 372 additions and 5 deletions
+61
View File
@@ -169,6 +169,67 @@ body {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 2; 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 { .tree-grid .num {
+310 -4
View File
@@ -40,6 +40,7 @@ type SaveDirection = "stay" | "next" | "prev";
type StartEditMode = "selectExisting" | "replaceWithTypedChar"; type StartEditMode = "selectExisting" | "replaceWithTypedChar";
type RowType = "section" | "circuitCompact" | "circuitSummary" | "deviceRow" | "reserveCircuit" | "placeholder"; type RowType = "section" | "circuitCompact" | "circuitSummary" | "deviceRow" | "reserveCircuit" | "placeholder";
type CellKind = "circuitField" | "deviceField" | "computed" | "readonly"; type CellKind = "circuitField" | "deviceField" | "computed" | "readonly";
type SortDirection = "asc" | "desc";
interface SelectedCell { interface SelectedCell {
rowKey: string; rowKey: string;
@@ -131,6 +132,9 @@ const columns: Array<{ key: CellKey; label: string; numeric?: boolean }> = [
{ key: "remark", label: "Remark" }, { 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>([ const deviceFieldKeys = new Set<CellKey>([
"displayName", "displayName",
"roomSummary", "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 { function getCellKind(rowType: RowType, key: CellKey): CellKind {
if (key === "rowTotalPower" || key === "circuitTotalPower") { if (key === "rowTotalPower" || key === "circuitTotalPower") {
return "computed"; return "computed";
@@ -313,6 +347,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [undoStack, setUndoStack] = useState<HistoryCommand[]>([]); const [undoStack, setUndoStack] = useState<HistoryCommand[]>([]);
const [redoStack, setRedoStack] = useState<HistoryCommand[]>([]); const [redoStack, setRedoStack] = useState<HistoryCommand[]>([]);
const [historyBusy, setHistoryBusy] = useState(false); 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 [pendingFocus, setPendingFocus] = useState<SelectedCell | null>(null);
const pendingSelectionAfterReload = useRef<SelectionIntent | null>(null); const pendingSelectionAfterReload = useRef<SelectionIntent | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
@@ -353,12 +390,104 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
void loadProjectDeviceList(); void loadProjectDeviceList();
}, [projectId]); }, [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) { 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[]; return [] as VisibleGridRow[];
} }
const rows: VisibleGridRow[] = []; const rows: VisibleGridRow[] = [];
for (const section of data.sections) { for (const section of filteredSortedSections) {
rows.push({ rows.push({
rowKey: `section:${section.id}`, rowKey: `section:${section.id}`,
rowType: "section", rowType: "section",
@@ -382,7 +511,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
rows.push(makeRow("placeholder", section.id)); rows.push(makeRow("placeholder", section.id));
} }
return rows; return rows;
}, [data]); }, [filteredSortedSections]);
const searchableProjectDevices = useMemo(() => { const searchableProjectDevices = useMemo(() => {
const term = projectDeviceSearch.trim().toLowerCase(); const term = projectDeviceSearch.trim().toLowerCase();
@@ -605,6 +734,55 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await applyHistory(command, "redo"); 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(() => { useEffect(() => {
const intent = pendingSelectionAfterReload.current; const intent = pendingSelectionAfterReload.current;
if (!intent || !data) { if (!intent || !data) {
@@ -1658,6 +1836,35 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (!data || data.sections.length === 0) { if (!data || data.sections.length === 0) {
return <div className="notice muted">No sections/circuits available.</div>; 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 ( return (
<div className="tree-editor-shell"> <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}> <button type="button" onClick={() => void handleRedo()} disabled={redoStack.length === 0 || historyBusy || isSaving}>
Redo Redo
</button> </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> </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} {isSaving ? <div className="notice info">Saving...</div> : null}
<div className="tree-editor-layout"> <div className="tree-editor-layout">
<aside className="project-device-sidebar"> <aside className="project-device-sidebar">
@@ -1763,7 +1997,73 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
<tr> <tr>
{columns.map((column) => ( {columns.map((column) => (
<th key={column.key} className={column.numeric ? "num" : ""}> <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} {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>
))} ))}
<th>Actions</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)}> <button type="button" tabIndex={-1} onClick={() => void handleAddReserveCircuit(section.id)}>
Add circuit Add circuit
</button> </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 Renumber section
</button> </button>
</div> </div>