@ifc-lite/viewer 1.16.0 → 1.17.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 (55) hide show
  1. package/.turbo/turbo-build.log +46 -0
  2. package/.turbo/turbo-typecheck.log +4 -0
  3. package/CHANGELOG.md +15 -0
  4. package/dist/assets/{Arrow.dom--gdrQd-q.js → Arrow.dom-CcoDLP6E.js} +1 -1
  5. package/dist/assets/{basketViewActivator-CI3y6VYQ.js → basketViewActivator-FtbS__bG.js} +1 -1
  6. package/dist/assets/{browser-vWDubxDI.js → browser-CXd3z0DO.js} +1 -1
  7. package/dist/assets/ifc-lite-TI3u_Zyw.js +7 -0
  8. package/dist/assets/ifc-lite_bg-DeZrXTKQ.wasm +0 -0
  9. package/dist/assets/index-Ba4eoTe7.css +1 -0
  10. package/dist/assets/{index-BImINgzG.js → index-D99fzcwI.js} +28962 -26947
  11. package/dist/assets/{index-RXIK18da.js → index-DqNiuQep.js} +4 -4
  12. package/dist/assets/{native-bridge-4rLidc3f.js → native-bridge-DjDj2M6p.js} +1 -1
  13. package/dist/assets/{wasm-bridge-BkfXfw8O.js → wasm-bridge-CDTF4ZQc.js} +1 -1
  14. package/dist/assets/workerHelpers-G7llXNMi.js +36 -0
  15. package/dist/index.html +2 -2
  16. package/package.json +15 -14
  17. package/src/components/viewer/BCFPanel.tsx +12 -0
  18. package/src/components/viewer/BulkPropertyEditor.tsx +315 -154
  19. package/src/components/viewer/CommandPalette.tsx +0 -6
  20. package/src/components/viewer/DataConnector.tsx +489 -284
  21. package/src/components/viewer/ExportDialog.tsx +66 -6
  22. package/src/components/viewer/KeyboardShortcutsDialog.tsx +44 -1
  23. package/src/components/viewer/MainToolbar.tsx +1 -5
  24. package/src/components/viewer/Viewport.tsx +42 -56
  25. package/src/components/viewer/ViewportContainer.tsx +3 -0
  26. package/src/components/viewer/ViewportOverlays.tsx +12 -10
  27. package/src/components/viewer/bcf/BCFOverlay.tsx +254 -0
  28. package/src/components/viewer/lists/ListPanel.tsx +0 -21
  29. package/src/components/viewer/lists/ListResultsTable.tsx +93 -5
  30. package/src/components/viewer/measureHandlers.ts +558 -0
  31. package/src/components/viewer/mouseHandlerTypes.ts +108 -0
  32. package/src/components/viewer/selectionHandlers.ts +86 -0
  33. package/src/components/viewer/useAnimationLoop.ts +116 -44
  34. package/src/components/viewer/useGeometryStreaming.ts +155 -367
  35. package/src/components/viewer/useKeyboardControls.ts +30 -46
  36. package/src/components/viewer/useMouseControls.ts +169 -695
  37. package/src/components/viewer/useRenderUpdates.ts +9 -59
  38. package/src/components/viewer/useTouchControls.ts +55 -40
  39. package/src/hooks/bcfIdLookup.ts +70 -0
  40. package/src/hooks/useBCF.ts +12 -31
  41. package/src/hooks/useIfcCache.ts +2 -20
  42. package/src/hooks/useIfcFederation.ts +5 -11
  43. package/src/hooks/useIfcLoader.ts +47 -56
  44. package/src/hooks/useIfcServer.ts +9 -1
  45. package/src/hooks/useKeyboardShortcuts.ts +0 -10
  46. package/src/hooks/useLatestRef.ts +24 -0
  47. package/src/sdk/adapters/export-adapter.ts +2 -2
  48. package/src/sdk/adapters/model-adapter.ts +1 -0
  49. package/src/sdk/local-backend.ts +2 -0
  50. package/src/store/basketVisibleSet.ts +12 -0
  51. package/src/store/slices/bcfSlice.ts +9 -0
  52. package/src/utils/loadingUtils.ts +46 -0
  53. package/src/utils/serverDataModel.ts +4 -3
  54. package/dist/assets/ifc-lite_bg-CyWQTvp5.wasm +0 -0
  55. package/dist/assets/index-ax1X2WPd.css +0 -1
@@ -8,7 +8,7 @@
8
8
  * Full integration with CsvConnector from @ifc-lite/mutations
9
9
  */
10
10
 
