@ifc-lite/viewer 1.1.6 → 1.5.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/LICENSE +373 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/Arrow.dom-B0e15b_b.js +20 -0
- package/dist/assets/arrow2-bb-jcVEo.js +2 -0
- package/dist/assets/arrow2_bg-4Y7xYo54.wasm +0 -0
- package/dist/assets/arrow2_bg-BlXl-cSQ.js +1 -0
- package/dist/assets/arrow2_bg-BoXCojjR.wasm +0 -0
- package/dist/assets/desktop-cache-oPzaWXYE.js +1 -0
- package/dist/assets/event-DIOks52T.js +1 -0
- package/dist/assets/ifc-cache-BAN4vcd4.js +1 -0
- package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
- package/dist/assets/index-Dgd6vzw_.js +65252 -0
- package/dist/assets/index-v3mcCUPN.css +1 -0
- package/dist/assets/native-bridge-Ci7NLjlZ.js +111 -0
- package/dist/assets/wasm-bridge-Dc82YpdZ.js +1 -0
- package/dist/favicon-16x16-cropped.png +0 -0
- package/dist/favicon-16x16.png +0 -0
- package/dist/favicon-192x192-cropped.png +0 -0
- package/dist/favicon-192x192.png +0 -0
- package/dist/favicon-32x32-cropped.png +0 -0
- package/dist/favicon-32x32.png +0 -0
- package/dist/favicon-48x48-cropped.png +0 -0
- package/dist/favicon-48x48.png +0 -0
- package/dist/favicon-512x512-cropped.png +0 -0
- package/dist/favicon-512x512.png +0 -0
- package/dist/favicon-64x64-cropped.png +0 -0
- package/dist/favicon-64x64.png +0 -0
- package/dist/favicon-96x96-cropped.png +0 -0
- package/dist/favicon-96x96.png +0 -0
- package/dist/favicon-square-512.png +0 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.png +0 -0
- package/dist/favicon.svg +3 -0
- package/dist/index.html +44 -0
- package/dist/logo.png +0 -0
- package/dist/manifest.json +48 -0
- package/index.html +33 -2
- package/package.json +34 -17
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon-16x16-cropped.png +0 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-192x192-cropped.png +0 -0
- package/public/favicon-192x192.png +0 -0
- package/public/favicon-32x32-cropped.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon-48x48-cropped.png +0 -0
- package/public/favicon-48x48.png +0 -0
- package/public/favicon-512x512-cropped.png +0 -0
- package/public/favicon-512x512.png +0 -0
- package/public/favicon-64x64-cropped.png +0 -0
- package/public/favicon-64x64.png +0 -0
- package/public/favicon-96x96-cropped.png +0 -0
- package/public/favicon-96x96.png +0 -0
- package/public/favicon-square-512.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +3 -0
- package/public/logo.png +0 -0
- package/public/manifest.json +48 -0
- package/src/App.tsx +2 -0
- package/src/components/ui/alert.tsx +62 -0
- package/src/components/ui/badge.tsx +39 -0
- package/src/components/ui/dialog.tsx +120 -0
- package/src/components/ui/label.tsx +27 -0
- package/src/components/ui/select.tsx +151 -0
- package/src/components/ui/switch.tsx +30 -0
- package/src/components/ui/table.tsx +120 -0
- package/src/components/ui/tabs.tsx +1 -1
- package/src/components/viewer/BCFPanel.tsx +1164 -0
- package/src/components/viewer/BulkPropertyEditor.tsx +875 -0
- package/src/components/viewer/DataConnector.tsx +840 -0
- package/src/components/viewer/DrawingSettingsPanel.tsx +536 -0
- package/src/components/viewer/EntityContextMenu.tsx +45 -17
- package/src/components/viewer/ExportChangesButton.tsx +195 -0
- package/src/components/viewer/ExportDialog.tsx +402 -0
- package/src/components/viewer/HierarchyPanel.tsx +1132 -218
- package/src/components/viewer/IDSPanel.tsx +661 -0
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +245 -39
- package/src/components/viewer/MainToolbar.tsx +418 -94
- package/src/components/viewer/PropertiesPanel.tsx +1355 -91
- package/src/components/viewer/PropertyEditor.tsx +611 -0
- package/src/components/viewer/Section2DPanel.tsx +3313 -0
- package/src/components/viewer/SheetSetupPanel.tsx +502 -0
- package/src/components/viewer/StatusBar.tsx +27 -16
- package/src/components/viewer/TitleBlockEditor.tsx +437 -0
- package/src/components/viewer/ToolOverlays.tsx +935 -127
- package/src/components/viewer/ViewerLayout.tsx +40 -11
- package/src/components/viewer/Viewport.tsx +1276 -336
- package/src/components/viewer/ViewportContainer.tsx +554 -18
- package/src/components/viewer/ViewportOverlays.tsx +24 -7
- package/src/hooks/useBCF.ts +504 -0
- package/src/hooks/useIDS.ts +1065 -0
- package/src/hooks/useIfc.ts +1534 -205
- package/src/hooks/useIfcCache.ts +279 -0
- package/src/hooks/useKeyboardShortcuts.ts +50 -8
- package/src/hooks/useModelSelection.ts +61 -0
- package/src/hooks/useViewerSelectors.ts +218 -0
- package/src/hooks/useWebGPU.ts +80 -0
- package/src/index.css +265 -27
- package/src/lib/platform.ts +23 -0
- package/src/services/cacheService.ts +142 -0
- package/src/services/desktop-cache.ts +143 -0
- package/src/services/fs-cache.ts +212 -0
- package/src/services/ifc-cache.ts +14 -6
- package/src/store/constants.ts +85 -0
- package/src/store/index.ts +214 -0
- package/src/store/slices/bcfSlice.ts +372 -0
- package/src/store/slices/cameraSlice.ts +63 -0
- package/src/store/slices/dataSlice.test.ts +226 -0
- package/src/store/slices/dataSlice.ts +112 -0
- package/src/store/slices/drawing2DSlice.ts +340 -0
- package/src/store/slices/hoverSlice.ts +40 -0
- package/src/store/slices/idsSlice.ts +310 -0
- package/src/store/slices/loadingSlice.ts +33 -0
- package/src/store/slices/measurementSlice.test.ts +217 -0
- package/src/store/slices/measurementSlice.ts +293 -0
- package/src/store/slices/modelSlice.test.ts +271 -0
- package/src/store/slices/modelSlice.ts +211 -0
- package/src/store/slices/mutationSlice.ts +502 -0
- package/src/store/slices/sectionSlice.test.ts +125 -0
- package/src/store/slices/sectionSlice.ts +58 -0
- package/src/store/slices/selectionSlice.test.ts +286 -0
- package/src/store/slices/selectionSlice.ts +263 -0
- package/src/store/slices/sheetSlice.ts +565 -0
- package/src/store/slices/uiSlice.ts +58 -0
- package/src/store/slices/visibilitySlice.test.ts +304 -0
- package/src/store/slices/visibilitySlice.ts +277 -0
- package/src/store/types.test.ts +135 -0
- package/src/store/types.ts +248 -0
- package/src/store.ts +40 -515
- package/src/utils/ifcConfig.ts +82 -0
- package/src/utils/localParsingUtils.ts +287 -0
- package/src/utils/serverDataModel.ts +783 -0
- package/src/utils/spatialHierarchy.ts +283 -0
- package/src/utils/viewportUtils.ts +334 -0
- package/src/vite-env.d.ts +23 -0
- package/src/webgpu-types.d.ts +128 -0
- package/src-tauri/Cargo.toml +29 -0
- package/src-tauri/build.rs +7 -0
- package/src-tauri/capabilities/default.json +18 -0
- package/src-tauri/icons/128x128.png +0 -0
- package/src-tauri/icons/128x128@2x.png +0 -0
- package/src-tauri/icons/32x32.png +0 -0
- package/src-tauri/icons/Square107x107Logo.png +0 -0
- package/src-tauri/icons/Square142x142Logo.png +0 -0
- package/src-tauri/icons/Square150x150Logo.png +0 -0
- package/src-tauri/icons/Square284x284Logo.png +0 -0
- package/src-tauri/icons/Square30x30Logo.png +0 -0
- package/src-tauri/icons/Square310x310Logo.png +0 -0
- package/src-tauri/icons/Square44x44Logo.png +0 -0
- package/src-tauri/icons/Square71x71Logo.png +0 -0
- package/src-tauri/icons/Square89x89Logo.png +0 -0
- package/src-tauri/icons/StoreLogo.png +0 -0
- package/src-tauri/icons/icon.icns +0 -0
- package/src-tauri/icons/icon.ico +0 -0
- package/src-tauri/icons/icon.png +0 -0
- package/src-tauri/src/lib.rs +21 -0
- package/src-tauri/src/main.rs +10 -0
- package/src-tauri/tauri.conf.json +39 -0
- package/vite.config.ts +174 -26
- package/public/ifc-lite_bg.wasm +0 -0
- package/public/web-ifc.wasm +0 -0
- package/src/components/Viewport.tsx +0 -723
- package/src/components/viewer/BoxSelectionOverlay.tsx +0 -53
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Data Connector UI - Import data from CSV files and map to IFC properties
|
|
7
|
+
*
|
|
8
|
+
* Full integration with CsvConnector from @ifc-lite/mutations
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
|
12
|
+
import {
|
|
13
|
+
Upload,
|
|
14
|
+
FileSpreadsheet,
|
|
15
|
+
Link2,
|
|
16
|
+
ArrowRight,
|
|
17
|
+
Check,
|
|
18
|
+
AlertCircle,
|
|
19
|
+
Loader2,
|
|
20
|
+
Trash2,
|
|
21
|
+
Plus,
|
|
22
|
+
Eye,
|
|
23
|
+
Play,
|
|
24
|
+
Wand2,
|
|
25
|
+
} from 'lucide-react';
|
|
26
|
+
import { Button } from '@/components/ui/button';
|
|
27
|
+
import { Input } from '@/components/ui/input';
|
|
28
|
+
import { Label } from '@/components/ui/label';
|
|
29
|
+
import { Badge } from '@/components/ui/badge';
|
|
30
|
+
import {
|
|
31
|
+
Select,
|
|
32
|
+
SelectContent,
|
|
33
|
+
SelectItem,
|
|
34
|
+
SelectTrigger,
|
|
35
|
+
SelectValue,
|
|
36
|
+
} from '@/components/ui/select';
|
|
37
|
+
import {
|
|
38
|
+
Dialog,
|
|
39
|
+
DialogContent,
|
|
40
|
+
DialogDescription,
|
|
41
|
+
DialogFooter,
|
|
42
|
+
DialogHeader,
|
|
43
|
+
DialogTitle,
|
|
44
|
+
DialogTrigger,
|
|
45
|
+
} from '@/components/ui/dialog';
|
|
46
|
+
import {
|
|
47
|
+
Alert,
|
|
48
|
+
AlertDescription,
|
|
49
|
+
AlertTitle,
|
|
50
|
+
} from '@/components/ui/alert';
|
|
51
|
+
import {
|
|
52
|
+
Table,
|
|
53
|
+
TableBody,
|
|
54
|
+
TableCell,
|
|
55
|
+
TableHead,
|
|
56
|
+
TableHeader,
|
|
57
|
+
TableRow,
|
|
58
|
+
} from '@/components/ui/table';
|
|
59
|
+
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
60
|
+
import { Separator } from '@/components/ui/separator';
|
|
61
|
+
import { useViewerStore } from '@/store';
|
|
62
|
+
import { useIfc } from '@/hooks/useIfc';
|
|
63
|
+
import { PropertyValueType } from '@ifc-lite/data';
|
|
64
|
+
import {
|
|
65
|
+
CsvConnector,
|
|
66
|
+
MutablePropertyView,
|
|
67
|
+
type CsvRow,
|
|
68
|
+
type MatchStrategy,
|
|
69
|
+
type PropertyMapping,
|
|
70
|
+
type DataMapping,
|
|
71
|
+
type MatchResult,
|
|
72
|
+
type ImportStats,
|
|
73
|
+
} from '@ifc-lite/mutations';
|
|
74
|
+
import { extractPropertiesOnDemand, type IfcDataStore } from '@ifc-lite/parser';
|
|
75
|
+
|
|
76
|
+
type MatchType = 'globalId' | 'expressId' | 'name' | 'property';
|
|
77
|
+
|
|
78
|
+
interface DataConnectorProps {
|
|
79
|
+
trigger?: React.ReactNode;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface CsvColumn {
|
|
83
|
+
name: string;
|
|
84
|
+
sampleValues: string[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface MappingRow {
|
|
88
|
+
id: string;
|
|
89
|
+
sourceColumn: string;
|
|
90
|
+
targetPset: string;
|
|
91
|
+
targetProperty: string;
|
|
92
|
+
valueType: PropertyValueType;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function DataConnector({ trigger }: DataConnectorProps) {
|
|
96
|
+
const { models } = useIfc();
|
|
97
|
+
const getMutationView = useViewerStore((s) => s.getMutationView);
|
|
98
|
+
const registerMutationView = useViewerStore((s) => s.registerMutationView);
|
|
99
|
+
// Also get legacy single-model state for backward compatibility
|
|
100
|
+
const legacyIfcDataStore = useViewerStore((s) => s.ifcDataStore);
|
|
101
|
+
const legacyGeometryResult = useViewerStore((s) => s.geometryResult);
|
|
102
|
+
|
|
103
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
104
|
+
const [open, setOpen] = useState(false);
|
|
105
|
+
const [selectedModelId, setSelectedModelId] = useState<string>('');
|
|
106
|
+
|
|
107
|
+
// Raw CSV content
|
|
108
|
+
const [csvContent, setCsvContent] = useState<string>('');
|
|
109
|
+
const [fileName, setFileName] = useState<string>('');
|
|
110
|
+
|
|
111
|
+
// Parsed CSV data for preview
|
|
112
|
+
const [parsedRows, setParsedRows] = useState<CsvRow[]>([]);
|
|
113
|
+
const [csvColumns, setCsvColumns] = useState<CsvColumn[]>([]);
|
|
114
|
+
|
|
115
|
+
// Matching configuration
|
|
116
|
+
const [matchType, setMatchType] = useState<MatchType>('globalId');
|
|
117
|
+
const [matchColumn, setMatchColumn] = useState<string>('');
|
|
118
|
+
const [matchPset, setMatchPset] = useState<string>('');
|
|
119
|
+
const [matchProp, setMatchProp] = useState<string>('');
|
|
120
|
+
|
|
121
|
+
// Property mappings
|
|
122
|
+
const [mappings, setMappings] = useState<MappingRow[]>([]);
|
|
123
|
+
|
|
124
|
+
// Results
|
|
125
|
+
const [matchResults, setMatchResults] = useState<MatchResult[] | null>(null);
|
|
126
|
+
const [importStats, setImportStats] = useState<ImportStats | null>(null);
|
|
127
|
+
const [isProcessing, setIsProcessing] = useState(false);
|
|
128
|
+
const [error, setError] = useState<string | null>(null);
|
|
129
|
+
|
|
130
|
+
// Get list of models - includes both federated models and legacy single-model
|
|
131
|
+
const modelList = useMemo(() => {
|
|
132
|
+
const list = Array.from(models.values()).map((m) => ({
|
|
133
|
+
id: m.id,
|
|
134
|
+
name: m.name,
|
|
135
|
+
}));
|
|
136
|
+
|
|
137
|
+
// If no models in Map but legacy data exists, add a synthetic entry
|
|
138
|
+
if (list.length === 0 && legacyIfcDataStore) {
|
|
139
|
+
list.push({
|
|
140
|
+
id: '__legacy__',
|
|
141
|
+
name: 'Current Model',
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return list;
|
|
146
|
+
}, [models, legacyIfcDataStore]);
|
|
147
|
+
|
|
148
|
+
// Get selected model's data - supports both federated and legacy mode
|
|
149
|
+
const selectedModel = useMemo(() => {
|
|
150
|
+
if (selectedModelId === '__legacy__' && legacyIfcDataStore && legacyGeometryResult) {
|
|
151
|
+
// Return a synthetic FederatedModel-like object for legacy mode
|
|
152
|
+
return {
|
|
153
|
+
id: '__legacy__',
|
|
154
|
+
name: 'Current Model',
|
|
155
|
+
ifcDataStore: legacyIfcDataStore,
|
|
156
|
+
geometryResult: legacyGeometryResult,
|
|
157
|
+
visible: true,
|
|
158
|
+
collapsed: false,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
return models.get(selectedModelId);
|
|
162
|
+
}, [models, selectedModelId, legacyIfcDataStore, legacyGeometryResult]);
|
|
163
|
+
|
|
164
|
+
// Auto-select first model
|
|
165
|
+
useMemo(() => {
|
|
166
|
+
if (modelList.length > 0 && !selectedModelId) {
|
|
167
|
+
setSelectedModelId(modelList[0].id);
|
|
168
|
+
}
|
|
169
|
+
}, [modelList, selectedModelId]);
|
|
170
|
+
|
|
171
|
+
// Ensure mutation view exists for selected model
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (!selectedModel?.ifcDataStore || !selectedModelId) return;
|
|
174
|
+
|
|
175
|
+
// Check if mutation view already exists
|
|
176
|
+
let mutationView = getMutationView(selectedModelId);
|
|
177
|
+
if (mutationView) return;
|
|
178
|
+
|
|
179
|
+
// Create new mutation view with on-demand property extractor
|
|
180
|
+
const dataStore = selectedModel.ifcDataStore;
|
|
181
|
+
mutationView = new MutablePropertyView(dataStore.properties || null, selectedModelId);
|
|
182
|
+
|
|
183
|
+
// Set up on-demand property extraction if the data store supports it
|
|
184
|
+
if (dataStore.onDemandPropertyMap && dataStore.source?.length > 0) {
|
|
185
|
+
mutationView.setOnDemandExtractor((entityId: number) => {
|
|
186
|
+
return extractPropertiesOnDemand(dataStore as IfcDataStore, entityId);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Register the mutation view
|
|
191
|
+
registerMutationView(selectedModelId, mutationView);
|
|
192
|
+
}, [selectedModel, selectedModelId, getMutationView, registerMutationView]);
|
|
193
|
+
|
|
194
|
+
// Create CsvConnector instance
|
|
195
|
+
const csvConnector = useMemo(() => {
|
|
196
|
+
if (!selectedModel?.ifcDataStore) return null;
|
|
197
|
+
|
|
198
|
+
const mutationView = getMutationView(selectedModelId);
|
|
199
|
+
if (!mutationView) return null;
|
|
200
|
+
|
|
201
|
+
const dataStore = selectedModel.ifcDataStore;
|
|
202
|
+
|
|
203
|
+
return new CsvConnector(
|
|
204
|
+
dataStore.entities,
|
|
205
|
+
mutationView,
|
|
206
|
+
dataStore.strings || null
|
|
207
|
+
);
|
|
208
|
+
}, [selectedModel, selectedModelId, getMutationView]);
|
|
209
|
+
|
|
210
|
+
// Parse CSV file
|
|
211
|
+
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
212
|
+
const file = e.target.files?.[0];
|
|
213
|
+
if (!file) return;
|
|
214
|
+
|
|
215
|
+
setFileName(file.name);
|
|
216
|
+
setMatchResults(null);
|
|
217
|
+
setImportStats(null);
|
|
218
|
+
setError(null);
|
|
219
|
+
|
|
220
|
+
const reader = new FileReader();
|
|
221
|
+
reader.onload = (event) => {
|
|
222
|
+
const text = event.target?.result as string;
|
|
223
|
+
if (!text) return;
|
|
224
|
+
|
|
225
|
+
setCsvContent(text);
|
|
226
|
+
|
|
227
|
+
// Use CsvConnector to parse if available, otherwise do basic parsing for preview
|
|
228
|
+
if (csvConnector) {
|
|
229
|
+
try {
|
|
230
|
+
const rows = csvConnector.parse(text);
|
|
231
|
+
setParsedRows(rows);
|
|
232
|
+
|
|
233
|
+
// Extract column names and sample values
|
|
234
|
+
if (rows.length > 0) {
|
|
235
|
+
const headers = Object.keys(rows[0]);
|
|
236
|
+
const columns: CsvColumn[] = headers.map((name) => ({
|
|
237
|
+
name,
|
|
238
|
+
sampleValues: rows.slice(0, 3).map((row) => row[name] || ''),
|
|
239
|
+
}));
|
|
240
|
+
setCsvColumns(columns);
|
|
241
|
+
|
|
242
|
+
// Auto-detect match column
|
|
243
|
+
const globalIdCol = columns.find(
|
|
244
|
+
(c) =>
|
|
245
|
+
c.name.toLowerCase().includes('globalid') ||
|
|
246
|
+
c.name.toLowerCase().includes('guid')
|
|
247
|
+
);
|
|
248
|
+
if (globalIdCol) {
|
|
249
|
+
setMatchColumn(globalIdCol.name);
|
|
250
|
+
setMatchType('globalId');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Auto-detect property mappings
|
|
254
|
+
const autoMappings = csvConnector.autoDetectMappings(headers);
|
|
255
|
+
const mappingRows: MappingRow[] = autoMappings.map((m, idx) => ({
|
|
256
|
+
id: `auto_${idx}_${Date.now()}`,
|
|
257
|
+
sourceColumn: m.sourceColumn,
|
|
258
|
+
targetPset: m.targetPset,
|
|
259
|
+
targetProperty: m.targetProperty,
|
|
260
|
+
valueType: m.valueType,
|
|
261
|
+
}));
|
|
262
|
+
|
|
263
|
+
// Filter out ID columns from auto mappings
|
|
264
|
+
const filteredMappings = mappingRows.filter(
|
|
265
|
+
(m) =>
|
|
266
|
+
!m.sourceColumn.toLowerCase().includes('globalid') &&
|
|
267
|
+
!m.sourceColumn.toLowerCase().includes('expressid') &&
|
|
268
|
+
!m.sourceColumn.toLowerCase().includes('guid') &&
|
|
269
|
+
m.sourceColumn.toLowerCase() !== 'id'
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
if (filteredMappings.length > 0) {
|
|
273
|
+
setMappings(filteredMappings);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
} catch (err) {
|
|
277
|
+
setError(`Failed to parse CSV: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
// Basic parsing for preview before model selected
|
|
281
|
+
const lines = text.split('\n').filter((line) => line.trim());
|
|
282
|
+
if (lines.length < 2) {
|
|
283
|
+
setError('CSV must have at least a header row and one data row');
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, ''));
|
|
288
|
+
const columns: CsvColumn[] = headers.map((name, idx) => ({
|
|
289
|
+
name,
|
|
290
|
+
sampleValues: lines
|
|
291
|
+
.slice(1, 4)
|
|
292
|
+
.map((line) => {
|
|
293
|
+
const values = line.split(',');
|
|
294
|
+
return values[idx]?.trim().replace(/^"|"$/g, '') || '';
|
|
295
|
+
}),
|
|
296
|
+
}));
|
|
297
|
+
setCsvColumns(columns);
|
|
298
|
+
|
|
299
|
+
// Auto-detect match column
|
|
300
|
+
const globalIdCol = columns.find(
|
|
301
|
+
(c) =>
|
|
302
|
+
c.name.toLowerCase().includes('globalid') ||
|
|
303
|
+
c.name.toLowerCase().includes('guid')
|
|
304
|
+
);
|
|
305
|
+
if (globalIdCol) {
|
|
306
|
+
setMatchColumn(globalIdCol.name);
|
|
307
|
+
setMatchType('globalId');
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
reader.readAsText(file);
|
|
313
|
+
e.target.value = ''; // Reset input
|
|
314
|
+
}, [csvConnector]);
|
|
315
|
+
|
|
316
|
+
// Re-parse when model changes and we have content
|
|
317
|
+
const handleModelChange = useCallback((modelId: string) => {
|
|
318
|
+
setSelectedModelId(modelId);
|
|
319
|
+
setMatchResults(null);
|
|
320
|
+
setImportStats(null);
|
|
321
|
+
setError(null);
|
|
322
|
+
}, []);
|
|
323
|
+
|
|
324
|
+
// Add a mapping row
|
|
325
|
+
const addMapping = useCallback(() => {
|
|
326
|
+
setMappings((prev) => [
|
|
327
|
+
...prev,
|
|
328
|
+
{
|
|
329
|
+
id: `mapping_${Date.now()}`,
|
|
330
|
+
sourceColumn: '',
|
|
331
|
+
targetPset: 'Pset_Custom',
|
|
332
|
+
targetProperty: '',
|
|
333
|
+
valueType: PropertyValueType.String,
|
|
334
|
+
},
|
|
335
|
+
]);
|
|
336
|
+
}, []);
|
|
337
|
+
|
|
338
|
+
// Remove a mapping
|
|
339
|
+
const removeMapping = useCallback((id: string) => {
|
|
340
|
+
setMappings((prev) => prev.filter((m) => m.id !== id));
|
|
341
|
+
}, []);
|
|
342
|
+
|
|
343
|
+
// Update a mapping
|
|
344
|
+
const updateMapping = useCallback(
|
|
345
|
+
(id: string, field: keyof MappingRow, value: string | number) => {
|
|
346
|
+
setMappings((prev) =>
|
|
347
|
+
prev.map((m) => (m.id === id ? { ...m, [field]: value } : m))
|
|
348
|
+
);
|
|
349
|
+
},
|
|
350
|
+
[]
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
// Auto-detect mappings
|
|
354
|
+
const handleAutoDetect = useCallback(() => {
|
|
355
|
+
if (!csvConnector || csvColumns.length === 0) return;
|
|
356
|
+
|
|
357
|
+
const headers = csvColumns.map((c) => c.name);
|
|
358
|
+
const autoMappings = csvConnector.autoDetectMappings(headers);
|
|
359
|
+
|
|
360
|
+
const mappingRows: MappingRow[] = autoMappings
|
|
361
|
+
.filter(
|
|
362
|
+
(m) =>
|
|
363
|
+
!m.sourceColumn.toLowerCase().includes('globalid') &&
|
|
364
|
+
!m.sourceColumn.toLowerCase().includes('expressid') &&
|
|
365
|
+
!m.sourceColumn.toLowerCase().includes('guid') &&
|
|
366
|
+
m.sourceColumn.toLowerCase() !== 'id'
|
|
367
|
+
)
|
|
368
|
+
.map((m, idx) => ({
|
|
369
|
+
id: `auto_${idx}_${Date.now()}`,
|
|
370
|
+
sourceColumn: m.sourceColumn,
|
|
371
|
+
targetPset: m.targetPset,
|
|
372
|
+
targetProperty: m.targetProperty,
|
|
373
|
+
valueType: m.valueType,
|
|
374
|
+
}));
|
|
375
|
+
|
|
376
|
+
setMappings(mappingRows);
|
|
377
|
+
}, [csvConnector, csvColumns]);
|
|
378
|
+
|
|
379
|
+
// Build DataMapping from UI state
|
|
380
|
+
const buildDataMapping = useCallback((): DataMapping | null => {
|
|
381
|
+
if (!matchColumn) return null;
|
|
382
|
+
|
|
383
|
+
const matchStrategy: MatchStrategy =
|
|
384
|
+
matchType === 'property'
|
|
385
|
+
? { type: 'property', psetName: matchPset, propName: matchProp, column: matchColumn }
|
|
386
|
+
: { type: matchType, column: matchColumn };
|
|
387
|
+
|
|
388
|
+
const propertyMappings: PropertyMapping[] = mappings
|
|
389
|
+
.filter((m) => m.sourceColumn && m.targetProperty)
|
|
390
|
+
.map((m) => ({
|
|
391
|
+
sourceColumn: m.sourceColumn,
|
|
392
|
+
targetPset: m.targetPset,
|
|
393
|
+
targetProperty: m.targetProperty,
|
|
394
|
+
valueType: m.valueType,
|
|
395
|
+
}));
|
|
396
|
+
|
|
397
|
+
return { matchStrategy, propertyMappings };
|
|
398
|
+
}, [matchColumn, matchType, matchPset, matchProp, mappings]);
|
|
399
|
+
|
|
400
|
+
// Preview matches using CsvConnector.preview
|
|
401
|
+
const handlePreview = useCallback(() => {
|
|
402
|
+
if (!csvConnector || !csvContent || !matchColumn) return;
|
|
403
|
+
|
|
404
|
+
setIsProcessing(true);
|
|
405
|
+
setMatchResults(null);
|
|
406
|
+
setImportStats(null);
|
|
407
|
+
setError(null);
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
const dataMapping = buildDataMapping();
|
|
411
|
+
if (!dataMapping) {
|
|
412
|
+
setError('Invalid mapping configuration');
|
|
413
|
+
setIsProcessing(false);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Use CsvConnector preview method
|
|
418
|
+
const preview = csvConnector.preview(csvContent, dataMapping);
|
|
419
|
+
|
|
420
|
+
setParsedRows(preview.rows);
|
|
421
|
+
setMatchResults(preview.matches);
|
|
422
|
+
} catch (err) {
|
|
423
|
+
console.error('Preview failed:', err);
|
|
424
|
+
setError(`Preview failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
|
425
|
+
} finally {
|
|
426
|
+
setIsProcessing(false);
|
|
427
|
+
}
|
|
428
|
+
}, [csvConnector, csvContent, matchColumn, buildDataMapping]);
|
|
429
|
+
|
|
430
|
+
// Import using CsvConnector.import
|
|
431
|
+
const handleImport = useCallback(() => {
|
|
432
|
+
if (!csvConnector || !csvContent) return;
|
|
433
|
+
|
|
434
|
+
setIsProcessing(true);
|
|
435
|
+
setImportStats(null);
|
|
436
|
+
setError(null);
|
|
437
|
+
|
|
438
|
+
try {
|
|
439
|
+
const dataMapping = buildDataMapping();
|
|
440
|
+
if (!dataMapping) {
|
|
441
|
+
setError('Invalid mapping configuration');
|
|
442
|
+
setIsProcessing(false);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Use CsvConnector import method - this creates mutations via the MutablePropertyView
|
|
447
|
+
const stats = csvConnector.import(csvContent, dataMapping);
|
|
448
|
+
|
|
449
|
+
setImportStats(stats);
|
|
450
|
+
|
|
451
|
+
if (stats.errors.length > 0) {
|
|
452
|
+
setError(stats.errors.join('\n'));
|
|
453
|
+
}
|
|
454
|
+
} catch (err) {
|
|
455
|
+
console.error('Import failed:', err);
|
|
456
|
+
setError(`Import failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
|
457
|
+
} finally {
|
|
458
|
+
setIsProcessing(false);
|
|
459
|
+
}
|
|
460
|
+
}, [csvConnector, csvContent, buildDataMapping]);
|
|
461
|
+
|
|
462
|
+
// Stats from match results
|
|
463
|
+
const matchStats = useMemo(() => {
|
|
464
|
+
if (!matchResults) return null;
|
|
465
|
+
const matched = matchResults.filter((r) => r.matchedEntityIds.length > 0).length;
|
|
466
|
+
const unmatched = matchResults.filter((r) => r.matchedEntityIds.length === 0).length;
|
|
467
|
+
const multiMatch = matchResults.filter((r) => r.matchedEntityIds.length > 1).length;
|
|
468
|
+
const highConfidence = matchResults.filter((r) => r.confidence === 1).length;
|
|
469
|
+
return { matched, unmatched, multiMatch, highConfidence, total: matchResults.length };
|
|
470
|
+
}, [matchResults]);
|
|
471
|
+
|
|
472
|
+
// Convert parsed rows to array for table display
|
|
473
|
+
const previewData = useMemo(() => {
|
|
474
|
+
if (parsedRows.length === 0) return [];
|
|
475
|
+
return parsedRows.slice(0, 5).map((row) => {
|
|
476
|
+
return csvColumns.map((col) => row[col.name] || '');
|
|
477
|
+
});
|
|
478
|
+
}, [parsedRows, csvColumns]);
|
|
479
|
+
|
|
480
|
+
return (
|
|
481
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
482
|
+
<DialogTrigger asChild>
|
|
483
|
+
{trigger || (
|
|
484
|
+
<Button variant="outline" size="sm">
|
|
485
|
+
<Upload className="h-4 w-4 mr-2" />
|
|
486
|
+
Import Data
|
|
487
|
+
</Button>
|
|
488
|
+
)}
|
|
489
|
+
</DialogTrigger>
|
|
490
|
+
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
|
|
491
|
+
<DialogHeader>
|
|
492
|
+
<DialogTitle className="flex items-center gap-2">
|
|
493
|
+
<FileSpreadsheet className="h-5 w-5" />
|
|
494
|
+
Import External Data
|
|
495
|
+
</DialogTitle>
|
|
496
|
+
<DialogDescription>
|
|
497
|
+
Import property data from CSV files and map to IFC entities using CsvConnector
|
|
498
|
+
</DialogDescription>
|
|
499
|
+
</DialogHeader>
|
|
500
|
+
|
|
501
|
+
<div className="space-y-6 py-4">
|
|
502
|
+
{/* Model selector - first so CsvConnector can be created */}
|
|
503
|
+
<div className="space-y-2">
|
|
504
|
+
<Label className="text-sm font-medium">Target Model</Label>
|
|
505
|
+
<Select value={selectedModelId} onValueChange={handleModelChange}>
|
|
506
|
+
<SelectTrigger>
|
|
507
|
+
<SelectValue placeholder="Select a model" />
|
|
508
|
+
</SelectTrigger>
|
|
509
|
+
<SelectContent>
|
|
510
|
+
{modelList.map((m) => (
|
|
511
|
+
<SelectItem key={m.id} value={m.id}>
|
|
512
|
+
{m.name}
|
|
513
|
+
</SelectItem>
|
|
514
|
+
))}
|
|
515
|
+
</SelectContent>
|
|
516
|
+
</Select>
|
|
517
|
+
{selectedModelId && !csvConnector && (
|
|
518
|
+
<p className="text-xs text-amber-600">
|
|
519
|
+
Note: MutationView not available for this model. Some features may be limited.
|
|
520
|
+
</p>
|
|
521
|
+
)}
|
|
522
|
+
</div>
|
|
523
|
+
|
|
524
|
+
{/* File Upload */}
|
|
525
|
+
<div className="space-y-2">
|
|
526
|
+
<Label className="text-sm font-medium">CSV File</Label>
|
|
527
|
+
<input
|
|
528
|
+
ref={fileInputRef}
|
|
529
|
+
type="file"
|
|
530
|
+
accept=".csv"
|
|
531
|
+
onChange={handleFileSelect}
|
|
532
|
+
className="hidden"
|
|
533
|
+
/>
|
|
534
|
+
<div className="flex items-center gap-2">
|
|
535
|
+
<Button variant="outline" onClick={() => fileInputRef.current?.click()}>
|
|
536
|
+
<Upload className="h-4 w-4 mr-2" />
|
|
537
|
+
Choose File
|
|
538
|
+
</Button>
|
|
539
|
+
{fileName && <Badge variant="secondary">{fileName}</Badge>}
|
|
540
|
+
</div>
|
|
541
|
+
</div>
|
|
542
|
+
|
|
543
|
+
{csvColumns.length > 0 && (
|
|
544
|
+
<>
|
|
545
|
+
<Separator />
|
|
546
|
+
|
|
547
|
+
{/* CSV Preview */}
|
|
548
|
+
<div className="space-y-2">
|
|
549
|
+
<Label className="text-sm font-medium">Data Preview</Label>
|
|
550
|
+
<ScrollArea className="h-32 border rounded-md">
|
|
551
|
+
<Table>
|
|
552
|
+
<TableHeader>
|
|
553
|
+
<TableRow>
|
|
554
|
+
{csvColumns.map((col) => (
|
|
555
|
+
<TableHead key={col.name} className="text-xs whitespace-nowrap">
|
|
556
|
+
{col.name}
|
|
557
|
+
</TableHead>
|
|
558
|
+
))}
|
|
559
|
+
</TableRow>
|
|
560
|
+
</TableHeader>
|
|
561
|
+
<TableBody>
|
|
562
|
+
{previewData.map((row, rowIdx) => (
|
|
563
|
+
<TableRow key={rowIdx}>
|
|
564
|
+
{row.map((cell, cellIdx) => (
|
|
565
|
+
<TableCell key={cellIdx} className="text-xs py-1">
|
|
566
|
+
{cell || '—'}
|
|
567
|
+
</TableCell>
|
|
568
|
+
))}
|
|
569
|
+
</TableRow>
|
|
570
|
+
))}
|
|
571
|
+
</TableBody>
|
|
572
|
+
</Table>
|
|
573
|
+
</ScrollArea>
|
|
574
|
+
<p className="text-xs text-muted-foreground">
|
|
575
|
+
{parsedRows.length > 0
|
|
576
|
+
? `${parsedRows.length} rows parsed`
|
|
577
|
+
: `${csvColumns[0]?.sampleValues.length || 0} sample rows`}
|
|
578
|
+
</p>
|
|
579
|
+
</div>
|
|
580
|
+
|
|
581
|
+
<Separator />
|
|
582
|
+
|
|
583
|
+
{/* Matching Configuration */}
|
|
584
|
+
<div className="space-y-4">
|
|
585
|
+
<Label className="text-sm font-medium flex items-center gap-2">
|
|
586
|
+
<Link2 className="h-4 w-4" />
|
|
587
|
+
Match Configuration
|
|
588
|
+
</Label>
|
|
589
|
+
|
|
590
|
+
<div className="grid grid-cols-2 gap-4">
|
|
591
|
+
<div className="space-y-2">
|
|
592
|
+
<Label className="text-xs text-muted-foreground">Match By</Label>
|
|
593
|
+
<Select value={matchType} onValueChange={(v) => setMatchType(v as MatchType)}>
|
|
594
|
+
<SelectTrigger>
|
|
595
|
+
<SelectValue />
|
|
596
|
+
</SelectTrigger>
|
|
597
|
+
<SelectContent>
|
|
598
|
+
<SelectItem value="globalId">GlobalId</SelectItem>
|
|
599
|
+
<SelectItem value="expressId">EXPRESS ID</SelectItem>
|
|
600
|
+
<SelectItem value="name">Entity Name</SelectItem>
|
|
601
|
+
<SelectItem value="property">Property Value</SelectItem>
|
|
602
|
+
</SelectContent>
|
|
603
|
+
</Select>
|
|
604
|
+
</div>
|
|
605
|
+
|
|
606
|
+
<div className="space-y-2">
|
|
607
|
+
<Label className="text-xs text-muted-foreground">Match Column</Label>
|
|
608
|
+
<Select value={matchColumn} onValueChange={setMatchColumn}>
|
|
609
|
+
<SelectTrigger>
|
|
610
|
+
<SelectValue placeholder="Select column" />
|
|
611
|
+
</SelectTrigger>
|
|
612
|
+
<SelectContent>
|
|
613
|
+
{csvColumns.map((col) => (
|
|
614
|
+
<SelectItem key={col.name} value={col.name}>
|
|
615
|
+
{col.name}
|
|
616
|
+
{col.sampleValues[0] && (
|
|
617
|
+
<span className="ml-2 text-muted-foreground">
|
|
618
|
+
(e.g., {col.sampleValues[0].slice(0, 20)})
|
|
619
|
+
</span>
|
|
620
|
+
)}
|
|
621
|
+
</SelectItem>
|
|
622
|
+
))}
|
|
623
|
+
</SelectContent>
|
|
624
|
+
</Select>
|
|
625
|
+
</div>
|
|
626
|
+
</div>
|
|
627
|
+
|
|
628
|
+
{matchType === 'property' && (
|
|
629
|
+
<div className="grid grid-cols-2 gap-4">
|
|
630
|
+
<div className="space-y-2">
|
|
631
|
+
<Label className="text-xs text-muted-foreground">Property Set</Label>
|
|
632
|
+
<Input
|
|
633
|
+
value={matchPset}
|
|
634
|
+
onChange={(e) => setMatchPset(e.target.value)}
|
|
635
|
+
placeholder="e.g., Pset_WallCommon"
|
|
636
|
+
/>
|
|
637
|
+
</div>
|
|
638
|
+
<div className="space-y-2">
|
|
639
|
+
<Label className="text-xs text-muted-foreground">Property Name</Label>
|
|
640
|
+
<Input
|
|
641
|
+
value={matchProp}
|
|
642
|
+
onChange={(e) => setMatchProp(e.target.value)}
|
|
643
|
+
placeholder="e.g., Reference"
|
|
644
|
+
/>
|
|
645
|
+
</div>
|
|
646
|
+
</div>
|
|
647
|
+
)}
|
|
648
|
+
</div>
|
|
649
|
+
|
|
650
|
+
<Separator />
|
|
651
|
+
|
|
652
|
+
{/* Property Mappings */}
|
|
653
|
+
<div className="space-y-4">
|
|
654
|
+
<div className="flex items-center justify-between">
|
|
655
|
+
<Label className="text-sm font-medium">Property Mappings</Label>
|
|
656
|
+
<div className="flex items-center gap-2">
|
|
657
|
+
{csvConnector && (
|
|
658
|
+
<Button variant="ghost" size="sm" onClick={handleAutoDetect}>
|
|
659
|
+
<Wand2 className="h-3 w-3 mr-1" />
|
|
660
|
+
Auto-detect
|
|
661
|
+
</Button>
|
|
662
|
+
)}
|
|
663
|
+
<Button variant="ghost" size="sm" onClick={addMapping}>
|
|
664
|
+
<Plus className="h-3 w-3 mr-1" />
|
|
665
|
+
Add Mapping
|
|
666
|
+
</Button>
|
|
667
|
+
</div>
|
|
668
|
+
</div>
|
|
669
|
+
|
|
670
|
+
{mappings.length === 0 ? (
|
|
671
|
+
<p className="text-sm text-muted-foreground text-center py-4">
|
|
672
|
+
Add mappings to import column values as IFC properties
|
|
673
|
+
</p>
|
|
674
|
+
) : (
|
|
675
|
+
<div className="space-y-2">
|
|
676
|
+
{mappings.map((mapping) => (
|
|
677
|
+
<div
|
|
678
|
+
key={mapping.id}
|
|
679
|
+
className="flex items-center gap-2 p-2 border rounded-md bg-muted/30"
|
|
680
|
+
>
|
|
681
|
+
<Select
|
|
682
|
+
value={mapping.sourceColumn}
|
|
683
|
+
onValueChange={(v) => updateMapping(mapping.id, 'sourceColumn', v)}
|
|
684
|
+
>
|
|
685
|
+
<SelectTrigger className="h-8 w-32">
|
|
686
|
+
<SelectValue placeholder="Column" />
|
|
687
|
+
</SelectTrigger>
|
|
688
|
+
<SelectContent>
|
|
689
|
+
{csvColumns.map((col) => (
|
|
690
|
+
<SelectItem key={col.name} value={col.name}>
|
|
691
|
+
{col.name}
|
|
692
|
+
</SelectItem>
|
|
693
|
+
))}
|
|
694
|
+
</SelectContent>
|
|
695
|
+
</Select>
|
|
696
|
+
|
|
697
|
+
<ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
698
|
+
|
|
699
|
+
<Input
|
|
700
|
+
placeholder="Pset name"
|
|
701
|
+
value={mapping.targetPset}
|
|
702
|
+
onChange={(e) => updateMapping(mapping.id, 'targetPset', e.target.value)}
|
|
703
|
+
className="h-8 text-xs w-32"
|
|
704
|
+
/>
|
|
705
|
+
|
|
706
|
+
<Input
|
|
707
|
+
placeholder="Property"
|
|
708
|
+
value={mapping.targetProperty}
|
|
709
|
+
onChange={(e) =>
|
|
710
|
+
updateMapping(mapping.id, 'targetProperty', e.target.value)
|
|
711
|
+
}
|
|
712
|
+
className="h-8 text-xs flex-1"
|
|
713
|
+
/>
|
|
714
|
+
|
|
715
|
+
<Select
|
|
716
|
+
value={mapping.valueType.toString()}
|
|
717
|
+
onValueChange={(v) => updateMapping(mapping.id, 'valueType', parseInt(v))}
|
|
718
|
+
>
|
|
719
|
+
<SelectTrigger className="h-8 w-24">
|
|
720
|
+
<SelectValue />
|
|
721
|
+
</SelectTrigger>
|
|
722
|
+
<SelectContent>
|
|
723
|
+
<SelectItem value={PropertyValueType.String.toString()}>
|
|
724
|
+
String
|
|
725
|
+
</SelectItem>
|
|
726
|
+
<SelectItem value={PropertyValueType.Real.toString()}>Real</SelectItem>
|
|
727
|
+
<SelectItem value={PropertyValueType.Integer.toString()}>
|
|
728
|
+
Integer
|
|
729
|
+
</SelectItem>
|
|
730
|
+
<SelectItem value={PropertyValueType.Boolean.toString()}>
|
|
731
|
+
Boolean
|
|
732
|
+
</SelectItem>
|
|
733
|
+
</SelectContent>
|
|
734
|
+
</Select>
|
|
735
|
+
|
|
736
|
+
<Button
|
|
737
|
+
variant="ghost"
|
|
738
|
+
size="icon"
|
|
739
|
+
className="h-8 w-8"
|
|
740
|
+
onClick={() => removeMapping(mapping.id)}
|
|
741
|
+
>
|
|
742
|
+
<Trash2 className="h-3 w-3 text-destructive" />
|
|
743
|
+
</Button>
|
|
744
|
+
</div>
|
|
745
|
+
))}
|
|
746
|
+
</div>
|
|
747
|
+
)}
|
|
748
|
+
</div>
|
|
749
|
+
|
|
750
|
+
{/* Match Results */}
|
|
751
|
+
{matchStats && (
|
|
752
|
+
<Alert>
|
|
753
|
+
<Eye className="h-4 w-4" />
|
|
754
|
+
<AlertTitle>Match Results</AlertTitle>
|
|
755
|
+
<AlertDescription className="flex flex-wrap items-center gap-2">
|
|
756
|
+
<Badge variant="default">{matchStats.matched} matched</Badge>
|
|
757
|
+
<Badge variant="secondary">{matchStats.unmatched} unmatched</Badge>
|
|
758
|
+
<Badge variant="outline">{matchStats.highConfidence} high confidence</Badge>
|
|
759
|
+
{matchStats.multiMatch > 0 && (
|
|
760
|
+
<Badge variant="destructive">{matchStats.multiMatch} multi-match</Badge>
|
|
761
|
+
)}
|
|
762
|
+
</AlertDescription>
|
|
763
|
+
</Alert>
|
|
764
|
+
)}
|
|
765
|
+
|
|
766
|
+
{/* Import Stats */}
|
|
767
|
+
{importStats && (
|
|
768
|
+
<Alert variant={importStats.errors.length === 0 ? 'default' : 'destructive'}>
|
|
769
|
+
<Check className="h-4 w-4" />
|
|
770
|
+
<AlertTitle>Import Complete</AlertTitle>
|
|
771
|
+
<AlertDescription>
|
|
772
|
+
<div className="flex flex-wrap items-center gap-2 mt-1">
|
|
773
|
+
<Badge variant="default">
|
|
774
|
+
{importStats.mutationsCreated} properties updated
|
|
775
|
+
</Badge>
|
|
776
|
+
<Badge variant="secondary">{importStats.matchedRows} rows matched</Badge>
|
|
777
|
+
<Badge variant="outline">{importStats.unmatchedRows} rows unmatched</Badge>
|
|
778
|
+
</div>
|
|
779
|
+
{importStats.warnings.length > 0 && (
|
|
780
|
+
<div className="mt-2 text-xs text-amber-600">
|
|
781
|
+
{importStats.warnings.length} warning(s)
|
|
782
|
+
</div>
|
|
783
|
+
)}
|
|
784
|
+
</AlertDescription>
|
|
785
|
+
</Alert>
|
|
786
|
+
)}
|
|
787
|
+
|
|
788
|
+
{/* Error Display */}
|
|
789
|
+
{error && (
|
|
790
|
+
<Alert variant="destructive">
|
|
791
|
+
<AlertCircle className="h-4 w-4" />
|
|
792
|
+
<AlertTitle>Error</AlertTitle>
|
|
793
|
+
<AlertDescription className="whitespace-pre-wrap">{error}</AlertDescription>
|
|
794
|
+
</Alert>
|
|
795
|
+
)}
|
|
796
|
+
</>
|
|
797
|
+
)}
|
|
798
|
+
</div>
|
|
799
|
+
|
|
800
|
+
<DialogFooter className="gap-2">
|
|
801
|
+
<Button
|
|
802
|
+
variant="secondary"
|
|
803
|
+
onClick={handlePreview}
|
|
804
|
+
disabled={!csvConnector || !csvContent || !matchColumn || isProcessing}
|
|
805
|
+
>
|
|
806
|
+
{isProcessing ? (
|
|
807
|
+
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
808
|
+
) : (
|
|
809
|
+
<Eye className="h-4 w-4 mr-2" />
|
|
810
|
+
)}
|
|
811
|
+
Preview Matches
|
|
812
|
+
</Button>
|
|
813
|
+
<Button
|
|
814
|
+
onClick={handleImport}
|
|
815
|
+
disabled={
|
|
816
|
+
!csvConnector ||
|
|
817
|
+
!csvContent ||
|
|
818
|
+
!matchResults ||
|
|
819
|
+
matchStats?.matched === 0 ||
|
|
820
|
+
mappings.length === 0 ||
|
|
821
|
+
isProcessing
|
|
822
|
+
}
|
|
823
|
+
>
|
|
824
|
+
{isProcessing ? (
|
|
825
|
+
<>
|
|
826
|
+
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
827
|
+
Importing...
|
|
828
|
+
</>
|
|
829
|
+
) : (
|
|
830
|
+
<>
|
|
831
|
+
<Play className="h-4 w-4 mr-2" />
|
|
832
|
+
Import {matchStats?.matched || 0} rows
|
|
833
|
+
</>
|
|
834
|
+
)}
|
|
835
|
+
</Button>
|
|
836
|
+
</DialogFooter>
|
|
837
|
+
</DialogContent>
|
|
838
|
+
</Dialog>
|
|
839
|
+
);
|
|
840
|
+
}
|