@visns-studio/visns-components 5.15.9 → 5.15.11
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/README.md +40 -2
- package/package.json +1 -1
- package/src/components/Breadcrumb.jsx +19 -11
- package/src/components/CameraPlacement.jsx +292 -2
- package/src/components/Form.jsx +240 -95
- package/src/components/generic/GenericReport.jsx +379 -5
- package/src/components/oauth/DataPreviewModal.jsx +372 -0
- package/src/components/oauth/DataSyncWizard.jsx +850 -0
- package/src/components/oauth/OAuthConnectionStatus.jsx +85 -0
- package/src/components/oauth/OAuthIntegrationsPage.jsx +185 -0
- package/src/components/oauth/OAuthManager.jsx +260 -0
- package/src/components/oauth/OAuthProviderCard.jsx +291 -0
- package/src/components/styles/CameraPlacement.module.scss +13 -2
- package/src/components/styles/DataPreviewModal.module.scss +658 -0
- package/src/components/styles/DataSyncWizard.module.scss +1211 -0
- package/src/components/styles/GenericReport.module.scss +342 -0
- package/src/components/styles/OAuthConnectionStatus.module.scss +143 -0
- package/src/components/styles/OAuthManager.module.scss +119 -0
- package/src/components/styles/OAuthProviderCard.module.scss +760 -0
- package/src/index.js +12 -0
|
@@ -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;
|