11
- import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
11
+ import { useState, useCallback, useMemo, useRef, useEffect, type DragEvent } from 'react';
12
12
  import {
13
13
  Upload,
14
14
  FileSpreadsheet,
@@ -22,6 +22,7 @@ import {
22
22
  Eye,
23
23
  Play,
24
24
  Wand2,
25
+ ChevronRight,
25
26
  } from 'lucide-react';
26
27
  import { Button } from '@/components/ui/button';
27
28
  import { Input } from '@/components/ui/input';
@@ -57,6 +58,7 @@ import {
57
58
  TableRow,
58
59
  } from '@/components/ui/table';
59
60
  import { ScrollArea } from '@/components/ui/scroll-area';
61
+ import { Progress } from '@/components/ui/progress';
60
62
  import { Separator } from '@/components/ui/separator';
61
63
  import { useViewerStore } from '@/store';
62
64
  import { useIfc } from '@/hooks/useIfc';
@@ -71,6 +73,7 @@ import {
71
73
  type DataMapping,
72
74
  type MatchResult,
73
75
  type ImportStats,
76
+ type ImportProgress,
74
77
  } from '@ifc-lite/mutations';
75
78
  import type { IfcDataStore } from '@ifc-lite/parser';
76
79
 
@@ -102,6 +105,7 @@ export function DataConnector({ trigger }: DataConnectorProps) {
102
105
  const legacyGeometryResult = useViewerStore((s) => s.geometryResult);
103
106
 
104
107
  const fileInputRef = useRef<HTMLInputElement>(null);
108
+ const scrollAreaRef = useRef<HTMLDivElement>(null);
105
109
  const [open, setOpen] = useState(false);
106
110
  const [selectedModelId, setSelectedModelId] = useState<string>('');
107
111
 
@@ -126,7 +130,10 @@ export function DataConnector({ trigger }: DataConnectorProps) {
126
130
  const [matchResults, setMatchResults] = useState<MatchResult[] | null>(null);
127
131
  const [importStats, setImportStats] = useState<ImportStats | null>(null);
128
132
  const [isProcessing, setIsProcessing] = useState(false);
133
+ const [importProgress, setImportProgress] = useState<ImportProgress | null>(null);
129
134
  const [error, setError] = useState<string | null>(null);
135
+ // Track whether config changed since last import (disables button after success)
136
+ const [importDirty, setImportDirty] = useState(true);
130
137
 
131
138
  // Get list of models - includes both federated models and legacy single-model
132
139
  const modelList = useMemo(() => {
@@ -212,6 +219,7 @@ export function DataConnector({ trigger }: DataConnectorProps) {
212
219
  setMatchResults(null);
213
220
  setImportStats(null);
214
221
  setError(null);
222
+ setImportDirty(true);
215
223
 
216
224
  const reader = new FileReader();
217
225
  reader.onload = (event) => {
@@ -315,6 +323,7 @@ export function DataConnector({ trigger }: DataConnectorProps) {
315
323
  setMatchResults(null);
316
324
  setImportStats(null);
317
325
  setError(null);
326
+ setImportDirty(true);
318
327
  }, []);
319
328
 
320
329
  // Add a mapping row
@@ -423,12 +432,13 @@ export function DataConnector({ trigger }: DataConnectorProps) {
423
432
  }
424
433
  }, [csvConnector, csvContent, matchColumn, buildDataMapping]);
425
434
 
426
- // Import using CsvConnector.import
427
- const handleImport = useCallback(() => {
435
+ // Import using CsvConnector.importAsync for non-blocking progress
436
+ const handleImport = useCallback(async () => {
428
437
  if (!csvConnector || !csvContent) return;
429
438
 
430
439
  setIsProcessing(true);
431
440
  setImportStats(null);
441
+ setImportProgress(null);
432
442
  setError(null);
433
443
 
434
444
  try {
@@ -439,10 +449,15 @@ export function DataConnector({ trigger }: DataConnectorProps) {
439
449
  return;
440
450
  }
441
451
 
442
- // Use CsvConnector import method - this creates mutations via the MutablePropertyView
443
- const stats = csvConnector.import(csvContent, dataMapping);
452
+ const stats = await csvConnector.importAsync(
453
+ csvContent,
454
+ dataMapping,
455
+ (progress) => setImportProgress(progress)
456
+ );
444
457
 
445
458
  setImportStats(stats);
459
+ setImportProgress(null);
460
+ setImportDirty(false);
446
461
 
447
462
  if (stats.errors.length > 0) {
448
463
  setError(stats.errors.join('\n'));
@@ -455,6 +470,36 @@ export function DataConnector({ trigger }: DataConnectorProps) {
455
470
  }
456
471
  }, [csvConnector, csvContent, buildDataMapping]);
457
472
 
473
+ // Scroll to bottom of the body area — double rAF ensures DOM is painted
474
+ const scrollToBottom = useCallback(() => {
475
+ requestAnimationFrame(() => {
476
+ requestAnimationFrame(() => {
477
+ scrollAreaRef.current?.scrollTo({
478
+ top: scrollAreaRef.current.scrollHeight,
479
+ behavior: 'smooth',
480
+ });
481
+ });
482
+ });
483
+ }, []);
484
+
485
+ // Auto-scroll when import completes or errors appear
486
+ useEffect(() => {
487
+ if (importStats || error) scrollToBottom();
488
+ }, [importStats, error, scrollToBottom]);
489
+
490
+ // Auto-scroll when progress first appears (so user sees the bar)
491
+ const prevProgressRef = useRef<ImportProgress | null>(null);
492
+ useEffect(() => {
493
+ if (importProgress && !prevProgressRef.current) scrollToBottom();
494
+ prevProgressRef.current = importProgress;
495
+ }, [importProgress, scrollToBottom]);
496
+
497
+ // Mark config dirty when match/mapping settings change after a completed import
498
+ useEffect(() => {
499
+ if (importStats) setImportDirty(true);
500
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- only fire on config changes, not importStats itself
501
+ }, [matchType, matchColumn, matchPset, matchProp, mappings]);
502
+
458
503
  // Stats from match results
459
504
  const matchStats = useMemo(() => {
460
505
  if (!matchResults) return null;
@@ -473,6 +518,51 @@ export function DataConnector({ trigger }: DataConnectorProps) {
473
518
  });
474
519
  }, [parsedRows, csvColumns]);
475
520
 
521
+ // Drag-and-drop handlers
522
+ const [isDragging, setIsDragging] = useState(false);
523
+
524
+ const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
525
+ e.preventDefault();
526
+ e.stopPropagation();
527
+ setIsDragging(true);
528
+ }, []);
529
+
530
+ const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
531
+ e.preventDefault();
532
+ e.stopPropagation();
533
+ setIsDragging(false);
534
+ }, []);
535
+
536
+ const handleDrop = useCallback(
537
+ (e: DragEvent<HTMLDivElement>) => {
538
+ e.preventDefault();
539
+ e.stopPropagation();
540
+ setIsDragging(false);
541
+
542
+ const file = e.dataTransfer.files[0];
543
+ if (!file || !file.name.endsWith('.csv')) return;
544
+
545
+ // Reuse the same file-reading logic via a synthetic event
546
+ const dataTransfer = new DataTransfer();
547
+ dataTransfer.items.add(file);
548
+ if (fileInputRef.current) {
549
+ fileInputRef.current.files = dataTransfer.files;
550
+ fileInputRef.current.dispatchEvent(new Event('change', { bubbles: true }));
551
+ }
552
+ },
553
+ []
554
+ );
555
+
556
+ // Derive the current step for the step indicator
557
+ const currentStep = useMemo(() => {
558
+ if (importStats) return 3;
559
+ if (csvColumns.length > 0 && matchColumn && mappings.length > 0) return 2;
560
+ if (csvColumns.length > 0) return 1;
561
+ return 0;
562
+ }, [csvColumns.length, matchColumn, mappings.length, importStats]);
563
+
564
+ const steps = ['Upload CSV', 'Configure Mapping', 'Import'];
565
+
476
566
  return (
477
567
  <Dialog open={open} onOpenChange={setOpen}>
478
568
  <DialogTrigger asChild>
@@ -483,317 +573,432 @@ export function DataConnector({ trigger }: DataConnectorProps) {
483
573
  </Button>
484
574
  )}
485
575
  </DialogTrigger>
486
- <DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
487
- <DialogHeader>
576
+ <DialogContent className="sm:max-w-4xl max-h-[85vh] flex flex-col p-0 gap-0">
577
+ {/* Fixed Header */}
578
+ <DialogHeader className="px-6 pt-6 pb-4 shrink-0 border-b">
488
579
  <DialogTitle className="flex items-center gap-2">
489
580
  <FileSpreadsheet className="h-5 w-5" />
490
581
  Import External Data
491
582
  </DialogTitle>
492
583
  <DialogDescription>
493
- Import property data from CSV files and map to IFC entities using CsvConnector
584
+ Map CSV data to IFC entity properties
494
585
  </DialogDescription>
495
- </DialogHeader>
496
586
 
497
- <div className="space-y-6 py-4">
498
- {/* Model selector - first so CsvConnector can be created */}
499
- <div className="space-y-2">
500
- <Label className="text-sm font-medium">Target Model</Label>
501
- <Select value={selectedModelId} onValueChange={handleModelChange}>
502
- <SelectTrigger>
503
- <SelectValue placeholder="Select a model" />
504
- </SelectTrigger>
505
- <SelectContent>
506
- {modelList.map((m) => (
507
- <SelectItem key={m.id} value={m.id}>
508
- {m.name}
509
- </SelectItem>
510
- ))}
511
- </SelectContent>
512
- </Select>
513
- {selectedModelId && !csvConnector && (
514
- <p className="text-xs text-amber-600">
515
- Note: MutationView not available for this model. Some features may be limited.
516
- </p>
517
- )}
518
- </div>
519
-
520
- {/* File Upload */}
521
- <div className="space-y-2">
522
- <Label className="text-sm font-medium">CSV File</Label>
523
- <input
524
- ref={fileInputRef}
525
- type="file"
526
- accept=".csv"
527
- onChange={handleFileSelect}
528
- className="hidden"
529
- />
530
- <div className="flex items-center gap-2">
531
- <Button variant="outline" onClick={() => fileInputRef.current?.click()}>
532
- <Upload className="h-4 w-4 mr-2" />
533
- Choose File
534
- </Button>
535
- {fileName && <Badge variant="secondary">{fileName}</Badge>}
536
- </div>
587
+ {/* Step Indicator */}
588
+ <div className="flex items-center gap-1 pt-3">
589
+ {steps.map((step, idx) => (
590
+ <div key={step} className="flex items-center gap-1">
591
+ <div
592
+ className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
593
+ idx < currentStep
594
+ ? 'bg-primary/10 text-primary'
595
+ : idx === currentStep
596
+ ? 'bg-primary text-primary-foreground'
597
+ : 'bg-muted text-muted-foreground'
598
+ }`}
599
+ >
600
+ {idx < currentStep ? (
601
+ <Check className="h-3 w-3" />
602
+ ) : (
603
+ <span className="w-4 text-center">{idx + 1}</span>
604
+ )}
605
+ {step}
606
+ </div>
607
+ {idx < steps.length - 1 && (
608
+ <ChevronRight className="h-3 w-3 text-muted-foreground shrink-0" />
609
+ )}
610
+ </div>
611
+ ))}
537
612
  </div>
613
+ </DialogHeader>
538
614
 
539
- {csvColumns.length > 0 && (
540
- <>
541
- <Separator />
542
-
543
- {/* CSV Preview */}
544
- <div className="space-y-2">
545
- <Label className="text-sm font-medium">Data Preview</Label>
546
- <ScrollArea className="h-32 border rounded-md">
547
- <Table>
548
- <TableHeader>
549
- <TableRow>
550
- {csvColumns.map((col) => (
551
- <TableHead key={col.name} className="text-xs whitespace-nowrap">
552
- {col.name}
553
- </TableHead>
554
- ))}
555
- </TableRow>
556
- </TableHeader>
557
- <TableBody>
558
- {previewData.map((row, rowIdx) => (
559
- <TableRow key={rowIdx}>
560
- {row.map((cell, cellIdx) => (
561
- <TableCell key={cellIdx} className="text-xs py-1">
562
- {cell || '—'}
563
- </TableCell>
564
- ))}
565
- </TableRow>
566
- ))}
567
- </TableBody>
568
- </Table>
569
- </ScrollArea>
570
- <p className="text-xs text-muted-foreground">
571
- {parsedRows.length > 0
572
- ? `${parsedRows.length} rows parsed`
573
- : `${csvColumns[0]?.sampleValues.length || 0} sample rows`}
615
+ {/* Scrollable Body */}
616
+ <div ref={scrollAreaRef} className="flex-1 overflow-y-auto px-6 py-4">
617
+ <div className="space-y-6">
618
+ {/* Model selector */}
619
+ <div className="space-y-2">
620
+ <Label className="text-sm font-medium">Target Model</Label>
621
+ <Select value={selectedModelId} onValueChange={handleModelChange}>
622
+ <SelectTrigger>
623
+ <SelectValue placeholder="Select a model" />
624
+ </SelectTrigger>
625
+ <SelectContent>
626
+ {modelList.map((m) => (
627
+ <SelectItem key={m.id} value={m.id}>
628
+ {m.name}
629
+ </SelectItem>
630
+ ))}
631
+ </SelectContent>
632
+ </Select>
633
+ {selectedModelId && !csvConnector && (
634
+ <p className="text-xs text-amber-600">
635
+ Note: MutationView not available for this model. Some features may be limited.
574
636
  </p>
575
- </div>
576
-
577
- <Separator />
578
-
579
- {/* Matching Configuration */}
580
- <div className="space-y-4">
581
- <Label className="text-sm font-medium flex items-center gap-2">
582
- <Link2 className="h-4 w-4" />
583
- Match Configuration
584
- </Label>
637
+ )}
638
+ </div>
585
639
 
586
- <div className="grid grid-cols-2 gap-4">
587
- <div className="space-y-2">
588
- <Label className="text-xs text-muted-foreground">Match By</Label>
589
- <Select value={matchType} onValueChange={(v) => setMatchType(v as MatchType)}>
590
- <SelectTrigger>
591
- <SelectValue />
592
- </SelectTrigger>
593
- <SelectContent>
594
- <SelectItem value="globalId">GlobalId</SelectItem>
595
- <SelectItem value="expressId">EXPRESS ID</SelectItem>
596
- <SelectItem value="name">Entity Name</SelectItem>
597
- <SelectItem value="property">Property Value</SelectItem>
598
- </SelectContent>
599
- </Select>
640
+ {/* File Upload - Drag and Drop Zone */}
641
+ <div className="space-y-2">
642
+ <Label className="text-sm font-medium">CSV File</Label>
643
+ <input
644
+ ref={fileInputRef}
645
+ type="file"
646
+ accept=".csv"
647
+ onChange={handleFileSelect}
648
+ className="hidden"
649
+ />
650
+ {!fileName ? (
651
+ <div
652
+ onDragOver={handleDragOver}
653
+ onDragLeave={handleDragLeave}
654
+ onDrop={handleDrop}
655
+ onClick={() => fileInputRef.current?.click()}
656
+ className={`flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-8 cursor-pointer transition-colors ${
657
+ isDragging
658
+ ? 'border-primary bg-primary/5'
659
+ : 'border-muted-foreground/25 hover:border-muted-foreground/50 hover:bg-muted/50'
660
+ }`}
661
+ >
662
+ <Upload className={`h-8 w-8 ${isDragging ? 'text-primary' : 'text-muted-foreground'}`} />
663
+ <div className="text-center">
664
+ <p className="text-sm font-medium">
665
+ {isDragging ? 'Drop CSV file here' : 'Drag & drop a CSV file'}
666
+ </p>
667
+ <p className="text-xs text-muted-foreground mt-1">
668
+ or click to browse
669
+ </p>
600
670
  </div>
671
+ </div>
672
+ ) : (
673
+ <div className="flex items-center gap-2">
674
+ <Badge variant="secondary" className="gap-1.5">
675
+ <FileSpreadsheet className="h-3 w-3" />
676
+ {fileName}
677
+ </Badge>
678
+ <Button
679
+ variant="ghost"
680
+ size="sm"
681
+ className="h-7 text-xs"
682
+ onClick={() => fileInputRef.current?.click()}
683
+ >
684
+ Change
685
+ </Button>
686
+ </div>
687
+ )}
688
+ </div>
601
689
 
602
- <div className="space-y-2">
603
- <Label className="text-xs text-muted-foreground">Match Column</Label>
604
- <Select value={matchColumn} onValueChange={setMatchColumn}>
605
- <SelectTrigger>
606
- <SelectValue placeholder="Select column" />
607
- </SelectTrigger>
608
- <SelectContent>
609
- {csvColumns.map((col) => (
610
- <SelectItem key={col.name} value={col.name}>
611
- {col.name}
612
- {col.sampleValues[0] && (
613
- <span className="ml-2 text-muted-foreground">
614
- (e.g., {col.sampleValues[0].slice(0, 20)})
615
- </span>
616
- )}
617
- </SelectItem>
690
+ {csvColumns.length > 0 && (
691
+ <>
692
+ <Separator />
693
+
694
+ {/* CSV Preview */}
695
+ <div className="space-y-2">
696
+ <Label className="text-sm font-medium">Data Preview</Label>
697
+ <ScrollArea className="h-32 border rounded-md">
698
+ <Table>
699
+ <TableHeader>
700
+ <TableRow>
701
+ {csvColumns.map((col) => (
702
+ <TableHead key={col.name} className="text-xs whitespace-nowrap">
703
+ {col.name}
704
+ </TableHead>
705
+ ))}
706
+ </TableRow>
707
+ </TableHeader>
708
+ <TableBody>
709
+ {previewData.map((row, rowIdx) => (
710
+ <TableRow key={rowIdx}>
711
+ {row.map((cell, cellIdx) => (
712
+ <TableCell key={cellIdx} className="text-xs py-1">
713
+ {cell || '\u2014'}
714
+ </TableCell>
715
+ ))}
716
+ </TableRow>
618
717
  ))}
619
- </SelectContent>
620
- </Select>
621
- </div>
718
+ </TableBody>
719
+ </Table>
720
+ </ScrollArea>
721
+ <p className="text-xs text-muted-foreground">
722
+ {parsedRows.length > 0
723
+ ? `${parsedRows.length} rows parsed`
724
+ : `${csvColumns[0]?.sampleValues.length || 0} sample rows`}
725
+ </p>
622
726
  </div>
623
727
 
624
- {matchType === 'property' && (
728
+ <Separator />
729
+
730
+ {/* Matching Configuration */}
731
+ <div className="space-y-4">
732
+ <Label className="text-sm font-medium flex items-center gap-2">
733
+ <Link2 className="h-4 w-4" />
734
+ Entity Matching
735
+ </Label>
736
+
625
737
  <div className="grid grid-cols-2 gap-4">
626
738
  <div className="space-y-2">
627
- <Label className="text-xs text-muted-foreground">Property Set</Label>
628
- <Input
629
- value={matchPset}
630
- onChange={(e) => setMatchPset(e.target.value)}
631
- placeholder="e.g., Pset_WallCommon"
632
- />
739
+ <Label className="text-xs text-muted-foreground">Match By</Label>
740
+ <Select value={matchType} onValueChange={(v) => setMatchType(v as MatchType)}>
741
+ <SelectTrigger>
742
+ <SelectValue />
743
+ </SelectTrigger>
744
+ <SelectContent>
745
+ <SelectItem value="globalId">GlobalId</SelectItem>
746
+ <SelectItem value="expressId">EXPRESS ID</SelectItem>
747
+ <SelectItem value="name">Entity Name</SelectItem>
748
+ <SelectItem value="property">Property Value</SelectItem>
749
+ </SelectContent>
750
+ </Select>
633
751
  </div>
752
+
634
753
  <div className="space-y-2">
635
- <Label className="text-xs text-muted-foreground">Property Name</Label>
636
- <Input
637
- value={matchProp}
638
- onChange={(e) => setMatchProp(e.target.value)}
639
- placeholder="e.g., Reference"
640
- />
754
+ <Label className="text-xs text-muted-foreground">CSV Column</Label>
755
+ <Select value={matchColumn} onValueChange={setMatchColumn}>
756
+ <SelectTrigger>
757
+ <SelectValue placeholder="Select column" />
758
+ </SelectTrigger>
759
+ <SelectContent>
760
+ {csvColumns.map((col) => (
761
+ <SelectItem key={col.name} value={col.name}>
762
+ {col.name}
763
+ {col.sampleValues[0] && (
764
+ <span className="ml-2 text-muted-foreground">
765
+ (e.g., {col.sampleValues[0].slice(0, 20)})
766
+ </span>
767
+ )}
768
+ </SelectItem>
769
+ ))}
770
+ </SelectContent>
771
+ </Select>
641
772
  </div>
642
773
  </div>
643
- )}
644
- </div>
645
-
646
- <Separator />
647
-
648
- {/* Property Mappings */}
649
- <div className="space-y-4">
650
- <div className="flex items-center justify-between">
651
- <Label className="text-sm font-medium">Property Mappings</Label>
652
- <div className="flex items-center gap-2">
653
- {csvConnector && (
654
- <Button variant="ghost" size="sm" onClick={handleAutoDetect}>
655
- <Wand2 className="h-3 w-3 mr-1" />
656
- Auto-detect
657
- </Button>
658
- )}
659
- <Button variant="ghost" size="sm" onClick={addMapping}>
660
- <Plus className="h-3 w-3 mr-1" />
661
- Add Mapping
662
- </Button>
663
- </div>
664
- </div>
665
-
666
- {mappings.length === 0 ? (
667
- <p className="text-sm text-muted-foreground text-center py-4">
668
- Add mappings to import column values as IFC properties
669
- </p>
670
- ) : (
671
- <div className="space-y-2">
672
- {mappings.map((mapping) => (
673
- <div
674
- key={mapping.id}
675
- className="flex items-center gap-2 p-2 border rounded-md bg-muted/30"
676
- >
677
- <Select
678
- value={mapping.sourceColumn}
679
- onValueChange={(v) => updateMapping(mapping.id, 'sourceColumn', v)}
680
- >
681
- <SelectTrigger className="h-8 w-32">
682
- <SelectValue placeholder="Column" />
683
- </SelectTrigger>
684
- <SelectContent>
685
- {csvColumns.map((col) => (
686
- <SelectItem key={col.name} value={col.name}>
687
- {col.name}
688
- </SelectItem>
689
- ))}
690
- </SelectContent>
691
- </Select>
692
-
693
- <ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
694
774
 
775
+ {matchType === 'property' && (
776
+ <div className="grid grid-cols-2 gap-4">
777
+ <div className="space-y-2">
778
+ <Label className="text-xs text-muted-foreground">Property Set</Label>
695
779
  <Input
696
- placeholder="Pset name"
697
- value={mapping.targetPset}
698
- onChange={(e) => updateMapping(mapping.id, 'targetPset', e.target.value)}
699
- className="h-8 text-xs w-32"
780
+ value={matchPset}
781
+ onChange={(e) => setMatchPset(e.target.value)}
782
+ placeholder="e.g., Pset_WallCommon"
700
783
  />
701
-
784
+ </div>
785
+ <div className="space-y-2">
786
+ <Label className="text-xs text-muted-foreground">Property Name</Label>
702
787
  <Input
703
- placeholder="Property"
704
- value={mapping.targetProperty}
705
- onChange={(e) =>
706
- updateMapping(mapping.id, 'targetProperty', e.target.value)
707
- }
708
- className="h-8 text-xs flex-1"
788
+ value={matchProp}
789
+ onChange={(e) => setMatchProp(e.target.value)}
790
+ placeholder="e.g., Reference"
709
791
  />
792
+ </div>
793
+ </div>
794
+ )}
795
+ </div>
710
796
 
711
- <Select
712
- value={mapping.valueType.toString()}
713
- onValueChange={(v) => updateMapping(mapping.id, 'valueType', parseInt(v))}
714
- >
715
- <SelectTrigger className="h-8 w-24">
716
- <SelectValue />
717
- </SelectTrigger>
718
- <SelectContent>
719
- <SelectItem value={PropertyValueType.String.toString()}>
720
- String
721
- </SelectItem>
722
- <SelectItem value={PropertyValueType.Real.toString()}>Real</SelectItem>
723
- <SelectItem value={PropertyValueType.Integer.toString()}>
724
- Integer
725
- </SelectItem>
726
- <SelectItem value={PropertyValueType.Boolean.toString()}>
727
- Boolean
728
- </SelectItem>
729
- </SelectContent>
730
- </Select>
731
-
732
- <Button
733
- variant="ghost"
734
- size="icon"
735
- className="h-8 w-8"
736
- onClick={() => removeMapping(mapping.id)}
737
- >
738
- <Trash2 className="h-3 w-3 text-destructive" />
797
+ <Separator />
798
+
799
+ {/* Property Mappings */}
800
+ <div className="space-y-4">
801
+ <div className="flex items-center justify-between">
802
+ <Label className="text-sm font-medium">Property Mappings</Label>
803
+ <div className="flex items-center gap-2">
804
+ {csvConnector && (
805
+ <Button variant="ghost" size="sm" onClick={handleAutoDetect}>
806
+ <Wand2 className="h-3 w-3 mr-1" />
807
+ Auto-detect
739
808
  </Button>
740
- </div>
741
- ))}
809
+ )}
810
+ <Button variant="ghost" size="sm" onClick={addMapping}>
811
+ <Plus className="h-3 w-3 mr-1" />
812
+ Add
813
+ </Button>
814
+ </div>
742
815
  </div>
743
- )}
744
- </div>
745
816
 
746
- {/* Match Results */}
747
- {matchStats && (
748
- <Alert>
749
- <Eye className="h-4 w-4" />
750
- <AlertTitle>Match Results</AlertTitle>
751
- <AlertDescription className="flex flex-wrap items-center gap-2">
752
- <Badge variant="default">{matchStats.matched} matched</Badge>
753
- <Badge variant="secondary">{matchStats.unmatched} unmatched</Badge>
754
- <Badge variant="outline">{matchStats.highConfidence} high confidence</Badge>
755
- {matchStats.multiMatch > 0 && (
756
- <Badge variant="destructive">{matchStats.multiMatch} multi-match</Badge>
757
- )}
758
- </AlertDescription>
759
- </Alert>
760
- )}
817
+ {mappings.length === 0 ? (
818
+ <div className="text-center py-6 border rounded-lg border-dashed">
819
+ <p className="text-sm text-muted-foreground">
820
+ No property mappings configured
821
+ </p>
822
+ <p className="text-xs text-muted-foreground mt-1">
823
+ Click &quot;Auto-detect&quot; or &quot;Add&quot; to map CSV columns to IFC properties
824
+ </p>
825
+ </div>
826
+ ) : (
827
+ <div className="space-y-2">
828
+ {/* Column headers for mapping rows */}
829
+ <div className="grid grid-cols-[1fr_auto_1fr_1fr_auto_auto] gap-2 px-2 text-xs text-muted-foreground">
830
+ <span>Source Column</span>
831
+ <span />
832
+ <span>Target Pset</span>
833
+ <span>Target Property</span>
834
+ <span>Type</span>
835
+ <span />
836
+ </div>
837
+ {mappings.map((mapping) => (
838
+ <div
839
+ key={mapping.id}
840
+ className="grid grid-cols-[1fr_auto_1fr_1fr_auto_auto] gap-2 items-center p-2 border rounded-md bg-muted/30"
841
+ >
842
+ <Select
843
+ value={mapping.sourceColumn}
844
+ onValueChange={(v) => updateMapping(mapping.id, 'sourceColumn', v)}
845
+ >
846
+ <SelectTrigger className="h-8">
847
+ <SelectValue placeholder="Column" />
848
+ </SelectTrigger>
849
+ <SelectContent>
850
+ {csvColumns.map((col) => (
851
+ <SelectItem key={col.name} value={col.name}>
852
+ {col.name}
853
+ </SelectItem>
854
+ ))}
855
+ </SelectContent>
856
+ </Select>
857
+
858
+ <ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
859
+
860
+ <Input
861
+ placeholder="Pset name"
862
+ value={mapping.targetPset}
863
+ onChange={(e) =>
864
+ updateMapping(mapping.id, 'targetPset', e.target.value)
865
+ }
866
+ className="h-8 text-xs"
867
+ />
868
+
869
+ <Input
870
+ placeholder="Property"
871
+ value={mapping.targetProperty}
872
+ onChange={(e) =>
873
+ updateMapping(mapping.id, 'targetProperty', e.target.value)
874
+ }
875
+ className="h-8 text-xs"
876
+ />
877
+
878
+ <Select
879
+ value={mapping.valueType.toString()}
880
+ onValueChange={(v) =>
881
+ updateMapping(mapping.id, 'valueType', parseInt(v))
882
+ }
883
+ >
884
+ <SelectTrigger className="h-8 w-24">
885
+ <SelectValue />
886
+ </SelectTrigger>
887
+ <SelectContent>
888
+ <SelectItem value={PropertyValueType.String.toString()}>
889
+ String
890
+ </SelectItem>
891
+ <SelectItem value={PropertyValueType.Real.toString()}>
892
+ Real
893
+ </SelectItem>
894
+ <SelectItem value={PropertyValueType.Integer.toString()}>
895
+ Integer
896
+ </SelectItem>
897
+ <SelectItem value={PropertyValueType.Boolean.toString()}>
898
+ Boolean
899
+ </SelectItem>
900
+ </SelectContent>
901
+ </Select>
902
+
903
+ <Button
904
+ variant="ghost"
905
+ size="icon"
906
+ className="h-8 w-8"
907
+ onClick={() => removeMapping(mapping.id)}
908
+ >
909
+ <Trash2 className="h-3 w-3 text-destructive" />
910
+ </Button>
911
+ </div>
912
+ ))}
913
+ </div>
914
+ )}
915
+ </div>
761
916
 
762
- {/* Import Stats */}
763
- {importStats && (
764
- <Alert variant={importStats.errors.length === 0 ? 'default' : 'destructive'}>
765
- <Check className="h-4 w-4" />
766
- <AlertTitle>Import Complete</AlertTitle>
767
- <AlertDescription>
768
- <div className="flex flex-wrap items-center gap-2 mt-1">
769
- <Badge variant="default">
770
- {importStats.mutationsCreated} properties updated
917
+ {/* Match Results */}
918
+ {matchStats && (
919
+ <Alert>
920
+ <Eye className="h-4 w-4" />
921
+ <AlertTitle>Match Results</AlertTitle>
922
+ <AlertDescription className="flex flex-wrap items-center gap-2">
923
+ <Badge variant="default">{matchStats.matched} matched</Badge>
924
+ <Badge variant="secondary">{matchStats.unmatched} unmatched</Badge>
925
+ <Badge variant="outline">
926
+ {matchStats.highConfidence} high confidence
771
927
  </Badge>
772
- <Badge variant="secondary">{importStats.matchedRows} rows matched</Badge>
773
- <Badge variant="outline">{importStats.unmatchedRows} rows unmatched</Badge>
928
+ {matchStats.multiMatch > 0 && (
929
+ <Badge variant="destructive">
930
+ {matchStats.multiMatch} multi-match
931
+ </Badge>
932
+ )}
933
+ </AlertDescription>
934
+ </Alert>
935
+ )}
936
+
937
+ {/* Live Import Progress */}
938
+ {importProgress && (
939
+ <div className="space-y-2">
940
+ <div className="flex items-center justify-between text-sm text-muted-foreground">
941
+ <span className="flex items-center gap-2">
942
+ <Loader2 className="h-4 w-4 animate-spin" />
943
+ {importProgress.phase === 'parsing' && 'Parsing CSV...'}
944
+ {importProgress.phase === 'matching' && 'Matching entities...'}
945
+ {importProgress.phase === 'applying' && 'Applying properties...'}
946
+ </span>
947
+ <span className="tabular-nums">
948
+ {importProgress.matchedRows.toLocaleString()} matched
949
+ {importProgress.mutationsCreated > 0 &&
950
+ ` \u00b7 ${importProgress.mutationsCreated.toLocaleString()} written`}
951
+ </span>
774
952
  </div>
775
- {importStats.warnings.length > 0 && (
776
- <div className="mt-2 text-xs text-amber-600">
777
- {importStats.warnings.length} warning(s)
953
+ <Progress value={importProgress.percent * 100} />
954
+ </div>
955
+ )}
956
+
957
+ {/* Import Stats */}
958
+ {importStats && (
959
+ <Alert
960
+ variant={importStats.errors.length === 0 ? 'default' : 'destructive'}
961
+ >
962
+ <Check className="h-4 w-4" />
963
+ <AlertTitle>Import Complete</AlertTitle>
964
+ <AlertDescription>
965
+ <div className="flex flex-wrap items-center gap-2 mt-1">
966
+ <Badge variant="default">
967
+ {importStats.mutationsCreated} properties updated
968
+ </Badge>
969
+ <Badge variant="secondary">
970
+ {importStats.matchedRows} rows matched
971
+ </Badge>
972
+ <Badge variant="outline">
973
+ {importStats.unmatchedRows} rows unmatched
974
+ </Badge>
778
975
  </div>
779
- )}
780
- </AlertDescription>
781
- </Alert>
782
- )}
976
+ {importStats.warnings.length > 0 && (
977
+ <div className="mt-2 text-xs text-amber-600">
978
+ {importStats.warnings.length} warning(s)
979
+ </div>
980
+ )}
981
+ </AlertDescription>
982
+ </Alert>
983
+ )}
783
984
 
784
- {/* Error Display */}
785
- {error && (
786
- <Alert variant="destructive">
787
- <AlertCircle className="h-4 w-4" />
788
- <AlertTitle>Error</AlertTitle>
789
- <AlertDescription className="whitespace-pre-wrap">{error}</AlertDescription>
790
- </Alert>
791
- )}
792
- </>
793
- )}
985
+ {/* Error Display */}
986
+ {error && (
987
+ <Alert variant="destructive">
988
+ <AlertCircle className="h-4 w-4" />
989
+ <AlertTitle>Error</AlertTitle>
990
+ <AlertDescription className="whitespace-pre-wrap">
991
+ {error}
992
+ </AlertDescription>
993
+ </Alert>
994
+ )}
995
+ </>
996
+ )}
997
+ </div>
794
998
  </div>
795
999
 
796
- <DialogFooter className="gap-2">
1000
+ {/* Fixed Footer */}
1001
+ <DialogFooter className="px-6 py-4 border-t shrink-0 gap-2">
797
1002
  <Button
798
1003
  variant="secondary"
799
1004
  onClick={handlePreview}
@@ -811,21 +1016,21 @@ export function DataConnector({ trigger }: DataConnectorProps) {
811
1016
  disabled={
812
1017
  !csvConnector ||
813
1018
  !csvContent ||
814
- !matchResults ||
815
- matchStats?.matched === 0 ||
1019
+ !matchColumn ||
816
1020
  mappings.length === 0 ||
817
- isProcessing
1021
+ isProcessing ||
1022
+ !importDirty
818
1023
  }
819
1024
  >
820
- {isProcessing ? (
1025
+ {isProcessing && importProgress ? (
821
1026
  <>
822
1027
  <Loader2 className="h-4 w-4 mr-2 animate-spin" />
823
- Importing...
1028
+ {Math.round(importProgress.percent * 100)}%
824
1029
  </>
825
1030
  ) : (
826
1031
  <>
827
1032
  <Play className="h-4 w-4 mr-2" />
828
- Import {matchStats?.matched || 0} rows
1033
+ {matchStats ? `Import ${matchStats.matched} rows` : 'Import'}
829
1034
  </>
830
1035
  )}
831
1036
  </Button>