@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.
Files changed (116) hide show
  1. package/.turbo/turbo-build.log +40 -30
  2. package/CHANGELOG.md +110 -0
  3. package/dist/assets/{basketViewActivator-CTgyKI3U.js → basketViewActivator-B3CdrLsb.js} +7 -7
  4. package/dist/assets/{bcf-7jQby1qi.js → bcf-QeHK_Aud.js} +5 -5
  5. package/dist/assets/{browser-DXS29_v9.js → browser-BIoDDfBW.js} +1 -1
  6. package/dist/assets/{cesium-BoVuJvTC.js → cesium-CzZn5yVA.js} +319 -319
  7. package/dist/assets/{deflate-Cfp9t1Df.js → deflate-B-d0SYQM.js} +1 -1
  8. package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
  9. package/dist/assets/{exporters-DfSvJPi4.js → exporters-B4LbZFeT.js} +1434 -1179
  10. package/dist/assets/geometry.worker-BdH-E6NB.js +1 -0
  11. package/dist/assets/{geotiff-xZoE8BkO.js → geotiff-CrVtDRFq.js} +10 -10
  12. package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
  13. package/dist/assets/{ids-Cu73hD0Y.js → ids-DjsGFN10.js} +21 -21
  14. package/dist/assets/ifc-lite_bg-DsYUIHm3.wasm +0 -0
  15. package/dist/assets/{index-WSbA5iy6.js → index-COYokSKc.js} +44122 -38782
  16. package/dist/assets/index-ajK6D32J.css +1 -0
  17. package/dist/assets/index.es-CY202jA3.js +6866 -0
  18. package/dist/assets/{jpeg-DhwFEbqb.js → jpeg-D4wOkf5h.js} +1 -1
  19. package/dist/assets/jspdf.es.min-DIGb9BHN.js +19571 -0
  20. package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
  21. package/dist/assets/{lerc-Dz6BXOVb.js → lerc-DmW0_tgf.js} +1 -1
  22. package/dist/assets/{lzw-C9z0fG2o.js → lzw-oWetY-d6.js} +1 -1
  23. package/dist/assets/{maplibre-gl-Do6O5tDc.js → maplibre-gl-BF3Z0idw.js} +1 -1
  24. package/dist/assets/{native-bridge-RvDmzO-2.js → native-bridge-BX8_tHXE.js} +1 -1
  25. package/dist/assets/{packbits-jfwifz7C.js → packbits-F8Nkp4NY.js} +1 -1
  26. package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
  27. package/dist/assets/{parser.worker-C594dWxH.js → parser.worker-D591Zu_-.js} +3 -3
  28. package/dist/assets/pdf-Dsh3HPZB.js +135 -0
  29. package/dist/assets/raw-D9iw0tmc.js +1 -0
  30. package/dist/assets/{sandbox-DDSZ7rek.js → sandbox-BAC3a-eN.js} +4235 -2716
  31. package/dist/assets/server-client-Cjwnm7il.js +706 -0
  32. package/dist/assets/{webimage-XFHVyVtC.js → webimage-BLV1dgmd.js} +1 -1
  33. package/dist/assets/xlsx-Bc2HTrjC.js +142 -0
  34. package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
  35. package/dist/assets/{zstd-3q5qcl5V.js → zstd-C_1HxVrA.js} +1 -1
  36. package/dist/index.html +8 -8
  37. package/package.json +13 -9
  38. package/src/components/extensions/FlavorDialog.tsx +18 -2
  39. package/src/components/extensions/FlavorListView.tsx +12 -3
  40. package/src/components/mcp/PlaygroundChat.tsx +1 -0
  41. package/src/components/mcp/data.ts +6 -0
  42. package/src/components/mcp/playground-dispatcher.ts +277 -0
  43. package/src/components/mcp/types.ts +2 -1
  44. package/src/components/ui/combo-input.tsx +163 -0
  45. package/src/components/ui/tabs.tsx +1 -1
  46. package/src/components/viewer/ClashBcfExportDialog.tsx +271 -0
  47. package/src/components/viewer/ClashPanel.tsx +370 -0
  48. package/src/components/viewer/ClashSettingsDialog.tsx +407 -0
  49. package/src/components/viewer/CommandPalette.tsx +14 -15
  50. package/src/components/viewer/MainToolbar.tsx +155 -175
  51. package/src/components/viewer/PropertiesPanel.tsx +13 -6
  52. package/src/components/viewer/SearchInline.tsx +62 -2
  53. package/src/components/viewer/SearchModal.filter.builder.tsx +24 -393
  54. package/src/components/viewer/SearchModal.filter.editors.tsx +503 -0
  55. package/src/components/viewer/SearchModal.filter.tsx +64 -1
  56. package/src/components/viewer/SearchModal.tsx +19 -6
  57. package/src/components/viewer/ViewerLayout.tsx +5 -0
  58. package/src/components/viewer/Viewport.tsx +64 -9
  59. package/src/components/viewer/ViewportContainer.tsx +45 -3
  60. package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
  61. package/src/components/viewer/lists/ColumnHeaderMenu.tsx +84 -0
  62. package/src/components/viewer/lists/ListBuilder.tsx +789 -280
  63. package/src/components/viewer/lists/ListGroupingBar.tsx +72 -0
  64. package/src/components/viewer/lists/ListPanel.tsx +49 -5
  65. package/src/components/viewer/lists/ListResultsTable.tsx +270 -176
  66. package/src/components/viewer/lists/list-table-utils.ts +123 -0
  67. package/src/components/viewer/useGeometryStreaming.ts +21 -1
  68. package/src/generated/mcp-catalog.json +4 -0
  69. package/src/hooks/ingest/streamCleanup.test.ts +41 -0
  70. package/src/hooks/ingest/streamCleanup.ts +45 -0
  71. package/src/hooks/ingest/viewerModelIngest.ts +64 -42
  72. package/src/hooks/ingest/watchedGeometryStream.test.ts +78 -0
  73. package/src/hooks/ingest/watchedGeometryStream.ts +76 -0
  74. package/src/hooks/source-key.ts +35 -0
  75. package/src/hooks/useAlignmentLines3D.ts +139 -0
  76. package/src/hooks/useClash.ts +420 -0
  77. package/src/hooks/useGridLines3D.ts +140 -0
  78. package/src/hooks/useIfcFederation.ts +16 -2
  79. package/src/hooks/useIfcLoader.ts +5 -7
  80. package/src/lib/clash/persistence.ts +308 -0
  81. package/src/lib/geo/effective-georef.test.ts +66 -0
  82. package/src/lib/length-unit-scale.ts +41 -0
  83. package/src/lib/lists/adapter.ts +136 -11
  84. package/src/lib/lists/export/csv.ts +47 -0
  85. package/src/lib/lists/export/index.ts +49 -0
  86. package/src/lib/lists/export/model.ts +111 -0
  87. package/src/lib/lists/export/pdf.ts +67 -0
  88. package/src/lib/lists/export/xlsx.ts +83 -0
  89. package/src/lib/lists/index.ts +2 -0
  90. package/src/lib/search/filter-evaluate.test.ts +81 -0
  91. package/src/lib/search/filter-evaluate.ts +59 -87
  92. package/src/lib/search/filter-match.ts +167 -0
  93. package/src/lib/search/filter-rules.test.ts +25 -0
  94. package/src/lib/search/filter-rules.ts +75 -2
  95. package/src/lib/search/filter-schema.ts +0 -0
  96. package/src/lib/slab-edit.test.ts +72 -0
  97. package/src/lib/slab-edit.ts +159 -19
  98. package/src/sdk/adapters/export-adapter.ts +3 -3
  99. package/src/sdk/adapters/query-adapter.ts +3 -3
  100. package/src/services/extensions/host.ts +13 -0
  101. package/src/store/constants.ts +33 -25
  102. package/src/store/index.ts +29 -8
  103. package/src/store/slices/clashSlice.ts +251 -0
  104. package/src/store/slices/listSlice.ts +6 -0
  105. package/src/store/slices/mutationSlice.ts +14 -6
  106. package/src/store/slices/searchSlice.ts +29 -3
  107. package/src/store/slices/visibilitySlice.test.ts +23 -5
  108. package/src/store/slices/visibilitySlice.ts +18 -8
  109. package/src/utils/nativeSpatialDataStore.ts +6 -0
  110. package/src/utils/serverDataModel.test.ts +6 -0
  111. package/src/utils/serverDataModel.ts +7 -0
  112. package/dist/assets/geometry.worker-Cyn5BybV.js +0 -1
  113. package/dist/assets/ifc-lite_bg-ksLBP5cA.wasm +0 -0
  114. package/dist/assets/index-Bws3UAkj.css +0 -1
  115. package/dist/assets/raw-R2QfzPAR.js +0 -1
  116. 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 - Configure a list by selecting entity types, columns, and conditions
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
- const [columnsExpanded, setColumnsExpanded] = useState(true);
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
- // Discover available columns whenever selected types change (across all providers)
89
- const discovered = useMemo<DiscoveredColumns | null>(() => {
90
- if (selectedTypes.size === 0) return null;
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
- next.delete(type);
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
- const tmp = next[idx];
123
- next[idx] = next[target];
124
- next[target] = tmp;
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
- onSave(buildDefinition());
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="p-3 space-y-4">
165
- {/* Name & Description */}
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-8 text-sm"
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-8 text-sm"
330
+ className="h-7 text-xs"
178
331
  />
179
332
  </div>
180
333
 
181
- <Separator />
182
-
183
- {/* Entity Type Selection */}
184
- <div>
185
- <div className="flex items-center justify-between mb-2">
186
- <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
187
- Entity Types
188
- </span>
189
- {selectedTypes.size > 0 && (
190
- <Badge variant="secondary" className="text-xs h-5">
191
- {totalSelectedEntities} entities
192
- </Badge>
193
- )}
194
- </div>
195
- <div className="flex flex-wrap gap-1">
196
- {SELECTABLE_TYPES.map(({ type, label }) => {
197
- const count = typeCounts.get(type);
198
- if (!count) return null; // Don't show types not in model
199
- const selected = selectedTypes.has(type);
200
- return (
201
- <button
202
- key={type}
203
- onClick={() => toggleType(type)}
204
- className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs border transition-colors ${
205
- selected
206
- ? 'bg-primary text-primary-foreground border-primary'
207
- : 'bg-background border-border hover:bg-muted'
208
- }`}
209
- >
210
- {label}
211
- <span className={selected ? 'opacity-75' : 'text-muted-foreground'}>
212
- {count}
213
- </span>
214
- </button>
215
- );
216
- })}
217
- </div>
218
- </div>
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&apos;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
- </div>
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 Actions */}
291
- <div className="flex items-center gap-2 px-3 py-2 border-t">
292
- <Button
293
- variant="default"
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
- variant="outline"
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 h-7">
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
- // Column Picker
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 [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['attributes']));
333
-
334
- const toggleSection = (section: string) => {
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(section)) next.delete(section);
338
- else next.add(section);
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-1 text-xs">
345
- {/* Attributes */}
346
- <CollapsibleSection
347
- title="Attributes"
348
- expanded={expandedSections.has('attributes')}
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
- <PickerItem
605
+ <Chip
356
606
  key={id}
357
- label={attr}
358
- selected={isSelected}
359
- onClick={() => {
360
- if (!isSelected) {
361
- onAdd({ id, source: 'attribute', propertyName: attr });
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
- </CollapsibleSection>
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
- {/* Quantity Sets */}
400
- {Array.from(discovered.quantities.entries())
401
- .sort(([a], [b]) => a.localeCompare(b))
402
- .map(([qsetName, quantNames]) => (
403
- <CollapsibleSection
404
- key={`qset-${qsetName}`}
405
- title={qsetName}
406
- badge="Q"
407
- expanded={expandedSections.has(`qset-${qsetName}`)}
408
- onToggle={() => toggleSection(`qset-${qsetName}`)}
409
- >
410
- {quantNames.map(quantName => {
411
- const id = `quant-${qsetName}-${quantName}`.toLowerCase().replace(/\s+/g, '-');
412
- const isSelected = selectedIds.has(id);
413
- return (
414
- <PickerItem
415
- key={id}
416
- label={quantName}
417
- selected={isSelected}
418
- onClick={() => {
419
- if (!isSelected) {
420
- onAdd({ id, source: 'quantity', psetName: qsetName, propertyName: quantName, label: quantName });
421
- }
422
- }}
423
- />
424
- );
425
- })}
426
- </CollapsibleSection>
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 CollapsibleSection({
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?: string;
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 w-full px-1 py-0.5 rounded hover:bg-muted/50 text-xs"
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 truncate">{title}</span>
453
- {badge && (
454
- <span className="ml-auto text-[10px] bg-muted px-1 rounded">{badge}</span>
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="pl-4 space-y-0">{children}</div>}
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
- onClick,
700
+ onAdd,
466
701
  }: {
467
702
  label: string;
468
703
  selected: boolean;
469
- onClick: () => void;
704
+ onAdd: () => void;
470
705
  }) {
471
706
  return (
472
707
  <button
473
- className={`flex items-center gap-1 w-full px-1 py-0.5 rounded text-xs ${
474
- selected
475
- ? 'text-muted-foreground cursor-default'
476
- : 'hover:bg-muted/50 cursor-pointer'
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
- <Plus className={`h-2.5 w-2.5 ${selected ? 'invisible' : ''}`} />
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] text-muted-foreground">added</span>}
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
+ }