@ifc-lite/viewer 1.25.2 → 1.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +40 -30
- package/CHANGELOG.md +110 -0
- package/dist/assets/{basketViewActivator-CTgyKI3U.js → basketViewActivator-B3CdrLsb.js} +7 -7
- package/dist/assets/{bcf-7jQby1qi.js → bcf-QeHK_Aud.js} +5 -5
- package/dist/assets/{browser-DXS29_v9.js → browser-BIoDDfBW.js} +1 -1
- package/dist/assets/{cesium-BoVuJvTC.js → cesium-CzZn5yVA.js} +319 -319
- package/dist/assets/{deflate-Cfp9t1Df.js → deflate-B-d0SYQM.js} +1 -1
- package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
- package/dist/assets/{exporters-DfSvJPi4.js → exporters-B4LbZFeT.js} +1434 -1179
- package/dist/assets/geometry.worker-BdH-E6NB.js +1 -0
- package/dist/assets/{geotiff-xZoE8BkO.js → geotiff-CrVtDRFq.js} +10 -10
- package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
- package/dist/assets/{ids-Cu73hD0Y.js → ids-DjsGFN10.js} +21 -21
- package/dist/assets/ifc-lite_bg-DsYUIHm3.wasm +0 -0
- package/dist/assets/{index-WSbA5iy6.js → index-COYokSKc.js} +44122 -38782
- package/dist/assets/index-ajK6D32J.css +1 -0
- package/dist/assets/index.es-CY202jA3.js +6866 -0
- package/dist/assets/{jpeg-DhwFEbqb.js → jpeg-D4wOkf5h.js} +1 -1
- package/dist/assets/jspdf.es.min-DIGb9BHN.js +19571 -0
- package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
- package/dist/assets/{lerc-Dz6BXOVb.js → lerc-DmW0_tgf.js} +1 -1
- package/dist/assets/{lzw-C9z0fG2o.js → lzw-oWetY-d6.js} +1 -1
- package/dist/assets/{maplibre-gl-Do6O5tDc.js → maplibre-gl-BF3Z0idw.js} +1 -1
- package/dist/assets/{native-bridge-RvDmzO-2.js → native-bridge-BX8_tHXE.js} +1 -1
- package/dist/assets/{packbits-jfwifz7C.js → packbits-F8Nkp4NY.js} +1 -1
- package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
- package/dist/assets/{parser.worker-C594dWxH.js → parser.worker-D591Zu_-.js} +3 -3
- package/dist/assets/pdf-Dsh3HPZB.js +135 -0
- package/dist/assets/raw-D9iw0tmc.js +1 -0
- package/dist/assets/{sandbox-DDSZ7rek.js → sandbox-BAC3a-eN.js} +4235 -2716
- package/dist/assets/server-client-Cjwnm7il.js +706 -0
- package/dist/assets/{webimage-XFHVyVtC.js → webimage-BLV1dgmd.js} +1 -1
- package/dist/assets/xlsx-Bc2HTrjC.js +142 -0
- package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
- package/dist/assets/{zstd-3q5qcl5V.js → zstd-C_1HxVrA.js} +1 -1
- package/dist/index.html +8 -8
- package/package.json +13 -9
- package/src/components/extensions/FlavorDialog.tsx +18 -2
- package/src/components/extensions/FlavorListView.tsx +12 -3
- package/src/components/mcp/PlaygroundChat.tsx +1 -0
- package/src/components/mcp/data.ts +6 -0
- package/src/components/mcp/playground-dispatcher.ts +277 -0
- package/src/components/mcp/types.ts +2 -1
- package/src/components/ui/combo-input.tsx +163 -0
- package/src/components/ui/tabs.tsx +1 -1
- package/src/components/viewer/ClashBcfExportDialog.tsx +271 -0
- package/src/components/viewer/ClashPanel.tsx +370 -0
- package/src/components/viewer/ClashSettingsDialog.tsx +407 -0
- package/src/components/viewer/CommandPalette.tsx +14 -15
- package/src/components/viewer/MainToolbar.tsx +155 -175
- package/src/components/viewer/PropertiesPanel.tsx +13 -6
- package/src/components/viewer/SearchInline.tsx +62 -2
- package/src/components/viewer/SearchModal.filter.builder.tsx +24 -393
- package/src/components/viewer/SearchModal.filter.editors.tsx +503 -0
- package/src/components/viewer/SearchModal.filter.tsx +64 -1
- package/src/components/viewer/SearchModal.tsx +19 -6
- package/src/components/viewer/ViewerLayout.tsx +5 -0
- package/src/components/viewer/Viewport.tsx +64 -9
- package/src/components/viewer/ViewportContainer.tsx +45 -3
- package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
- package/src/components/viewer/lists/ColumnHeaderMenu.tsx +84 -0
- package/src/components/viewer/lists/ListBuilder.tsx +789 -280
- package/src/components/viewer/lists/ListGroupingBar.tsx +72 -0
- package/src/components/viewer/lists/ListPanel.tsx +49 -5
- package/src/components/viewer/lists/ListResultsTable.tsx +270 -176
- package/src/components/viewer/lists/list-table-utils.ts +123 -0
- package/src/components/viewer/useGeometryStreaming.ts +21 -1
- package/src/generated/mcp-catalog.json +4 -0
- package/src/hooks/ingest/streamCleanup.test.ts +41 -0
- package/src/hooks/ingest/streamCleanup.ts +45 -0
- package/src/hooks/ingest/viewerModelIngest.ts +64 -42
- package/src/hooks/ingest/watchedGeometryStream.test.ts +78 -0
- package/src/hooks/ingest/watchedGeometryStream.ts +76 -0
- package/src/hooks/source-key.ts +35 -0
- package/src/hooks/useAlignmentLines3D.ts +139 -0
- package/src/hooks/useClash.ts +420 -0
- package/src/hooks/useGridLines3D.ts +140 -0
- package/src/hooks/useIfcFederation.ts +16 -2
- package/src/hooks/useIfcLoader.ts +5 -7
- package/src/lib/clash/persistence.ts +308 -0
- package/src/lib/geo/effective-georef.test.ts +66 -0
- package/src/lib/length-unit-scale.ts +41 -0
- package/src/lib/lists/adapter.ts +136 -11
- package/src/lib/lists/export/csv.ts +47 -0
- package/src/lib/lists/export/index.ts +49 -0
- package/src/lib/lists/export/model.ts +111 -0
- package/src/lib/lists/export/pdf.ts +67 -0
- package/src/lib/lists/export/xlsx.ts +83 -0
- package/src/lib/lists/index.ts +2 -0
- package/src/lib/search/filter-evaluate.test.ts +81 -0
- package/src/lib/search/filter-evaluate.ts +59 -87
- package/src/lib/search/filter-match.ts +167 -0
- package/src/lib/search/filter-rules.test.ts +25 -0
- package/src/lib/search/filter-rules.ts +75 -2
- package/src/lib/search/filter-schema.ts +0 -0
- package/src/lib/slab-edit.test.ts +72 -0
- package/src/lib/slab-edit.ts +159 -19
- package/src/sdk/adapters/export-adapter.ts +3 -3
- package/src/sdk/adapters/query-adapter.ts +3 -3
- package/src/services/extensions/host.ts +13 -0
- package/src/store/constants.ts +33 -25
- package/src/store/index.ts +29 -8
- package/src/store/slices/clashSlice.ts +251 -0
- package/src/store/slices/listSlice.ts +6 -0
- package/src/store/slices/mutationSlice.ts +14 -6
- package/src/store/slices/searchSlice.ts +29 -3
- package/src/store/slices/visibilitySlice.test.ts +23 -5
- package/src/store/slices/visibilitySlice.ts +18 -8
- package/src/utils/nativeSpatialDataStore.ts +6 -0
- package/src/utils/serverDataModel.test.ts +6 -0
- package/src/utils/serverDataModel.ts +7 -0
- package/dist/assets/geometry.worker-Cyn5BybV.js +0 -1
- package/dist/assets/ifc-lite_bg-ksLBP5cA.wasm +0 -0
- package/dist/assets/index-Bws3UAkj.css +0 -1
- package/dist/assets/raw-R2QfzPAR.js +0 -1
- package/dist/assets/server-client-Ctk8_Bof.js +0 -626
|
@@ -3,33 +3,79 @@
|
|
|
3
3
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* ListBuilder
|
|
6
|
+
* ListBuilder — configure a list: scope (entity types + filters), the
|
|
7
|
+
* columns to show, and optional grouping / totals.
|
|
8
|
+
*
|
|
9
|
+
* UI is organised as labelled sections with a consistent header treatment.
|
|
10
|
+
* The most-used columns (attributes + Material / Classification / Storey)
|
|
11
|
+
* are surfaced as a flat chip grid; property/quantity sets — which can be
|
|
12
|
+
* numerous — stay in collapsible groups below.
|
|
7
13
|
*/
|
|
8
14
|
|
|
9
15
|
import React, { useCallback, useMemo, useState } from 'react';
|
|
10
|
-
import {
|
|
11
|
-
Play,
|
|
12
|
-
Plus,
|
|
13
|
-
Trash2,
|
|
14
|
-
ChevronDown,
|
|
15
|
-
ChevronRight,
|
|
16
|
-
ChevronUp,
|
|
17
|
-
Save,
|
|
18
|
-
} from 'lucide-react';
|
|
16
|
+
import { Play, Plus, Trash2, ChevronDown, ChevronRight, ChevronUp, Save, Check, GripVertical } from 'lucide-react';
|
|
19
17
|
import { Button } from '@/components/ui/button';
|
|
20
18
|
import { Input } from '@/components/ui/input';
|
|
19
|
+
import { ComboInput } from '@/components/ui/combo-input';
|
|
21
20
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
22
|
-
import { Separator } from '@/components/ui/separator';
|
|
23
21
|
import { Badge } from '@/components/ui/badge';
|
|
22
|
+
import { cn } from '@/lib/utils';
|
|
24
23
|
import { IfcTypeEnum } from '@ifc-lite/data';
|
|
24
|
+
import type { IfcDataStore } from '@ifc-lite/parser';
|
|
25
|
+
import {
|
|
26
|
+
discoverFilterValues,
|
|
27
|
+
discoverFilterSchema,
|
|
28
|
+
propValueKey,
|
|
29
|
+
} from '@/lib/search/filter-schema';
|
|
25
30
|
import type {
|
|
26
31
|
ListDataProvider,
|
|
27
32
|
ListDefinition,
|
|
28
33
|
ColumnDefinition,
|
|
29
34
|
DiscoveredColumns,
|
|
30
35
|
PropertyCondition,
|
|
36
|
+
ConditionOperator,
|
|
31
37
|
} from '@ifc-lite/lists';
|
|
32
|
-
import { discoverColumns } from '@ifc-lite/lists';
|
|
38
|
+
import { discoverColumns, ENTITY_ATTRIBUTES } from '@ifc-lite/lists';
|
|
39
|
+
|
|
40
|
+
const NO_OPTIONS: readonly string[] = [];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Distinct model values used to suggest condition values in the chip editors.
|
|
44
|
+
* Storeys are intentionally NOT here — they come cheaply from the spatial
|
|
45
|
+
* index, whereas these require sampling element property/material data.
|
|
46
|
+
*/
|
|
47
|
+
interface ListConditionValues {
|
|
48
|
+
materials: string[];
|
|
49
|
+
classifications: string[];
|
|
50
|
+
/** propValueKey(pset, prop) → distinct values. */
|
|
51
|
+
propertyValues: Map<string, string[]>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Merge per-store value discovery into one suggestion set. This is the
|
|
56
|
+
* EXPENSIVE pass (samples element property/material/classification data), so
|
|
57
|
+
* it's only run when a property/material/classification condition exists —
|
|
58
|
+
* never for storey-only filters (storeys come from `discoverFilterSchema`).
|
|
59
|
+
*/
|
|
60
|
+
function discoverConditionValues(stores: IfcDataStore[]): ListConditionValues {
|
|
61
|
+
const materials = new Set<string>();
|
|
62
|
+
const classifications = new Set<string>();
|
|
63
|
+
const propertyValues = new Map<string, Set<string>>();
|
|
64
|
+
for (const store of stores) {
|
|
65
|
+
const v = discoverFilterValues(store);
|
|
66
|
+
v.materials.forEach((m) => materials.add(m));
|
|
67
|
+
v.classifications.forEach((c) => classifications.add(c));
|
|
68
|
+
for (const [k, arr] of v.propertyValues) {
|
|
69
|
+
let bucket = propertyValues.get(k);
|
|
70
|
+
if (!bucket) { bucket = new Set(); propertyValues.set(k, bucket); }
|
|
71
|
+
for (const val of arr) bucket.add(val);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const sort = (s: Set<string>) => Array.from(s).sort();
|
|
75
|
+
const pv = new Map<string, string[]>();
|
|
76
|
+
for (const [k, s] of propertyValues) pv.set(k, sort(s));
|
|
77
|
+
return { materials: sort(materials), classifications: sort(classifications), propertyValues: pv };
|
|
78
|
+
}
|
|
33
79
|
|
|
34
80
|
// Building element types available for selection
|
|
35
81
|
const SELECTABLE_TYPES: { type: IfcTypeEnum; label: string }[] = [
|
|
@@ -54,15 +100,57 @@ const SELECTABLE_TYPES: { type: IfcTypeEnum; label: string }[] = [
|
|
|
54
100
|
{ type: IfcTypeEnum.IfcFlowFitting, label: 'MEP Fittings' },
|
|
55
101
|
];
|
|
56
102
|
|
|
103
|
+
/** Column descriptor shared by the quick-add grid. */
|
|
104
|
+
interface CommonColumn { id: string; source: ColumnDefinition['source']; propertyName: string; label: string }
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The first-class columns: built-in attributes plus the spatial / semantic
|
|
108
|
+
* columns. Surfaced as a flat grid so Material / Classification / Storey
|
|
109
|
+
* are as reachable as Name / Class — not buried in a collapsed group.
|
|
110
|
+
*/
|
|
111
|
+
const COMMON_COLUMNS: CommonColumn[] = [
|
|
112
|
+
...ENTITY_ATTRIBUTES.map((a): CommonColumn => ({
|
|
113
|
+
id: `attr-${a.toLowerCase()}`,
|
|
114
|
+
source: 'attribute',
|
|
115
|
+
propertyName: a,
|
|
116
|
+
label: a,
|
|
117
|
+
})),
|
|
118
|
+
{ id: 'col-material', source: 'material', propertyName: 'Material', label: 'Material' },
|
|
119
|
+
{ id: 'col-classification', source: 'classification', propertyName: 'Classification', label: 'Classification' },
|
|
120
|
+
{ id: 'col-storey', source: 'spatial', propertyName: 'Storey', label: 'Storey' },
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
/** Union the per-provider complete-discovery results into one column set. */
|
|
124
|
+
function mergeDiscovered(parts: DiscoveredColumns[]): DiscoveredColumns {
|
|
125
|
+
const properties = new Map<string, Set<string>>();
|
|
126
|
+
const quantities = new Map<string, Set<string>>();
|
|
127
|
+
const merge = (target: Map<string, Set<string>>, src: Map<string, string[]>) => {
|
|
128
|
+
for (const [k, arr] of src) {
|
|
129
|
+
let b = target.get(k);
|
|
130
|
+
if (!b) { b = new Set(); target.set(k, b); }
|
|
131
|
+
for (const v of arr) b.add(v);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
for (const d of parts) { merge(properties, d.properties); merge(quantities, d.quantities); }
|
|
135
|
+
const toSorted = (m: Map<string, Set<string>>) => {
|
|
136
|
+
const out = new Map<string, string[]>();
|
|
137
|
+
for (const [k, s] of m) out.set(k, Array.from(s).sort());
|
|
138
|
+
return out;
|
|
139
|
+
};
|
|
140
|
+
return { attributes: [...ENTITY_ATTRIBUTES], properties: toSorted(properties), quantities: toSorted(quantities) };
|
|
141
|
+
}
|
|
142
|
+
|
|
57
143
|
interface ListBuilderProps {
|
|
58
144
|
providers: ListDataProvider[];
|
|
145
|
+
/** Backing stores for value discovery (condition value suggestions). */
|
|
146
|
+
stores: IfcDataStore[];
|
|
59
147
|
initial: ListDefinition | null;
|
|
60
148
|
onSave: (definition: ListDefinition) => void;
|
|
61
149
|
onCancel: () => void;
|
|
62
150
|
onExecute: (definition: ListDefinition) => void;
|
|
63
151
|
}
|
|
64
152
|
|
|
65
|
-
export function ListBuilder({ providers, initial, onSave, onCancel, onExecute }: ListBuilderProps) {
|
|
153
|
+
export function ListBuilder({ providers, stores, initial, onSave, onCancel, onExecute }: ListBuilderProps) {
|
|
66
154
|
const [name, setName] = useState(initial?.name ?? '');
|
|
67
155
|
const [description, setDescription] = useState(initial?.description ?? '');
|
|
68
156
|
const [selectedTypes, setSelectedTypes] = useState<Set<IfcTypeEnum>>(
|
|
@@ -70,7 +158,33 @@ export function ListBuilder({ providers, initial, onSave, onCancel, onExecute }:
|
|
|
70
158
|
);
|
|
71
159
|
const [columns, setColumns] = useState<ColumnDefinition[]>(initial?.columns ?? []);
|
|
72
160
|
const [conditions, setConditions] = useState<PropertyCondition[]>(initial?.conditions ?? []);
|
|
73
|
-
|
|
161
|
+
// Lazily-discovered distinct values for condition suggestions. This is the
|
|
162
|
+
// EXPENSIVE sampling pass, so only run it when a property / material /
|
|
163
|
+
// classification condition exists — storey-only filters never trigger it.
|
|
164
|
+
const [conditionValues, setConditionValues] = useState<ListConditionValues | null>(null);
|
|
165
|
+
React.useEffect(() => {
|
|
166
|
+
if (conditionValues || stores.length === 0) return;
|
|
167
|
+
const needs = conditions.some(
|
|
168
|
+
(c) => c.source === 'property' || c.source === 'material' || c.source === 'classification',
|
|
169
|
+
);
|
|
170
|
+
if (!needs) return;
|
|
171
|
+
setConditionValues(discoverConditionValues(stores));
|
|
172
|
+
}, [conditions, stores, conditionValues]);
|
|
173
|
+
|
|
174
|
+
// Storey names come cheaply from the spatial index (no element sampling),
|
|
175
|
+
// so they're always available without the expensive value pass above.
|
|
176
|
+
const storeyNames = useMemo<string[]>(() => {
|
|
177
|
+
if (stores.length === 0) return [];
|
|
178
|
+
const set = new Set<string>();
|
|
179
|
+
for (const store of stores) {
|
|
180
|
+
for (const [name] of discoverFilterSchema(store).storeys) set.add(name);
|
|
181
|
+
}
|
|
182
|
+
return Array.from(set).sort();
|
|
183
|
+
}, [stores]);
|
|
184
|
+
const [groupByColumnId, setGroupByColumnId] = useState<string>(initial?.grouping?.columnId ?? '');
|
|
185
|
+
const [sumColumnIds, setSumColumnIds] = useState<Set<string>>(
|
|
186
|
+
new Set(initial?.grouping?.sumColumnIds ?? [])
|
|
187
|
+
);
|
|
74
188
|
|
|
75
189
|
// Count entities per type across all providers
|
|
76
190
|
const typeCounts = useMemo(() => {
|
|
@@ -85,33 +199,45 @@ export function ListBuilder({ providers, initial, onSave, onCancel, onExecute }:
|
|
|
85
199
|
return counts;
|
|
86
200
|
}, [providers]);
|
|
87
201
|
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
202
|
+
// Available columns. Prefer COMPLETE, type-independent discovery (every
|
|
203
|
+
// property set / quantity set in the model) so all properties/quantities
|
|
204
|
+
// are addable even with no entity type selected. Fall back to the
|
|
205
|
+
// type-sampled discovery for providers that can't enumerate completely.
|
|
206
|
+
const discovered = useMemo<DiscoveredColumns>(() => {
|
|
207
|
+
const complete = providers.filter((p) => typeof p.discoverAllColumns === 'function');
|
|
208
|
+
if (providers.length > 0 && complete.length === providers.length) {
|
|
209
|
+
return mergeDiscovered(complete.map((p) => p.discoverAllColumns!()));
|
|
210
|
+
}
|
|
91
211
|
return discoverColumns(providers, Array.from(selectedTypes));
|
|
92
212
|
}, [providers, selectedTypes]);
|
|
93
213
|
|
|
94
214
|
const toggleType = useCallback((type: IfcTypeEnum) => {
|
|
95
215
|
setSelectedTypes(prev => {
|
|
96
216
|
const next = new Set(prev);
|
|
97
|
-
if (next.has(type))
|
|
98
|
-
|
|
99
|
-
} else {
|
|
100
|
-
next.add(type);
|
|
101
|
-
}
|
|
217
|
+
if (next.has(type)) next.delete(type);
|
|
218
|
+
else next.add(type);
|
|
102
219
|
return next;
|
|
103
220
|
});
|
|
104
221
|
}, []);
|
|
105
222
|
|
|
106
223
|
const addColumn = useCallback((col: ColumnDefinition) => {
|
|
107
|
-
setColumns(prev =>
|
|
108
|
-
if (prev.some(c => c.id === col.id)) return prev;
|
|
109
|
-
return [...prev, col];
|
|
110
|
-
});
|
|
224
|
+
setColumns(prev => (prev.some(c => c.id === col.id) ? prev : [...prev, col]));
|
|
111
225
|
}, []);
|
|
112
226
|
|
|
113
227
|
const removeColumn = useCallback((id: string) => {
|
|
114
228
|
setColumns(prev => prev.filter(c => c.id !== id));
|
|
229
|
+
// Keep grouping consistent when its column is removed.
|
|
230
|
+
setGroupByColumnId(prev => (prev === id ? '' : prev));
|
|
231
|
+
setSumColumnIds(prev => {
|
|
232
|
+
if (!prev.has(id)) return prev;
|
|
233
|
+
const next = new Set(prev);
|
|
234
|
+
next.delete(id);
|
|
235
|
+
return next;
|
|
236
|
+
});
|
|
237
|
+
}, []);
|
|
238
|
+
|
|
239
|
+
const toggleColumn = useCallback((col: ColumnDefinition) => {
|
|
240
|
+
setColumns(prev => (prev.some(c => c.id === col.id) ? prev.filter(c => c.id !== col.id) : [...prev, col]));
|
|
115
241
|
}, []);
|
|
116
242
|
|
|
117
243
|
const moveColumn = useCallback((idx: number, direction: -1 | 1) => {
|
|
@@ -119,14 +245,38 @@ export function ListBuilder({ providers, initial, onSave, onCancel, onExecute }:
|
|
|
119
245
|
const target = idx + direction;
|
|
120
246
|
if (target < 0 || target >= prev.length) return prev;
|
|
121
247
|
const next = [...prev];
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
248
|
+
[next[idx], next[target]] = [next[target], next[idx]];
|
|
249
|
+
return next;
|
|
250
|
+
});
|
|
251
|
+
}, []);
|
|
252
|
+
|
|
253
|
+
const addCondition = useCallback((condition: PropertyCondition) => {
|
|
254
|
+
setConditions(prev => [...prev, condition]);
|
|
255
|
+
}, []);
|
|
256
|
+
const updateCondition = useCallback((idx: number, condition: PropertyCondition) => {
|
|
257
|
+
setConditions(prev => prev.map((c, i) => (i === idx ? condition : c)));
|
|
258
|
+
}, []);
|
|
259
|
+
const removeCondition = useCallback((idx: number) => {
|
|
260
|
+
setConditions(prev => prev.filter((_, i) => i !== idx));
|
|
261
|
+
}, []);
|
|
262
|
+
|
|
263
|
+
const toggleSumColumn = useCallback((id: string) => {
|
|
264
|
+
setSumColumnIds(prev => {
|
|
265
|
+
const next = new Set(prev);
|
|
266
|
+
if (next.has(id)) next.delete(id);
|
|
267
|
+
else next.add(id);
|
|
125
268
|
return next;
|
|
126
269
|
});
|
|
127
270
|
}, []);
|
|
128
271
|
|
|
129
272
|
const buildDefinition = useCallback((): ListDefinition => {
|
|
273
|
+
const groupValid = groupByColumnId && columns.some(c => c.id === groupByColumnId);
|
|
274
|
+
const sumCols = columns.filter(c => sumColumnIds.has(c.id)).map(c => c.id);
|
|
275
|
+
// Keep grouping when there's a valid group column OR any sum column — sums
|
|
276
|
+
// alone still produce grand totals, and may have been set from the table.
|
|
277
|
+
const grouping = (groupValid || sumCols.length > 0)
|
|
278
|
+
? { columnId: groupValid ? groupByColumnId : '', sumColumnIds: sumCols }
|
|
279
|
+
: undefined;
|
|
130
280
|
return {
|
|
131
281
|
id: initial?.id ?? crypto.randomUUID(),
|
|
132
282
|
name: name || 'Untitled List',
|
|
@@ -134,183 +284,147 @@ export function ListBuilder({ providers, initial, onSave, onCancel, onExecute }:
|
|
|
134
284
|
createdAt: initial?.createdAt ?? Date.now(),
|
|
135
285
|
updatedAt: Date.now(),
|
|
136
286
|
entityTypes: Array.from(selectedTypes),
|
|
287
|
+
// Preserve a filter-snapshot scope (set at creation; not edited here).
|
|
288
|
+
expressIdsByModel: initial?.expressIdsByModel,
|
|
137
289
|
conditions,
|
|
138
290
|
columns,
|
|
291
|
+
grouping,
|
|
139
292
|
};
|
|
140
|
-
}, [initial, name, description, selectedTypes, conditions, columns]);
|
|
293
|
+
}, [initial, name, description, selectedTypes, conditions, columns, groupByColumnId, sumColumnIds]);
|
|
141
294
|
|
|
142
|
-
const handleSave = useCallback(() =>
|
|
143
|
-
|
|
144
|
-
}, [buildDefinition, onSave]);
|
|
145
|
-
|
|
146
|
-
const handleRun = useCallback(() => {
|
|
147
|
-
const def = buildDefinition();
|
|
148
|
-
onExecute(def);
|
|
149
|
-
}, [buildDefinition, onExecute]);
|
|
295
|
+
const handleSave = useCallback(() => onSave(buildDefinition()), [buildDefinition, onSave]);
|
|
296
|
+
const handleRun = useCallback(() => onExecute(buildDefinition()), [buildDefinition, onExecute]);
|
|
150
297
|
|
|
151
298
|
const selectedColumnIds = useMemo(() => new Set(columns.map(c => c.id)), [columns]);
|
|
152
|
-
|
|
153
299
|
const totalSelectedEntities = useMemo(() => {
|
|
154
300
|
let count = 0;
|
|
155
|
-
for (const type of selectedTypes)
|
|
156
|
-
count += typeCounts.get(type) ?? 0;
|
|
157
|
-
}
|
|
301
|
+
for (const type of selectedTypes) count += typeCounts.get(type) ?? 0;
|
|
158
302
|
return count;
|
|
159
303
|
}, [selectedTypes, typeCounts]);
|
|
160
304
|
|
|
305
|
+
// A snapshot list (from "Create list" in the search filter) is frozen to an
|
|
306
|
+
// explicit element set; the entity-type scope doesn't apply.
|
|
307
|
+
const snapshotCount = initial?.expressIdsByModel
|
|
308
|
+
? Object.values(initial.expressIdsByModel).reduce((n, ids) => n + ids.length, 0)
|
|
309
|
+
: 0;
|
|
310
|
+
const isSnapshot = snapshotCount > 0;
|
|
311
|
+
|
|
312
|
+
const canRun = columns.length > 0;
|
|
313
|
+
|
|
161
314
|
return (
|
|
162
315
|
<div className="flex-1 flex flex-col min-h-0">
|
|
163
316
|
<ScrollArea className="flex-1">
|
|
164
|
-
<div className="
|
|
165
|
-
{/*
|
|
317
|
+
<div className="px-3 py-3 space-y-5">
|
|
318
|
+
{/* Identity */}
|
|
166
319
|
<div className="space-y-2">
|
|
167
320
|
<Input
|
|
168
|
-
placeholder="List name
|
|
321
|
+
placeholder="List name…"
|
|
169
322
|
value={name}
|
|
170
323
|
onChange={e => setName(e.target.value)}
|
|
171
|
-
className="h-
|
|
324
|
+
className="h-9 text-sm font-medium"
|
|
172
325
|
/>
|
|
173
326
|
<Input
|
|
174
327
|
placeholder="Description (optional)"
|
|
175
328
|
value={description}
|
|
176
329
|
onChange={e => setDescription(e.target.value)}
|
|
177
|
-
className="h-
|
|
330
|
+
className="h-7 text-xs"
|
|
178
331
|
/>
|
|
179
332
|
</div>
|
|
180
333
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
</
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
{selectedTypes.size > 0 && discovered && (
|
|
221
|
-
<>
|
|
222
|
-
<Separator />
|
|
223
|
-
|
|
224
|
-
{/* Column Selection */}
|
|
225
|
-
<div>
|
|
226
|
-
<button
|
|
227
|
-
className="flex items-center gap-1 text-xs font-medium text-muted-foreground uppercase tracking-wider w-full"
|
|
228
|
-
onClick={() => setColumnsExpanded(!columnsExpanded)}
|
|
229
|
-
>
|
|
230
|
-
{columnsExpanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
|
231
|
-
Columns ({columns.length} selected)
|
|
232
|
-
</button>
|
|
233
|
-
|
|
234
|
-
{columnsExpanded && (
|
|
235
|
-
<div className="mt-2 space-y-2">
|
|
236
|
-
{/* Selected columns (reorderable) */}
|
|
237
|
-
{columns.length > 0 && (
|
|
238
|
-
<div className="space-y-0.5 mb-2">
|
|
239
|
-
{columns.map((col, idx) => (
|
|
240
|
-
<div
|
|
241
|
-
key={col.id}
|
|
242
|
-
className="flex items-center gap-1 px-2 py-1 rounded bg-muted/50 text-xs"
|
|
243
|
-
>
|
|
244
|
-
<span className="text-muted-foreground w-4 text-right">{idx + 1}</span>
|
|
245
|
-
<span className="flex-1 truncate">
|
|
246
|
-
{col.label ?? col.propertyName}
|
|
247
|
-
{col.psetName && (
|
|
248
|
-
<span className="text-muted-foreground ml-1">({col.psetName})</span>
|
|
249
|
-
)}
|
|
250
|
-
</span>
|
|
251
|
-
<button
|
|
252
|
-
onClick={() => moveColumn(idx, -1)}
|
|
253
|
-
disabled={idx === 0}
|
|
254
|
-
className={`${idx === 0 ? 'text-muted-foreground/30' : 'text-muted-foreground hover:text-foreground'}`}
|
|
255
|
-
>
|
|
256
|
-
<ChevronUp className="h-3 w-3" />
|
|
257
|
-
</button>
|
|
258
|
-
<button
|
|
259
|
-
onClick={() => moveColumn(idx, 1)}
|
|
260
|
-
disabled={idx === columns.length - 1}
|
|
261
|
-
className={`${idx === columns.length - 1 ? 'text-muted-foreground/30' : 'text-muted-foreground hover:text-foreground'}`}
|
|
262
|
-
>
|
|
263
|
-
<ChevronDown className="h-3 w-3" />
|
|
264
|
-
</button>
|
|
265
|
-
<button
|
|
266
|
-
onClick={() => removeColumn(col.id)}
|
|
267
|
-
className="text-muted-foreground hover:text-destructive"
|
|
268
|
-
>
|
|
269
|
-
<Trash2 className="h-3 w-3" />
|
|
270
|
-
</button>
|
|
271
|
-
</div>
|
|
272
|
-
))}
|
|
273
|
-
</div>
|
|
274
|
-
)}
|
|
275
|
-
|
|
276
|
-
{/* Available columns */}
|
|
277
|
-
<ColumnPicker
|
|
278
|
-
discovered={discovered}
|
|
279
|
-
selectedIds={selectedColumnIds}
|
|
280
|
-
onAdd={addColumn}
|
|
281
|
-
/>
|
|
282
|
-
</div>
|
|
334
|
+
{/* Scope: entity types — or a frozen filter snapshot */}
|
|
335
|
+
<Section
|
|
336
|
+
label="Scope"
|
|
337
|
+
hint={isSnapshot
|
|
338
|
+
? `${snapshotCount.toLocaleString()} elements · snapshot`
|
|
339
|
+
: selectedTypes.size > 0
|
|
340
|
+
? `${totalSelectedEntities.toLocaleString()} elements`
|
|
341
|
+
: 'All elements'}
|
|
342
|
+
>
|
|
343
|
+
{isSnapshot ? (
|
|
344
|
+
<p className="rounded-md border border-primary/30 bg-primary/5 px-2.5 py-2 text-[11px] leading-relaxed text-muted-foreground">
|
|
345
|
+
<strong className="font-medium text-foreground">Filter snapshot</strong> — frozen to the{' '}
|
|
346
|
+
{snapshotCount.toLocaleString()} elements that matched the search filter. Entity-type scope
|
|
347
|
+
doesn't apply; configure columns and grouping below.
|
|
348
|
+
</p>
|
|
349
|
+
) : (
|
|
350
|
+
<>
|
|
351
|
+
<div className="flex flex-wrap gap-1.5">
|
|
352
|
+
{SELECTABLE_TYPES.map(({ type, label }) => {
|
|
353
|
+
const count = typeCounts.get(type);
|
|
354
|
+
if (!count) return null;
|
|
355
|
+
return (
|
|
356
|
+
<Chip
|
|
357
|
+
key={type}
|
|
358
|
+
selected={selectedTypes.has(type)}
|
|
359
|
+
onClick={() => toggleType(type)}
|
|
360
|
+
trailing={count.toLocaleString()}
|
|
361
|
+
>
|
|
362
|
+
{label}
|
|
363
|
+
</Chip>
|
|
364
|
+
);
|
|
365
|
+
})}
|
|
366
|
+
</div>
|
|
367
|
+
{selectedTypes.size === 0 && (
|
|
368
|
+
<p className="mt-2 text-[11px] leading-relaxed text-muted-foreground">
|
|
369
|
+
No type selected — the list targets <strong className="font-medium text-foreground">all model elements</strong>.
|
|
370
|
+
Use filters to narrow by name, material, classification or storey.
|
|
371
|
+
</p>
|
|
283
372
|
)}
|
|
284
|
-
|
|
285
|
-
|
|
373
|
+
</>
|
|
374
|
+
)}
|
|
375
|
+
</Section>
|
|
376
|
+
|
|
377
|
+
{/* Filters */}
|
|
378
|
+
<Section label="Filters" hint={conditions.length > 0 ? `${conditions.length}` : undefined}>
|
|
379
|
+
<ConditionsBody
|
|
380
|
+
conditions={conditions}
|
|
381
|
+
discovered={discovered}
|
|
382
|
+
values={conditionValues}
|
|
383
|
+
storeys={storeyNames}
|
|
384
|
+
onAdd={addCondition}
|
|
385
|
+
onUpdate={updateCondition}
|
|
386
|
+
onRemove={removeCondition}
|
|
387
|
+
/>
|
|
388
|
+
</Section>
|
|
389
|
+
|
|
390
|
+
{/* Columns */}
|
|
391
|
+
<Section label="Columns" hint={columns.length > 0 ? `${columns.length}` : undefined}>
|
|
392
|
+
{columns.length > 0 && (
|
|
393
|
+
<SelectedColumns columns={columns} onMove={moveColumn} onRemove={removeColumn} />
|
|
394
|
+
)}
|
|
395
|
+
<ColumnPicker
|
|
396
|
+
discovered={discovered}
|
|
397
|
+
selectedIds={selectedColumnIds}
|
|
398
|
+
onAdd={addColumn}
|
|
399
|
+
onToggle={toggleColumn}
|
|
400
|
+
/>
|
|
401
|
+
</Section>
|
|
402
|
+
|
|
403
|
+
{/* Grouping & totals */}
|
|
404
|
+
{columns.length > 0 && (
|
|
405
|
+
<Section label="Grouping & Totals">
|
|
406
|
+
<GroupingBody
|
|
407
|
+
columns={columns}
|
|
408
|
+
groupByColumnId={groupByColumnId}
|
|
409
|
+
sumColumnIds={sumColumnIds}
|
|
410
|
+
onGroupByChange={setGroupByColumnId}
|
|
411
|
+
onToggleSum={toggleSumColumn}
|
|
412
|
+
/>
|
|
413
|
+
</Section>
|
|
286
414
|
)}
|
|
287
415
|
</div>
|
|
288
416
|
</ScrollArea>
|
|
289
417
|
|
|
290
|
-
{/* Bottom
|
|
291
|
-
<div className="flex items-center gap-2 px-3 py-2 border-t">
|
|
292
|
-
<Button
|
|
293
|
-
|
|
294
|
-
size="sm"
|
|
295
|
-
onClick={handleRun}
|
|
296
|
-
disabled={selectedTypes.size === 0 || columns.length === 0}
|
|
297
|
-
className="text-xs h-7"
|
|
298
|
-
>
|
|
299
|
-
<Play className="h-3 w-3 mr-1" />
|
|
300
|
-
Run
|
|
418
|
+
{/* Bottom actions */}
|
|
419
|
+
<div className="flex items-center gap-2 px-3 py-2.5 border-t bg-muted/30">
|
|
420
|
+
<Button size="sm" onClick={handleRun} disabled={!canRun} className="h-8 gap-1.5 text-xs font-medium">
|
|
421
|
+
<Play className="h-3.5 w-3.5" /> Run
|
|
301
422
|
</Button>
|
|
302
|
-
<Button
|
|
303
|
-
|
|
304
|
-
size="sm"
|
|
305
|
-
onClick={handleSave}
|
|
306
|
-
disabled={selectedTypes.size === 0 || columns.length === 0}
|
|
307
|
-
className="text-xs h-7"
|
|
308
|
-
>
|
|
309
|
-
<Save className="h-3 w-3 mr-1" />
|
|
310
|
-
Save
|
|
423
|
+
<Button variant="outline" size="sm" onClick={handleSave} disabled={!canRun} className="h-8 gap-1.5 text-xs">
|
|
424
|
+
<Save className="h-3.5 w-3.5" /> Save
|
|
311
425
|
</Button>
|
|
312
426
|
<div className="flex-1" />
|
|
313
|
-
<Button variant="ghost" size="sm" onClick={onCancel} className="text-xs
|
|
427
|
+
<Button variant="ghost" size="sm" onClick={onCancel} className="h-8 text-xs">
|
|
314
428
|
Cancel
|
|
315
429
|
</Button>
|
|
316
430
|
</div>
|
|
@@ -319,117 +433,238 @@ export function ListBuilder({ providers, initial, onSave, onCancel, onExecute }:
|
|
|
319
433
|
}
|
|
320
434
|
|
|
321
435
|
// ============================================================================
|
|
322
|
-
//
|
|
436
|
+
// Section shell — consistent header with an accent rule
|
|
437
|
+
// ============================================================================
|
|
438
|
+
|
|
439
|
+
function Section({
|
|
440
|
+
label,
|
|
441
|
+
hint,
|
|
442
|
+
children,
|
|
443
|
+
}: {
|
|
444
|
+
label: string;
|
|
445
|
+
hint?: string;
|
|
446
|
+
children: React.ReactNode;
|
|
447
|
+
}) {
|
|
448
|
+
return (
|
|
449
|
+
<section>
|
|
450
|
+
<div className="mb-2 flex items-center gap-2">
|
|
451
|
+
<span className="h-3 w-1 rounded-full bg-primary/70" aria-hidden />
|
|
452
|
+
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
453
|
+
{label}
|
|
454
|
+
</span>
|
|
455
|
+
{hint !== undefined && (
|
|
456
|
+
<Badge variant="secondary" className="h-4 px-1.5 text-[10px] font-normal">{hint}</Badge>
|
|
457
|
+
)}
|
|
458
|
+
</div>
|
|
459
|
+
{children}
|
|
460
|
+
</section>
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function Chip({
|
|
465
|
+
selected,
|
|
466
|
+
onClick,
|
|
467
|
+
trailing,
|
|
468
|
+
children,
|
|
469
|
+
}: {
|
|
470
|
+
selected: boolean;
|
|
471
|
+
onClick: () => void;
|
|
472
|
+
trailing?: React.ReactNode;
|
|
473
|
+
children: React.ReactNode;
|
|
474
|
+
}) {
|
|
475
|
+
return (
|
|
476
|
+
<button
|
|
477
|
+
type="button"
|
|
478
|
+
onClick={onClick}
|
|
479
|
+
aria-pressed={selected}
|
|
480
|
+
className={cn(
|
|
481
|
+
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors',
|
|
482
|
+
selected
|
|
483
|
+
? 'border-primary bg-primary text-primary-foreground shadow-sm'
|
|
484
|
+
: 'border-border bg-background hover:bg-muted',
|
|
485
|
+
)}
|
|
486
|
+
>
|
|
487
|
+
{children}
|
|
488
|
+
{trailing !== undefined && (
|
|
489
|
+
<span className={cn('tabular-nums', selected ? 'opacity-80' : 'text-muted-foreground')}>{trailing}</span>
|
|
490
|
+
)}
|
|
491
|
+
</button>
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ============================================================================
|
|
496
|
+
// Selected columns (ordered, reorderable)
|
|
497
|
+
// ============================================================================
|
|
498
|
+
|
|
499
|
+
function SelectedColumns({
|
|
500
|
+
columns,
|
|
501
|
+
onMove,
|
|
502
|
+
onRemove,
|
|
503
|
+
}: {
|
|
504
|
+
columns: ColumnDefinition[];
|
|
505
|
+
onMove: (idx: number, dir: -1 | 1) => void;
|
|
506
|
+
onRemove: (id: string) => void;
|
|
507
|
+
}) {
|
|
508
|
+
return (
|
|
509
|
+
<div className="mb-3 space-y-1">
|
|
510
|
+
{columns.map((col, idx) => (
|
|
511
|
+
<div
|
|
512
|
+
key={col.id}
|
|
513
|
+
className="group flex items-center gap-1.5 rounded-md border border-border/60 bg-card px-2 py-1 text-xs"
|
|
514
|
+
>
|
|
515
|
+
<GripVertical className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
|
|
516
|
+
<span className="w-4 shrink-0 text-right tabular-nums text-muted-foreground">{idx + 1}</span>
|
|
517
|
+
<span className="flex-1 truncate font-medium">
|
|
518
|
+
{col.label ?? col.propertyName}
|
|
519
|
+
{col.psetName && <span className="ml-1 font-normal text-muted-foreground">· {col.psetName}</span>}
|
|
520
|
+
</span>
|
|
521
|
+
<ColSourceTag source={col.source} />
|
|
522
|
+
<button
|
|
523
|
+
onClick={() => onMove(idx, -1)}
|
|
524
|
+
disabled={idx === 0}
|
|
525
|
+
aria-label="Move up"
|
|
526
|
+
className="shrink-0 text-muted-foreground hover:text-foreground disabled:opacity-25"
|
|
527
|
+
>
|
|
528
|
+
<ChevronUp className="h-3.5 w-3.5" />
|
|
529
|
+
</button>
|
|
530
|
+
<button
|
|
531
|
+
onClick={() => onMove(idx, 1)}
|
|
532
|
+
disabled={idx === columns.length - 1}
|
|
533
|
+
aria-label="Move down"
|
|
534
|
+
className="shrink-0 text-muted-foreground hover:text-foreground disabled:opacity-25"
|
|
535
|
+
>
|
|
536
|
+
<ChevronDown className="h-3.5 w-3.5" />
|
|
537
|
+
</button>
|
|
538
|
+
<button
|
|
539
|
+
onClick={() => onRemove(col.id)}
|
|
540
|
+
aria-label="Remove column"
|
|
541
|
+
className="shrink-0 text-muted-foreground hover:text-destructive"
|
|
542
|
+
>
|
|
543
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
544
|
+
</button>
|
|
545
|
+
</div>
|
|
546
|
+
))}
|
|
547
|
+
</div>
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const SOURCE_TAG: Record<ColumnDefinition['source'], string> = {
|
|
552
|
+
attribute: 'attr',
|
|
553
|
+
property: 'pset',
|
|
554
|
+
quantity: 'qty',
|
|
555
|
+
material: 'mat',
|
|
556
|
+
classification: 'cls',
|
|
557
|
+
spatial: 'storey',
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
function ColSourceTag({ source }: { source: ColumnDefinition['source'] }) {
|
|
561
|
+
return (
|
|
562
|
+
<span className="shrink-0 rounded bg-muted px-1 text-[9px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
563
|
+
{SOURCE_TAG[source]}
|
|
564
|
+
</span>
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ============================================================================
|
|
569
|
+
// Column Picker — flat "common" grid + collapsible pset/qto groups
|
|
323
570
|
// ============================================================================
|
|
324
571
|
|
|
325
572
|
interface ColumnPickerProps {
|
|
326
573
|
discovered: DiscoveredColumns;
|
|
327
574
|
selectedIds: Set<string>;
|
|
328
575
|
onAdd: (col: ColumnDefinition) => void;
|
|
576
|
+
onToggle: (col: ColumnDefinition) => void;
|
|
329
577
|
}
|
|
330
578
|
|
|
331
|
-
function ColumnPicker({ discovered, selectedIds, onAdd }: ColumnPickerProps) {
|
|
332
|
-
const [
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
setExpandedSections(prev => {
|
|
579
|
+
function ColumnPicker({ discovered, selectedIds, onAdd, onToggle }: ColumnPickerProps) {
|
|
580
|
+
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
581
|
+
const toggleSection = (id: string) =>
|
|
582
|
+
setExpanded(prev => {
|
|
336
583
|
const next = new Set(prev);
|
|
337
|
-
if (next.has(
|
|
338
|
-
else next.add(
|
|
584
|
+
if (next.has(id)) next.delete(id);
|
|
585
|
+
else next.add(id);
|
|
339
586
|
return next;
|
|
340
587
|
});
|
|
341
|
-
|
|
588
|
+
|
|
589
|
+
const psetEntries = useMemo(
|
|
590
|
+
() => Array.from(discovered.properties.entries()).sort(([a], [b]) => a.localeCompare(b)),
|
|
591
|
+
[discovered.properties],
|
|
592
|
+
);
|
|
593
|
+
const qtoEntries = useMemo(
|
|
594
|
+
() => Array.from(discovered.quantities.entries()).sort(([a], [b]) => a.localeCompare(b)),
|
|
595
|
+
[discovered.quantities],
|
|
596
|
+
);
|
|
342
597
|
|
|
343
598
|
return (
|
|
344
|
-
<div className="space-y-
|
|
345
|
-
{/*
|
|
346
|
-
<
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
onToggle={() => toggleSection('attributes')}
|
|
350
|
-
>
|
|
351
|
-
{discovered.attributes.map(attr => {
|
|
352
|
-
const id = `attr-${attr.toLowerCase()}`;
|
|
353
|
-
const isSelected = selectedIds.has(id);
|
|
599
|
+
<div className="space-y-2">
|
|
600
|
+
{/* Quick-add grid of the first-class columns */}
|
|
601
|
+
<div className="flex flex-wrap gap-1.5">
|
|
602
|
+
{COMMON_COLUMNS.map(({ id, source, propertyName, label }) => {
|
|
603
|
+
const selected = selectedIds.has(id);
|
|
354
604
|
return (
|
|
355
|
-
<
|
|
605
|
+
<Chip
|
|
356
606
|
key={id}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
}}
|
|
364
|
-
/>
|
|
607
|
+
selected={selected}
|
|
608
|
+
onClick={() => onToggle({ id, source, propertyName, label })}
|
|
609
|
+
>
|
|
610
|
+
{selected && <Check className="h-3 w-3" />}
|
|
611
|
+
{label}
|
|
612
|
+
</Chip>
|
|
365
613
|
);
|
|
366
614
|
})}
|
|
367
|
-
</
|
|
368
|
-
|
|
369
|
-
{/* Property Sets */}
|
|
370
|
-
{Array.from(discovered.properties.entries())
|
|
371
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
372
|
-
.map(([psetName, propNames]) => (
|
|
373
|
-
<CollapsibleSection
|
|
374
|
-
key={`pset-${psetName}`}
|
|
375
|
-
title={psetName}
|
|
376
|
-
badge="P"
|
|
377
|
-
expanded={expandedSections.has(`pset-${psetName}`)}
|
|
378
|
-
onToggle={() => toggleSection(`pset-${psetName}`)}
|
|
379
|
-
>
|
|
380
|
-
{propNames.map(propName => {
|
|
381
|
-
const id = `prop-${psetName}-${propName}`.toLowerCase().replace(/\s+/g, '-');
|
|
382
|
-
const isSelected = selectedIds.has(id);
|
|
383
|
-
return (
|
|
384
|
-
<PickerItem
|
|
385
|
-
key={id}
|
|
386
|
-
label={propName}
|
|
387
|
-
selected={isSelected}
|
|
388
|
-
onClick={() => {
|
|
389
|
-
if (!isSelected) {
|
|
390
|
-
onAdd({ id, source: 'property', psetName, propertyName: propName, label: propName });
|
|
391
|
-
}
|
|
392
|
-
}}
|
|
393
|
-
/>
|
|
394
|
-
);
|
|
395
|
-
})}
|
|
396
|
-
</CollapsibleSection>
|
|
397
|
-
))}
|
|
615
|
+
</div>
|
|
398
616
|
|
|
399
|
-
{
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
617
|
+
{(psetEntries.length > 0 || qtoEntries.length > 0) && (
|
|
618
|
+
<div className="rounded-md border border-border/60">
|
|
619
|
+
{psetEntries.map(([psetName, propNames]) => (
|
|
620
|
+
<PickerGroup
|
|
621
|
+
key={`pset-${psetName}`}
|
|
622
|
+
title={psetName}
|
|
623
|
+
badge="Pset"
|
|
624
|
+
expanded={expanded.has(`pset-${psetName}`)}
|
|
625
|
+
onToggle={() => toggleSection(`pset-${psetName}`)}
|
|
626
|
+
>
|
|
627
|
+
{propNames.map(propName => {
|
|
628
|
+
const id = `prop-${psetName}-${propName}`.toLowerCase().replace(/\s+/g, '-');
|
|
629
|
+
return (
|
|
630
|
+
<PickerItem
|
|
631
|
+
key={id}
|
|
632
|
+
label={propName}
|
|
633
|
+
selected={selectedIds.has(id)}
|
|
634
|
+
onAdd={() => onAdd({ id, source: 'property', psetName, propertyName: propName, label: propName })}
|
|
635
|
+
/>
|
|
636
|
+
);
|
|
637
|
+
})}
|
|
638
|
+
</PickerGroup>
|
|
639
|
+
))}
|
|
640
|
+
{qtoEntries.map(([qsetName, quantNames]) => (
|
|
641
|
+
<PickerGroup
|
|
642
|
+
key={`qset-${qsetName}`}
|
|
643
|
+
title={qsetName}
|
|
644
|
+
badge="Qty"
|
|
645
|
+
expanded={expanded.has(`qset-${qsetName}`)}
|
|
646
|
+
onToggle={() => toggleSection(`qset-${qsetName}`)}
|
|
647
|
+
>
|
|
648
|
+
{quantNames.map(quantName => {
|
|
649
|
+
const id = `quant-${qsetName}-${quantName}`.toLowerCase().replace(/\s+/g, '-');
|
|
650
|
+
return (
|
|
651
|
+
<PickerItem
|
|
652
|
+
key={id}
|
|
653
|
+
label={quantName}
|
|
654
|
+
selected={selectedIds.has(id)}
|
|
655
|
+
onAdd={() => onAdd({ id, source: 'quantity', psetName: qsetName, propertyName: quantName, label: quantName })}
|
|
656
|
+
/>
|
|
657
|
+
);
|
|
658
|
+
})}
|
|
659
|
+
</PickerGroup>
|
|
660
|
+
))}
|
|
661
|
+
</div>
|
|
662
|
+
)}
|
|
428
663
|
</div>
|
|
429
664
|
);
|
|
430
665
|
}
|
|
431
666
|
|
|
432
|
-
function
|
|
667
|
+
function PickerGroup({
|
|
433
668
|
title,
|
|
434
669
|
badge,
|
|
435
670
|
expanded,
|
|
@@ -437,24 +672,24 @@ function CollapsibleSection({
|
|
|
437
672
|
children,
|
|
438
673
|
}: {
|
|
439
674
|
title: string;
|
|
440
|
-
badge
|
|
675
|
+
badge: string;
|
|
441
676
|
expanded: boolean;
|
|
442
677
|
onToggle: () => void;
|
|
443
678
|
children: React.ReactNode;
|
|
444
679
|
}) {
|
|
445
680
|
return (
|
|
446
|
-
<div>
|
|
681
|
+
<div className="border-b border-border/50 last:border-b-0">
|
|
447
682
|
<button
|
|
448
|
-
className="flex items-center gap-1
|
|
683
|
+
className="flex w-full items-center gap-1.5 px-2 py-1.5 text-xs hover:bg-muted/50"
|
|
449
684
|
onClick={onToggle}
|
|
450
685
|
>
|
|
451
|
-
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
|
452
|
-
<span className="font-medium
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
686
|
+
{expanded ? <ChevronDown className="h-3.5 w-3.5 shrink-0" /> : <ChevronRight className="h-3.5 w-3.5 shrink-0" />}
|
|
687
|
+
<span className="truncate font-medium">{title}</span>
|
|
688
|
+
<span className="ml-auto rounded bg-muted px-1 text-[9px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
689
|
+
{badge}
|
|
690
|
+
</span>
|
|
456
691
|
</button>
|
|
457
|
-
{expanded && <div className="
|
|
692
|
+
{expanded && <div className="px-1 pb-1">{children}</div>}
|
|
458
693
|
</div>
|
|
459
694
|
);
|
|
460
695
|
}
|
|
@@ -462,25 +697,299 @@ function CollapsibleSection({
|
|
|
462
697
|
function PickerItem({
|
|
463
698
|
label,
|
|
464
699
|
selected,
|
|
465
|
-
|
|
700
|
+
onAdd,
|
|
466
701
|
}: {
|
|
467
702
|
label: string;
|
|
468
703
|
selected: boolean;
|
|
469
|
-
|
|
704
|
+
onAdd: () => void;
|
|
470
705
|
}) {
|
|
471
706
|
return (
|
|
472
707
|
<button
|
|
473
|
-
className={
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
}
|
|
478
|
-
onClick={onClick}
|
|
708
|
+
className={cn(
|
|
709
|
+
'flex w-full items-center gap-1.5 rounded px-2 py-1 text-xs',
|
|
710
|
+
selected ? 'cursor-default text-muted-foreground' : 'cursor-pointer hover:bg-muted/60',
|
|
711
|
+
)}
|
|
712
|
+
onClick={onAdd}
|
|
479
713
|
disabled={selected}
|
|
480
714
|
>
|
|
481
|
-
<
|
|
715
|
+
{selected ? <Check className="h-3 w-3 text-primary" /> : <Plus className="h-3 w-3" />}
|
|
482
716
|
<span className="truncate">{label}</span>
|
|
483
|
-
{selected && <span className="ml-auto text-[10px]
|
|
717
|
+
{selected && <span className="ml-auto text-[10px]">added</span>}
|
|
484
718
|
</button>
|
|
485
719
|
);
|
|
486
720
|
}
|
|
721
|
+
|
|
722
|
+
// ============================================================================
|
|
723
|
+
// Grouping & totals
|
|
724
|
+
// ============================================================================
|
|
725
|
+
|
|
726
|
+
function GroupingBody({
|
|
727
|
+
columns,
|
|
728
|
+
groupByColumnId,
|
|
729
|
+
sumColumnIds,
|
|
730
|
+
onGroupByChange,
|
|
731
|
+
onToggleSum,
|
|
732
|
+
}: {
|
|
733
|
+
columns: ColumnDefinition[];
|
|
734
|
+
groupByColumnId: string;
|
|
735
|
+
sumColumnIds: Set<string>;
|
|
736
|
+
onGroupByChange: (id: string) => void;
|
|
737
|
+
onToggleSum: (id: string) => void;
|
|
738
|
+
}) {
|
|
739
|
+
return (
|
|
740
|
+
<div className="space-y-3 rounded-md border border-border/60 bg-card p-2.5">
|
|
741
|
+
<label className="flex items-center gap-2 text-xs">
|
|
742
|
+
<span className="w-16 shrink-0 text-muted-foreground">Group by</span>
|
|
743
|
+
<select
|
|
744
|
+
value={groupByColumnId}
|
|
745
|
+
onChange={(e) => onGroupByChange(e.target.value)}
|
|
746
|
+
className="h-7 flex-1 rounded-md border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
|
747
|
+
>
|
|
748
|
+
<option value="">— None (flat list) —</option>
|
|
749
|
+
{columns.map((c) => (
|
|
750
|
+
<option key={c.id} value={c.id}>{c.label ?? c.propertyName}</option>
|
|
751
|
+
))}
|
|
752
|
+
</select>
|
|
753
|
+
</label>
|
|
754
|
+
<div>
|
|
755
|
+
<div className="mb-1 text-[11px] text-muted-foreground">
|
|
756
|
+
Σ Totals — sum these columns per group and overall
|
|
757
|
+
</div>
|
|
758
|
+
<div className="flex flex-wrap gap-1.5">
|
|
759
|
+
{columns.map((c) => (
|
|
760
|
+
<Chip key={c.id} selected={sumColumnIds.has(c.id)} onClick={() => onToggleSum(c.id)}>
|
|
761
|
+
<span className="font-mono">Σ</span> {c.label ?? c.propertyName}
|
|
762
|
+
</Chip>
|
|
763
|
+
))}
|
|
764
|
+
</div>
|
|
765
|
+
</div>
|
|
766
|
+
</div>
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ============================================================================
|
|
771
|
+
// Filters (conditions)
|
|
772
|
+
// ============================================================================
|
|
773
|
+
|
|
774
|
+
type ConditionSource = PropertyCondition['source'];
|
|
775
|
+
|
|
776
|
+
const CONDITION_SOURCES: { source: ConditionSource; label: string }[] = [
|
|
777
|
+
{ source: 'attribute', label: 'Attribute' },
|
|
778
|
+
{ source: 'property', label: 'Property' },
|
|
779
|
+
{ source: 'quantity', label: 'Quantity' },
|
|
780
|
+
{ source: 'material', label: 'Material' },
|
|
781
|
+
{ source: 'classification', label: 'Classification' },
|
|
782
|
+
{ source: 'spatial', label: 'Storey' },
|
|
783
|
+
];
|
|
784
|
+
|
|
785
|
+
const OPERATOR_LABEL: Record<ConditionOperator, string> = {
|
|
786
|
+
equals: '=',
|
|
787
|
+
notEquals: '≠',
|
|
788
|
+
contains: 'contains',
|
|
789
|
+
gt: '>',
|
|
790
|
+
lt: '<',
|
|
791
|
+
gte: '≥',
|
|
792
|
+
lte: '≤',
|
|
793
|
+
exists: 'is set',
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
function operatorsFor(source: ConditionSource): ConditionOperator[] {
|
|
797
|
+
switch (source) {
|
|
798
|
+
case 'quantity':
|
|
799
|
+
return ['equals', 'notEquals', 'gt', 'gte', 'lt', 'lte', 'exists'];
|
|
800
|
+
case 'material':
|
|
801
|
+
case 'classification':
|
|
802
|
+
return ['contains', 'equals', 'notEquals', 'exists'];
|
|
803
|
+
default:
|
|
804
|
+
return ['equals', 'notEquals', 'contains', 'exists'];
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function defaultConditionFor(source: ConditionSource): PropertyCondition {
|
|
809
|
+
switch (source) {
|
|
810
|
+
case 'property':
|
|
811
|
+
return { source, psetName: '', propertyName: '', operator: 'equals', value: '' };
|
|
812
|
+
case 'quantity':
|
|
813
|
+
return { source, psetName: '', propertyName: '', operator: 'gt', value: '' };
|
|
814
|
+
case 'material':
|
|
815
|
+
return { source, propertyName: 'Material', operator: 'contains', value: '' };
|
|
816
|
+
case 'classification':
|
|
817
|
+
return { source, propertyName: 'Classification', operator: 'contains', value: '' };
|
|
818
|
+
case 'spatial':
|
|
819
|
+
return { source, propertyName: 'Storey', operator: 'equals', value: '' };
|
|
820
|
+
case 'attribute':
|
|
821
|
+
default:
|
|
822
|
+
return { source: 'attribute', propertyName: 'Name', operator: 'contains', value: '' };
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const SELECT_CLASS =
|
|
827
|
+
'h-7 rounded-md border border-border bg-background px-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-ring';
|
|
828
|
+
|
|
829
|
+
function ConditionsBody({
|
|
830
|
+
conditions,
|
|
831
|
+
discovered,
|
|
832
|
+
values,
|
|
833
|
+
storeys,
|
|
834
|
+
onAdd,
|
|
835
|
+
onUpdate,
|
|
836
|
+
onRemove,
|
|
837
|
+
}: {
|
|
838
|
+
conditions: PropertyCondition[];
|
|
839
|
+
discovered: DiscoveredColumns;
|
|
840
|
+
values: ListConditionValues | null;
|
|
841
|
+
storeys: string[];
|
|
842
|
+
onAdd: (condition: PropertyCondition) => void;
|
|
843
|
+
onUpdate: (idx: number, condition: PropertyCondition) => void;
|
|
844
|
+
onRemove: (idx: number) => void;
|
|
845
|
+
}) {
|
|
846
|
+
return (
|
|
847
|
+
<div className="space-y-1.5">
|
|
848
|
+
{conditions.map((condition, idx) => (
|
|
849
|
+
<ConditionRow
|
|
850
|
+
key={idx}
|
|
851
|
+
condition={condition}
|
|
852
|
+
discovered={discovered}
|
|
853
|
+
values={values}
|
|
854
|
+
storeys={storeys}
|
|
855
|
+
onChange={(next) => onUpdate(idx, next)}
|
|
856
|
+
onRemove={() => onRemove(idx)}
|
|
857
|
+
/>
|
|
858
|
+
))}
|
|
859
|
+
<button
|
|
860
|
+
onClick={() => onAdd(defaultConditionFor('attribute'))}
|
|
861
|
+
className="flex items-center gap-1 rounded-md border border-dashed border-border px-2 py-1 text-xs text-muted-foreground hover:border-primary/50 hover:text-foreground"
|
|
862
|
+
>
|
|
863
|
+
<Plus className="h-3.5 w-3.5" /> Add filter
|
|
864
|
+
</button>
|
|
865
|
+
</div>
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function ConditionRow({
|
|
870
|
+
condition,
|
|
871
|
+
discovered,
|
|
872
|
+
values,
|
|
873
|
+
storeys,
|
|
874
|
+
onChange,
|
|
875
|
+
onRemove,
|
|
876
|
+
}: {
|
|
877
|
+
condition: PropertyCondition;
|
|
878
|
+
discovered: DiscoveredColumns;
|
|
879
|
+
values: ListConditionValues | null;
|
|
880
|
+
storeys: string[];
|
|
881
|
+
onChange: (next: PropertyCondition) => void;
|
|
882
|
+
onRemove: () => void;
|
|
883
|
+
}) {
|
|
884
|
+
const ops = operatorsFor(condition.source);
|
|
885
|
+
const showValue = condition.operator !== 'exists';
|
|
886
|
+
const isProperty = condition.source === 'property';
|
|
887
|
+
const isQuantity = condition.source === 'quantity';
|
|
888
|
+
const showSetFields = isProperty || isQuantity;
|
|
889
|
+
|
|
890
|
+
const setNameOptions = useMemo<string[]>(() => {
|
|
891
|
+
if (isProperty) return Array.from(discovered.properties.keys()).sort();
|
|
892
|
+
if (isQuantity) return Array.from(discovered.quantities.keys()).sort();
|
|
893
|
+
return [];
|
|
894
|
+
}, [discovered, isProperty, isQuantity]);
|
|
895
|
+
|
|
896
|
+
const propNameOptions = useMemo<string[]>(() => {
|
|
897
|
+
const set = condition.psetName ?? '';
|
|
898
|
+
if (isProperty) return [...(discovered.properties.get(set) ?? [])];
|
|
899
|
+
if (isQuantity) return [...(discovered.quantities.get(set) ?? [])];
|
|
900
|
+
return [];
|
|
901
|
+
}, [discovered, condition.psetName, isProperty, isQuantity]);
|
|
902
|
+
|
|
903
|
+
const valueOptions = useMemo<readonly string[]>(() => {
|
|
904
|
+
switch (condition.source) {
|
|
905
|
+
case 'property':
|
|
906
|
+
return values?.propertyValues.get(propValueKey(condition.psetName ?? '', condition.propertyName)) ?? NO_OPTIONS;
|
|
907
|
+
case 'material': return values?.materials ?? NO_OPTIONS;
|
|
908
|
+
case 'classification': return values?.classifications ?? NO_OPTIONS;
|
|
909
|
+
case 'spatial': return storeys;
|
|
910
|
+
default: return NO_OPTIONS;
|
|
911
|
+
}
|
|
912
|
+
}, [condition.source, condition.psetName, condition.propertyName, values, storeys]);
|
|
913
|
+
|
|
914
|
+
const valuePlaceholder =
|
|
915
|
+
condition.source === 'spatial' ? 'storey name'
|
|
916
|
+
: condition.source === 'material' ? 'material'
|
|
917
|
+
: condition.source === 'classification' ? 'code or name'
|
|
918
|
+
: 'value';
|
|
919
|
+
|
|
920
|
+
return (
|
|
921
|
+
<div className="flex flex-wrap items-center gap-1.5 rounded-md border border-border/60 bg-card px-2 py-1.5 text-xs">
|
|
922
|
+
<select
|
|
923
|
+
value={condition.source}
|
|
924
|
+
onChange={(e) => onChange(defaultConditionFor(e.target.value as ConditionSource))}
|
|
925
|
+
className={SELECT_CLASS}
|
|
926
|
+
aria-label="Filter dimension"
|
|
927
|
+
>
|
|
928
|
+
{CONDITION_SOURCES.map((s) => (
|
|
929
|
+
<option key={s.source} value={s.source}>{s.label}</option>
|
|
930
|
+
))}
|
|
931
|
+
</select>
|
|
932
|
+
|
|
933
|
+
{condition.source === 'attribute' && (
|
|
934
|
+
<select
|
|
935
|
+
value={condition.propertyName}
|
|
936
|
+
onChange={(e) => onChange({ ...condition, propertyName: e.target.value })}
|
|
937
|
+
className={SELECT_CLASS}
|
|
938
|
+
aria-label="Attribute"
|
|
939
|
+
>
|
|
940
|
+
{ENTITY_ATTRIBUTES.map((a) => (
|
|
941
|
+
<option key={a} value={a}>{a}</option>
|
|
942
|
+
))}
|
|
943
|
+
</select>
|
|
944
|
+
)}
|
|
945
|
+
|
|
946
|
+
{showSetFields && (
|
|
947
|
+
<>
|
|
948
|
+
<ComboInput
|
|
949
|
+
value={condition.psetName ?? ''}
|
|
950
|
+
options={setNameOptions}
|
|
951
|
+
placeholder={isQuantity ? 'Qto_…' : 'Pset_…'}
|
|
952
|
+
className="h-7 w-32 text-xs"
|
|
953
|
+
onChange={(v) => onChange({ ...condition, psetName: v })}
|
|
954
|
+
/>
|
|
955
|
+
<ComboInput
|
|
956
|
+
value={condition.propertyName}
|
|
957
|
+
options={propNameOptions}
|
|
958
|
+
placeholder="name"
|
|
959
|
+
className="h-7 w-28 text-xs"
|
|
960
|
+
onChange={(v) => onChange({ ...condition, propertyName: v })}
|
|
961
|
+
/>
|
|
962
|
+
</>
|
|
963
|
+
)}
|
|
964
|
+
|
|
965
|
+
<select
|
|
966
|
+
value={condition.operator}
|
|
967
|
+
onChange={(e) => onChange({ ...condition, operator: e.target.value as ConditionOperator })}
|
|
968
|
+
className={SELECT_CLASS}
|
|
969
|
+
aria-label="Operator"
|
|
970
|
+
>
|
|
971
|
+
{ops.map((op) => (
|
|
972
|
+
<option key={op} value={op}>{OPERATOR_LABEL[op]}</option>
|
|
973
|
+
))}
|
|
974
|
+
</select>
|
|
975
|
+
|
|
976
|
+
{showValue && (
|
|
977
|
+
<ComboInput
|
|
978
|
+
value={String(condition.value ?? '')}
|
|
979
|
+
options={valueOptions}
|
|
980
|
+
placeholder={valuePlaceholder}
|
|
981
|
+
className="h-7 w-44 text-xs"
|
|
982
|
+
onChange={(v) => onChange({ ...condition, value: v })}
|
|
983
|
+
/>
|
|
984
|
+
)}
|
|
985
|
+
|
|
986
|
+
<button
|
|
987
|
+
onClick={onRemove}
|
|
988
|
+
aria-label="Remove filter"
|
|
989
|
+
className="ml-auto shrink-0 text-muted-foreground hover:text-destructive"
|
|
990
|
+
>
|
|
991
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
992
|
+
</button>
|
|
993
|
+
</div>
|
|
994
|
+
);
|
|
995
|
+
}
|