@visns-studio/visns-components 5.15.10 → 5.15.12

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.
@@ -0,0 +1,850 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import {
3
+ ArrowLeft,
4
+ ArrowRight,
5
+ Download,
6
+ RefreshCw,
7
+ Plus,
8
+ Edit,
9
+ AlertTriangle,
10
+ CheckCircle,
11
+ Search,
12
+ Filter,
13
+ Eye,
14
+ EyeOff,
15
+ Settings,
16
+ FileText,
17
+ Zap,
18
+ Link,
19
+ X,
20
+ UserX,
21
+ Users
22
+ } from 'lucide-react';
23
+ import styles from '../styles/DataSyncWizard.module.scss';
24
+
25
+ const DataSyncWizard = ({
26
+ provider,
27
+ dataType,
28
+ onCancel,
29
+ onComplete
30
+ }) => {
31
+ const [currentStep, setCurrentStep] = useState(1);
32
+ const [previewData, setPreviewData] = useState(null);
33
+ const [loading, setLoading] = useState(false);
34
+ const [syncSettings, setSyncSettings] = useState({
35
+ include_new: true,
36
+ include_updates: true,
37
+ skip_duplicates: true,
38
+ batch_size: 50
39
+ });
40
+
41
+ // Pagination and filtering for step 2
42
+ const [currentPage, setCurrentPage] = useState(1);
43
+ const [searchTerm, setSearchTerm] = useState('');
44
+ const [statusFilter, setStatusFilter] = useState('all');
45
+ const [showDetails, setShowDetails] = useState({});
46
+ const [manualMatches, setManualMatches] = useState({});
47
+ const [showMatchModal, setShowMatchModal] = useState(null);
48
+ const [skippedRecords, setSkippedRecords] = useState(new Set());
49
+ const [showDuplicateModal, setShowDuplicateModal] = useState(null);
50
+ const recordsPerPage = 20;
51
+
52
+ const steps = [
53
+ {
54
+ id: 1,
55
+ title: 'Configure Sync',
56
+ description: 'Choose what data to synchronize',
57
+ icon: <Settings size={20} />
58
+ },
59
+ {
60
+ id: 2,
61
+ title: 'Preview Changes',
62
+ description: 'Review all records before importing',
63
+ icon: <Eye size={20} />
64
+ },
65
+ {
66
+ id: 3,
67
+ title: 'Confirm & Sync',
68
+ description: 'Final confirmation and execution',
69
+ icon: <Zap size={20} />
70
+ }
71
+ ];
72
+
73
+ useEffect(() => {
74
+ if (currentStep === 2 && !previewData) {
75
+ loadPreviewData();
76
+ }
77
+ }, [currentStep]);
78
+
79
+ const loadPreviewData = async () => {
80
+ setLoading(true);
81
+ try {
82
+ const response = await fetch(`/integrations/oauth/${provider.name}/preview`, {
83
+ method: 'POST',
84
+ headers: {
85
+ 'Content-Type': 'application/json',
86
+ 'X-Requested-With': 'XMLHttpRequest',
87
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token\"]')?.getAttribute('content'),
88
+ },
89
+ credentials: 'same-origin',
90
+ body: JSON.stringify({
91
+ data_type: dataType,
92
+ options: {
93
+ limit: 50, // Get more records for preview
94
+ include_all: true,
95
+ include_status: true,
96
+ settings: syncSettings
97
+ }
98
+ }),
99
+ });
100
+
101
+ if (!response.ok) {
102
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
103
+ }
104
+
105
+ const data = await response.json();
106
+ setPreviewData(data);
107
+ } catch (error) {
108
+ console.error('Preview failed:', error);
109
+ setPreviewData({
110
+ success: false,
111
+ error: error.message
112
+ });
113
+ } finally {
114
+ setLoading(false);
115
+ }
116
+ };
117
+
118
+ const handleNext = () => {
119
+ if (currentStep < steps.length) {
120
+ setCurrentStep(currentStep + 1);
121
+ }
122
+ };
123
+
124
+ const handleBack = () => {
125
+ if (currentStep > 1) {
126
+ setCurrentStep(currentStep - 1);
127
+ }
128
+ };
129
+
130
+ const handleComplete = async () => {
131
+ try {
132
+ setLoading(true);
133
+
134
+ // Filter records to only include non-skipped ones
135
+ const recordsToSync = previewData.records.filter(record =>
136
+ !skippedRecords.has(record.id)
137
+ );
138
+
139
+ // Make direct API call to sync endpoint
140
+ const response = await fetch(`/integrations/oauth/${provider.name}/sync`, {
141
+ method: 'POST',
142
+ headers: {
143
+ 'Content-Type': 'application/json',
144
+ 'X-Requested-With': 'XMLHttpRequest',
145
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'),
146
+ },
147
+ credentials: 'same-origin',
148
+ body: JSON.stringify({
149
+ type: dataType,
150
+ confirmed: true,
151
+ preview_id: previewData.preview_id,
152
+ settings: syncSettings,
153
+ records: recordsToSync,
154
+ skipped_records: Array.from(skippedRecords),
155
+ manual_matches: manualMatches
156
+ }),
157
+ });
158
+
159
+ if (!response.ok) {
160
+ const errorData = await response.json();
161
+ throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
162
+ }
163
+
164
+ const result = await response.json();
165
+
166
+ if (result.success) {
167
+ // Call the onComplete callback with the results
168
+ await onComplete(dataType, result);
169
+ } else {
170
+ throw new Error(result.message || 'Sync failed');
171
+ }
172
+ } catch (error) {
173
+ console.error('Sync failed:', error);
174
+ // Still call onComplete to notify parent of failure
175
+ await onComplete(dataType, {
176
+ success: false,
177
+ message: error.message,
178
+ });
179
+ } finally {
180
+ setLoading(false);
181
+ }
182
+ };
183
+
184
+ const toggleDetails = (recordId) => {
185
+ setShowDetails(prev => ({
186
+ ...prev,
187
+ [recordId]: !prev[recordId]
188
+ }));
189
+ };
190
+
191
+ const skipRecord = (recordId) => {
192
+ setSkippedRecords(prev => new Set([...prev, recordId]));
193
+ };
194
+
195
+ const unSkipRecord = (recordId) => {
196
+ setSkippedRecords(prev => {
197
+ const newSet = new Set(prev);
198
+ newSet.delete(recordId);
199
+ return newSet;
200
+ });
201
+ };
202
+
203
+ const findPotentialDuplicates = (record) => {
204
+ if (!previewData?.records) return [];
205
+
206
+ const currentRecordId = record.id;
207
+ const recordName = record.display_name?.toLowerCase() || '';
208
+
209
+ return previewData.records.filter(r => {
210
+ if (r.id === currentRecordId) return false;
211
+
212
+ const otherName = r.display_name?.toLowerCase() || '';
213
+ if (!recordName || !otherName) return false;
214
+
215
+ // Simple similarity check - names that share words or one is contained in the other
216
+ const recordWords = recordName.split(/\s+/);
217
+ const otherWords = otherName.split(/\s+/);
218
+
219
+ // Check if any word matches or if one name contains the other
220
+ const hasCommonWord = recordWords.some(word =>
221
+ otherWords.some(otherWord =>
222
+ word.length > 2 && otherWord.length > 2 &&
223
+ (word.includes(otherWord) || otherWord.includes(word))
224
+ )
225
+ );
226
+
227
+ return hasCommonWord || recordName.includes(otherName) || otherName.includes(recordName);
228
+ });
229
+ };
230
+
231
+ const getStatusIcon = (status) => {
232
+ switch (status) {
233
+ case 'new':
234
+ return <Plus size={16} className={styles.statusIconNew} />;
235
+ case 'update':
236
+ return <Edit size={16} className={styles.statusIconUpdate} />;
237
+ case 'skip':
238
+ return <AlertTriangle size={16} className={styles.statusIconSkip} />;
239
+ default:
240
+ return <CheckCircle size={16} className={styles.statusIconDefault} />;
241
+ }
242
+ };
243
+
244
+ const getStatusBadge = (status) => {
245
+ const baseClass = styles.statusBadge;
246
+ switch (status) {
247
+ case 'new':
248
+ return `${baseClass} ${styles.statusNew}`;
249
+ case 'update':
250
+ return `${baseClass} ${styles.statusUpdate}`;
251
+ case 'skip':
252
+ return `${baseClass} ${styles.statusSkip}`;
253
+ default:
254
+ return `${baseClass} ${styles.statusDefault}`;
255
+ }
256
+ };
257
+
258
+ const filterRecords = (records) => {
259
+ if (!records) return [];
260
+
261
+ let filtered = records;
262
+
263
+ if (searchTerm) {
264
+ filtered = filtered.filter(record =>
265
+ JSON.stringify(record.data).toLowerCase().includes(searchTerm.toLowerCase()) ||
266
+ (record.existing_data && JSON.stringify(record.existing_data).toLowerCase().includes(searchTerm.toLowerCase()))
267
+ );
268
+ }
269
+
270
+ if (statusFilter !== 'all') {
271
+ filtered = filtered.filter(record => record.sync_status === statusFilter);
272
+ }
273
+
274
+ return filtered;
275
+ };
276
+
277
+ const paginateRecords = (records) => {
278
+ const startIndex = (currentPage - 1) * recordsPerPage;
279
+ return records.slice(startIndex, startIndex + recordsPerPage);
280
+ };
281
+
282
+ const filteredRecords = filterRecords(previewData?.records || []);
283
+ const paginatedRecords = paginateRecords(filteredRecords);
284
+ const totalPages = Math.ceil(filteredRecords.length / recordsPerPage);
285
+
286
+ const renderStep1 = () => (
287
+ <div className={styles.stepContent}>
288
+ <div className={styles.stepHeader}>
289
+ <div className={styles.stepIcon}>
290
+ <Settings size={24} />
291
+ </div>
292
+ <div className={styles.stepInfo}>
293
+ <h2>Configure Sync Settings</h2>
294
+ <p>Choose how to synchronize {dataType} from {provider.display_name}</p>
295
+ </div>
296
+ </div>
297
+
298
+ <div className={styles.settingsGrid}>
299
+ <div className={styles.settingCard}>
300
+ <div className={styles.settingHeader}>
301
+ <Plus size={20} />
302
+ <h3>New Records</h3>
303
+ </div>
304
+ <p>Import records that don't exist in the CRM</p>
305
+ <label className={styles.toggle}>
306
+ <input
307
+ type="checkbox"
308
+ checked={syncSettings.include_new}
309
+ onChange={(e) => setSyncSettings(prev => ({...prev, include_new: e.target.checked}))}
310
+ />
311
+ <span className={styles.slider}></span>
312
+ </label>
313
+ </div>
314
+
315
+ <div className={styles.settingCard}>
316
+ <div className={styles.settingHeader}>
317
+ <Edit size={20} />
318
+ <h3>Update Existing</h3>
319
+ </div>
320
+ <p>Update records that already exist in the CRM</p>
321
+ <label className={styles.toggle}>
322
+ <input
323
+ type="checkbox"
324
+ checked={syncSettings.include_updates}
325
+ onChange={(e) => setSyncSettings(prev => ({...prev, include_updates: e.target.checked}))}
326
+ />
327
+ <span className={styles.slider}></span>
328
+ </label>
329
+ </div>
330
+
331
+ <div className={styles.settingCard}>
332
+ <div className={styles.settingHeader}>
333
+ <AlertTriangle size={20} />
334
+ <h3>Skip Duplicates</h3>
335
+ </div>
336
+ <p>Skip records that appear to be duplicates</p>
337
+ <label className={styles.toggle}>
338
+ <input
339
+ type="checkbox"
340
+ checked={syncSettings.skip_duplicates}
341
+ onChange={(e) => setSyncSettings(prev => ({...prev, skip_duplicates: e.target.checked}))}
342
+ />
343
+ <span className={styles.slider}></span>
344
+ </label>
345
+ </div>
346
+
347
+ <div className={styles.settingCard}>
348
+ <div className={styles.settingHeader}>
349
+ <Zap size={20} />
350
+ <h3>Batch Size</h3>
351
+ </div>
352
+ <p>Number of records to process at once</p>
353
+ <select
354
+ value={syncSettings.batch_size}
355
+ onChange={(e) => setSyncSettings(prev => ({...prev, batch_size: parseInt(e.target.value)}))}
356
+ className={styles.batchSelect}
357
+ >
358
+ <option value={25}>25 records</option>
359
+ <option value={50}>50 records</option>
360
+ <option value={100}>100 records</option>
361
+ <option value={200}>200 records</option>
362
+ </select>
363
+ </div>
364
+ </div>
365
+ </div>
366
+ );
367
+
368
+ const renderStep2 = () => (
369
+ <div className={styles.stepContent}>
370
+ <div className={styles.stepHeader}>
371
+ <div className={styles.stepIcon}>
372
+ <Eye size={24} />
373
+ </div>
374
+ <div className={styles.stepInfo}>
375
+ <h2>Preview Changes</h2>
376
+ <p>Review all records that will be synchronized</p>
377
+ </div>
378
+ </div>
379
+
380
+ {loading ? (
381
+ <div className={styles.loadingState}>
382
+ <RefreshCw size={32} className={styles.spinner} />
383
+ <p>Analyzing all records...</p>
384
+ </div>
385
+ ) : previewData?.success === false ? (
386
+ <div className={styles.errorState}>
387
+ <AlertTriangle size={32} />
388
+ <h3>Preview Failed</h3>
389
+ <p>{previewData.error}</p>
390
+ <button onClick={loadPreviewData} className={styles.retryButton}>
391
+ Try Again
392
+ </button>
393
+ </div>
394
+ ) : previewData ? (
395
+ <>
396
+ {/* Summary Stats */}
397
+ <div className={styles.summaryGrid}>
398
+ <div className={styles.summaryCard}>
399
+ <div className={styles.summaryIcon}>
400
+ <Plus size={20} />
401
+ </div>
402
+ <div className={styles.summaryContent}>
403
+ <span className={styles.summaryNumber}>
404
+ {previewData.summary?.new_records || 0}
405
+ </span>
406
+ <span className={styles.summaryLabel}>New Records</span>
407
+ </div>
408
+ </div>
409
+
410
+ <div className={styles.summaryCard}>
411
+ <div className={styles.summaryIcon}>
412
+ <Edit size={20} />
413
+ </div>
414
+ <div className={styles.summaryContent}>
415
+ <span className={styles.summaryNumber}>
416
+ {previewData.summary?.updates || 0}
417
+ </span>
418
+ <span className={styles.summaryLabel}>Updates</span>
419
+ </div>
420
+ </div>
421
+
422
+ <div className={styles.summaryCard}>
423
+ <div className={styles.summaryIcon}>
424
+ <AlertTriangle size={20} />
425
+ </div>
426
+ <div className={styles.summaryContent}>
427
+ <span className={styles.summaryNumber}>
428
+ {previewData.summary?.skipped || 0}
429
+ </span>
430
+ <span className={styles.summaryLabel}>Skipped</span>
431
+ </div>
432
+ </div>
433
+ </div>
434
+
435
+ {/* Filters */}
436
+ <div className={styles.filtersSection}>
437
+ <div className={styles.searchBox}>
438
+ <Search size={16} />
439
+ <input
440
+ type="text"
441
+ placeholder="Search records..."
442
+ value={searchTerm}
443
+ onChange={(e) => setSearchTerm(e.target.value)}
444
+ />
445
+ </div>
446
+
447
+ <div className={styles.filterBox}>
448
+ <Filter size={16} />
449
+ <select
450
+ value={statusFilter}
451
+ onChange={(e) => setStatusFilter(e.target.value)}
452
+ >
453
+ <option value="all">All Records</option>
454
+ <option value="new">New Only</option>
455
+ <option value="update">Updates Only</option>
456
+ <option value="skip">Skipped Only</option>
457
+ </select>
458
+ </div>
459
+ </div>
460
+
461
+ {/* Records */}
462
+ <div className={styles.recordsSection}>
463
+ {paginatedRecords.length === 0 ? (
464
+ <div className={styles.emptyState}>
465
+ <p>No records match your current filters</p>
466
+ </div>
467
+ ) : (
468
+ paginatedRecords.map((record, index) => (
469
+ <div key={record.id || index} className={styles.recordCard}>
470
+ <div className={styles.recordHeader}>
471
+ <div className={styles.recordInfo}>
472
+ <span className={getStatusBadge(record.sync_status)}>
473
+ {getStatusIcon(record.sync_status)}
474
+ {record.sync_status?.toUpperCase() || 'UNKNOWN'}
475
+ </span>
476
+ <span className={styles.recordTitle}>
477
+ {record.display_name || record.data?.name || record.data?.email || `Record ${index + 1}`}
478
+ </span>
479
+ </div>
480
+
481
+ <div className={styles.recordActions}>
482
+ {/* Skip/UnSkip Button - Allow for all record types including 'skip' */}
483
+ {(record.sync_status === 'update' || record.sync_status === 'new' || record.sync_status === 'skip') && (
484
+ <>
485
+ {skippedRecords.has(record.id || index) ? (
486
+ <button
487
+ onClick={() => unSkipRecord(record.id || index)}
488
+ className={styles.unSkipButton}
489
+ title="Include this record in sync"
490
+ >
491
+ <CheckCircle size={14} /> Include
492
+ </button>
493
+ ) : record.sync_status === 'skip' ? (
494
+ <button
495
+ onClick={() => unSkipRecord(record.id || index)}
496
+ className={styles.includeButton}
497
+ title="Include this skipped record in sync to link with Zoho ID"
498
+ >
499
+ <CheckCircle size={14} /> Link ID
500
+ </button>
501
+ ) : (
502
+ <button
503
+ onClick={() => skipRecord(record.id || index)}
504
+ className={styles.skipButton}
505
+ title="Skip this record to prevent data loss"
506
+ >
507
+ <UserX size={14} /> Skip
508
+ </button>
509
+ )}
510
+ </>
511
+ )}
512
+
513
+ {/* Duplicate Detection for NEW records */}
514
+ {record.sync_status === 'new' && !skippedRecords.has(record.id || index) && (
515
+ <>
516
+ {findPotentialDuplicates(record).length > 0 && (
517
+ <button
518
+ onClick={() => setShowDuplicateModal({record, duplicates: findPotentialDuplicates(record)})}
519
+ className={styles.duplicateButton}
520
+ title="Potential duplicates found"
521
+ >
522
+ <Users size={14} /> Duplicates
523
+ </button>
524
+ )}
525
+
526
+ <button
527
+ onClick={() => setShowMatchModal(record.id || index)}
528
+ className={styles.linkButton}
529
+ title="Link to existing record"
530
+ >
531
+ <Link size={14} /> Link
532
+ </button>
533
+ </>
534
+ )}
535
+
536
+ <button
537
+ onClick={() => toggleDetails(record.id || index)}
538
+ className={styles.toggleButton}
539
+ >
540
+ {showDetails[record.id || index] ? (
541
+ <>Details <EyeOff size={14} /></>
542
+ ) : (
543
+ <>Details <Eye size={14} /></>
544
+ )}
545
+ </button>
546
+ </div>
547
+ </div>
548
+
549
+ {record.sync_status === 'update' && record.changes && (
550
+ <div className={styles.changesPreview}>
551
+ <strong>Changes:</strong>
552
+ <ul>
553
+ {record.changes.map((change, i) => (
554
+ <li key={i}>
555
+ <span className={styles.fieldName}>{change.field}:</span>
556
+ <span className={styles.oldValue}>{change.old_value}</span>
557
+
558
+ <span className={styles.newValue}>{change.new_value}</span>
559
+ </li>
560
+ ))}
561
+ </ul>
562
+ </div>
563
+ )}
564
+
565
+ {showDetails[record.id || index] && (
566
+ <div className={styles.recordDetails}>
567
+ <div className={styles.dataSection}>
568
+ <h4>External Data (from {provider.display_name})</h4>
569
+ <pre className={styles.jsonData}>
570
+ {JSON.stringify(record.data, null, 2)}
571
+ </pre>
572
+ </div>
573
+
574
+ {record.existing_data && (
575
+ <div className={styles.dataSection}>
576
+ <h4>Current CRM Data</h4>
577
+ <pre className={styles.jsonData}>
578
+ {JSON.stringify(record.existing_data, null, 2)}
579
+ </pre>
580
+ </div>
581
+ )}
582
+ </div>
583
+ )}
584
+ </div>
585
+ ))
586
+ )}
587
+ </div>
588
+
589
+ {/* Pagination */}
590
+ {totalPages > 1 && (
591
+ <div className={styles.paginationSection}>
592
+ <button
593
+ onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
594
+ disabled={currentPage === 1}
595
+ className={styles.paginationButton}
596
+ >
597
+ Previous
598
+ </button>
599
+
600
+ <span className={styles.paginationInfo}>
601
+ Page {currentPage} of {totalPages} ({filteredRecords.length} records)
602
+ </span>
603
+
604
+ <button
605
+ onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
606
+ disabled={currentPage === totalPages}
607
+ className={styles.paginationButton}
608
+ >
609
+ Next
610
+ </button>
611
+ </div>
612
+ )}
613
+ </>
614
+ ) : null}
615
+ </div>
616
+ );
617
+
618
+ const renderStep3 = () => {
619
+ // Calculate dynamic counts based on current selections
620
+ const recordsToSync = previewData?.records?.filter(record =>
621
+ !skippedRecords.has(record.id)
622
+ ) || [];
623
+
624
+ const dynamicCounts = {
625
+ new: recordsToSync.filter(r => r.sync_status === 'new').length,
626
+ updates: recordsToSync.filter(r => r.sync_status === 'update').length,
627
+ linked: recordsToSync.filter(r => r.sync_status === 'skip').length, // Skip records being included = linked
628
+ skipped: (previewData?.records?.length || 0) - recordsToSync.length
629
+ };
630
+
631
+ const totalChanges = dynamicCounts.new + dynamicCounts.updates + dynamicCounts.linked;
632
+
633
+ return (
634
+ <div className={styles.stepContent}>
635
+ <div className={styles.stepHeader}>
636
+ <div className={styles.stepIcon}>
637
+ <Zap size={24} />
638
+ </div>
639
+ <div className={styles.stepInfo}>
640
+ <h2>Confirm & Execute Sync</h2>
641
+ <p>Ready to import {totalChanges} changes</p>
642
+ </div>
643
+ </div>
644
+
645
+ <div className={styles.confirmationSection}>
646
+ <div className={styles.confirmationCard}>
647
+ <FileText size={48} />
648
+ <h3>Sync Summary</h3>
649
+ <div className={styles.confirmationStats}>
650
+ <div className={styles.stat}>
651
+ <span className={styles.statNumber}>{dynamicCounts.new}</span>
652
+ <span className={styles.statLabel}>New</span>
653
+ </div>
654
+ <div className={styles.stat}>
655
+ <span className={styles.statNumber}>{dynamicCounts.updates}</span>
656
+ <span className={styles.statLabel}>Updates</span>
657
+ </div>
658
+ <div className={styles.stat}>
659
+ <span className={styles.statNumber}>{dynamicCounts.linked}</span>
660
+ <span className={styles.statLabel}>Linked</span>
661
+ </div>
662
+ <div className={styles.stat}>
663
+ <span className={styles.statNumber}>{dynamicCounts.skipped}</span>
664
+ <span className={styles.statLabel}>Skipped</span>
665
+ </div>
666
+ </div>
667
+
668
+ <div className={styles.warningBox}>
669
+ <AlertTriangle size={20} />
670
+ <p>This action cannot be undone. Please ensure you have reviewed all changes.</p>
671
+ </div>
672
+ </div>
673
+ </div>
674
+ </div>
675
+ );
676
+ };
677
+
678
+ return (
679
+ <div className={styles.wizardContainer}>
680
+ {/* Progress Steps */}
681
+ <div className={styles.progressSteps}>
682
+ {steps.map((step, index) => (
683
+ <div
684
+ key={step.id}
685
+ className={`${styles.progressStep} ${
686
+ currentStep === step.id ? styles.active :
687
+ currentStep > step.id ? styles.completed : ''
688
+ }`}
689
+ >
690
+ <div className={styles.stepNumber}>
691
+ {currentStep > step.id ? <CheckCircle size={20} /> : step.icon}
692
+ </div>
693
+ <div className={styles.stepDetails}>
694
+ <h4>{step.title}</h4>
695
+ <p>{step.description}</p>
696
+ </div>
697
+ {index < steps.length - 1 && <div className={styles.progressLine} />}
698
+ </div>
699
+ ))}
700
+ </div>
701
+
702
+ {/* Step Content */}
703
+ <div className={styles.wizardContent}>
704
+ {currentStep === 1 && renderStep1()}
705
+ {currentStep === 2 && renderStep2()}
706
+ {currentStep === 3 && renderStep3()}
707
+ </div>
708
+
709
+ {/* Navigation */}
710
+ <div className={styles.wizardNavigation}>
711
+ <button
712
+ onClick={onCancel}
713
+ className={styles.cancelButton}
714
+ >
715
+ Cancel
716
+ </button>
717
+
718
+ <div className={styles.navigationButtons}>
719
+ {currentStep > 1 && (
720
+ <button
721
+ onClick={handleBack}
722
+ className={styles.backButton}
723
+ disabled={loading}
724
+ >
725
+ <ArrowLeft size={16} />
726
+ Back
727
+ </button>
728
+ )}
729
+
730
+ {currentStep < steps.length ? (
731
+ <button
732
+ onClick={handleNext}
733
+ className={styles.nextButton}
734
+ disabled={loading || (currentStep === 2 && !previewData?.success)}
735
+ >
736
+ Next
737
+ <ArrowRight size={16} />
738
+ </button>
739
+ ) : (
740
+ <button
741
+ onClick={handleComplete}
742
+ className={styles.completeButton}
743
+ disabled={loading || (!previewData?.summary?.total_changes && !previewData?.records?.some(r => !skippedRecords.has(r.id)))}
744
+ >
745
+ <Download size={16} />
746
+ {loading ? 'Syncing...' : `Import ${
747
+ (previewData?.records?.filter(record => !skippedRecords.has(record.id)) || []).length
748
+ } Changes`}
749
+ </button>
750
+ )}
751
+ </div>
752
+ </div>
753
+
754
+ {/* Manual Match Modal */}
755
+ {showMatchModal && (
756
+ <div className={styles.matchModal} onClick={() => setShowMatchModal(null)}>
757
+ <div className={styles.matchModalContent} onClick={(e) => e.stopPropagation()}>
758
+ <h3>Link Record to Existing Entry</h3>
759
+ <p>
760
+ This feature allows you to manually link this external record to an existing record in your CRM
761
+ instead of creating a new one. This is useful when the automatic matching didn't detect a duplicate.
762
+ </p>
763
+ <p><strong>Note:</strong> This is a placeholder for the manual matching interface.
764
+ The full implementation would include search functionality to find and select existing records.</p>
765
+
766
+ <div className={styles.matchModalActions}>
767
+ <button
768
+ onClick={() => setShowMatchModal(null)}
769
+ className={styles.cancelButton}
770
+ >
771
+ Cancel
772
+ </button>
773
+ <button
774
+ onClick={() => {
775
+ // Here you would implement the actual matching logic
776
+ console.log('Manual match for record:', showMatchModal);
777
+ setShowMatchModal(null);
778
+ }}
779
+ className={styles.confirmButton}
780
+ >
781
+ Link Record
782
+ </button>
783
+ </div>
784
+ </div>
785
+ </div>
786
+ )}
787
+
788
+ {/* Duplicate Detection Modal */}
789
+ {showDuplicateModal && (
790
+ <div className={styles.matchModal} onClick={() => setShowDuplicateModal(null)}>
791
+ <div className={styles.matchModalContent} onClick={(e) => e.stopPropagation()}>
792
+ <h3>Potential Duplicates Found</h3>
793
+ <p>
794
+ The following records might be duplicates of "<strong>{showDuplicateModal.record.display_name}</strong>":
795
+ </p>
796
+
797
+ <div className={styles.duplicatesList}>
798
+ {showDuplicateModal.duplicates.map((duplicate, index) => (
799
+ <div key={duplicate.id || index} className={styles.duplicateItem}>
800
+ <div className={styles.duplicateInfo}>
801
+ <span className={styles.duplicateName}>{duplicate.display_name}</span>
802
+ <span className={`${styles.duplicateStatus} ${duplicate.sync_status === 'new' ? styles.statusNew : duplicate.sync_status === 'update' ? styles.statusUpdate : styles.statusSkip}`}>
803
+ {duplicate.sync_status.toUpperCase()}
804
+ </span>
805
+ </div>
806
+ <div className={styles.duplicateActions}>
807
+ <button
808
+ onClick={() => {
809
+ skipRecord(duplicate.id || `dup_${index}`);
810
+ console.log('Skipped duplicate:', duplicate.display_name);
811
+ }}
812
+ className={styles.skipDuplicateButton}
813
+ >
814
+ Skip This
815
+ </button>
816
+ </div>
817
+ </div>
818
+ ))}
819
+ </div>
820
+
821
+ <div className={styles.matchModalActions}>
822
+ <button
823
+ onClick={() => setShowDuplicateModal(null)}
824
+ className={styles.cancelButton}
825
+ >
826
+ Keep All
827
+ </button>
828
+ <button
829
+ onClick={() => {
830
+ // Skip all duplicates except the first one
831
+ showDuplicateModal.duplicates.forEach((dup, index) => {
832
+ if (index > 0) {
833
+ skipRecord(dup.id || `dup_${index}`);
834
+ }
835
+ });
836
+ setShowDuplicateModal(null);
837
+ }}
838
+ className={styles.confirmButton}
839
+ >
840
+ Skip Duplicates
841
+ </button>
842
+ </div>
843
+ </div>
844
+ </div>
845
+ )}
846
+ </div>
847
+ );
848
+ };
849
+
850
+ export default DataSyncWizard;