@visns-studio/visns-components 5.11.2 → 5.11.4

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,1049 @@
1
+ import React, {
2
+ useState,
3
+ useEffect,
4
+ useCallback,
5
+ useRef,
6
+ useMemo,
7
+ memo,
8
+ } from 'react';
9
+ import { toast } from 'react-toastify';
10
+ import {
11
+ Search,
12
+ AlertTriangle,
13
+ Users,
14
+ ArrowRight,
15
+ CheckCircle,
16
+ XCircle,
17
+ Eye,
18
+ EyeOff,
19
+ Shuffle,
20
+ } from 'lucide-react';
21
+ import CustomFetch from './Fetch';
22
+ import MultiSelect from './MultiSelect';
23
+ import styles from './styles/MergeEntity.module.scss';
24
+
25
+ // Separate component for search input to prevent unnecessary re-renders
26
+ const SearchInput = memo(
27
+ ({ searchQuery, onSearchChange, placeholder, searchInputRef }) => {
28
+ return (
29
+ <div className={styles.searchField}>
30
+ <input
31
+ ref={searchInputRef}
32
+ type="text"
33
+ value={searchQuery}
34
+ placeholder={placeholder}
35
+ onChange={onSearchChange}
36
+ autoComplete="off"
37
+ className={styles.searchInput}
38
+ />
39
+ <Search size={20} className={styles.searchIcon} />
40
+ </div>
41
+ );
42
+ },
43
+ // Custom comparison function to prevent re-renders on irrelevant prop changes
44
+ (prevProps, nextProps) => {
45
+ return (
46
+ prevProps.searchQuery === nextProps.searchQuery &&
47
+ prevProps.placeholder === nextProps.placeholder &&
48
+ prevProps.onSearchChange === nextProps.onSearchChange
49
+ );
50
+ }
51
+ );
52
+
53
+ // Completely isolated search results component
54
+ const SearchResults = memo(
55
+ ({ searchResults, searchQuery, loading, config, onSelectEntity }) => {
56
+ if (searchResults.length > 0) {
57
+ return (
58
+ <div className={styles.searchResults}>
59
+ <h5>
60
+ {config.labels?.potentialDuplicates ||
61
+ 'Potential Duplicates Found'}
62
+ </h5>
63
+ {searchResults.map((entity) => (
64
+ <div
65
+ key={entity.id}
66
+ className={styles.searchResult}
67
+ onClick={() => onSelectEntity(entity)}
68
+ >
69
+ <div className={styles.resultInfo}>
70
+ <div className={styles.resultName}>
71
+ {entity[config.displayField || 'name']}
72
+ </div>
73
+ {config.subDisplayFields && (
74
+ <div className={styles.resultDetails}>
75
+ {config.subDisplayFields.map(
76
+ (field) =>
77
+ entity[field] && (
78
+ <span
79
+ key={field}
80
+ className={
81
+ styles.resultDetail
82
+ }
83
+ >
84
+ {entity[field]}
85
+ </span>
86
+ )
87
+ )}
88
+ </div>
89
+ )}
90
+ </div>
91
+ <ArrowRight
92
+ size={16}
93
+ className={styles.selectIcon}
94
+ />
95
+ </div>
96
+ ))}
97
+ </div>
98
+ );
99
+ }
100
+
101
+ if (searchQuery.length > 0 && !loading) {
102
+ return (
103
+ <div className={styles.noResults}>
104
+ {config.messages?.noDuplicates ||
105
+ 'No potential duplicates found. This entity appears to be unique.'}
106
+ </div>
107
+ );
108
+ }
109
+
110
+ return null;
111
+ },
112
+ // Prevent re-renders unless actual data changes
113
+ (prevProps, nextProps) => {
114
+ return (
115
+ prevProps.searchResults === nextProps.searchResults &&
116
+ prevProps.searchQuery === nextProps.searchQuery &&
117
+ prevProps.loading === nextProps.loading &&
118
+ prevProps.onSelectEntity === nextProps.onSelectEntity
119
+ );
120
+ }
121
+ );
122
+
123
+ // Completely isolated search container
124
+ const SearchContainer = memo(({
125
+ searchQuery,
126
+ onSearchChange,
127
+ searchResults,
128
+ loading,
129
+ config,
130
+ onSelectEntity,
131
+ placeholder,
132
+ searchInputRef
133
+ }) => {
134
+ return (
135
+ <div className={styles.searchSection}>
136
+ <SearchInput
137
+ searchQuery={searchQuery}
138
+ onSearchChange={onSearchChange}
139
+ placeholder={placeholder}
140
+ searchInputRef={searchInputRef}
141
+ />
142
+ <SearchResults
143
+ searchResults={searchResults}
144
+ searchQuery={searchQuery}
145
+ loading={loading}
146
+ config={config}
147
+ onSelectEntity={onSelectEntity}
148
+ />
149
+ </div>
150
+ );
151
+ });
152
+
153
+ const MergeEntity = ({ endpoints, config, dataId, onUpdate = () => {} }) => {
154
+ const [step, setStep] = useState(1); // 1: Search, 2: Select, 3: Configure, 4: Complete
155
+ const [loading, setLoading] = useState(false);
156
+ const [searchResults, setSearchResults] = useState([]);
157
+ const [selectedEntity, setSelectedEntity] = useState(null);
158
+ const [sourceEntity, setSourceEntity] = useState(null);
159
+ const [targetEntity, setTargetEntity] = useState(null);
160
+ const [conflicts, setConflicts] = useState([]);
161
+ const [searchQuery, setSearchQuery] = useState('');
162
+ const searchInputRef = useRef(null);
163
+ const searchTimeoutRef = useRef(null);
164
+ const previousActiveElement = useRef(null);
165
+ const isSearching = useRef(false);
166
+ const [mergeOptions, setMergeOptions] = useState({
167
+ includeAllRelationships: true, // Include all relationships by default
168
+ attributes: [],
169
+ exclude: ['id', 'created_at', 'updated_at', 'deleted_at'],
170
+ overwriteWithNull: false,
171
+ mergeTimestamps: false,
172
+ prioritizeSource: true,
173
+ });
174
+ const [showAdvanced, setShowAdvanced] = useState(false);
175
+
176
+ // Replace placeholders in URLs
177
+ const processTemplate = (template, data = {}) => {
178
+ let processed = template;
179
+ processed = processed.replace(/\{dataId\}/g, dataId);
180
+ Object.keys(data).forEach((key) => {
181
+ const placeholder = new RegExp(`\\{${key}\\}`, 'g');
182
+ processed = processed.replace(placeholder, data[key]);
183
+ });
184
+ return processed;
185
+ };
186
+
187
+ // Load source entity details
188
+ const loadSourceEntity = async () => {
189
+ if (!endpoints.getEntity || !dataId) return;
190
+
191
+ try {
192
+ setLoading(true);
193
+ const url = processTemplate(
194
+ endpoints.getEntity.url || endpoints.getEntity
195
+ );
196
+ const method = endpoints.getEntity.method || 'GET';
197
+ const response = await CustomFetch(url, method);
198
+
199
+ const dataPath = config.dataPath || 'data';
200
+ const entityData = dataPath
201
+ .split('.')
202
+ .reduce((obj, key) => obj?.[key], response);
203
+ setSourceEntity(entityData);
204
+ } catch (error) {
205
+ toast.error(
206
+ config.messages?.loadError || 'Failed to load entity details'
207
+ );
208
+ console.error('Error loading source entity:', error);
209
+ } finally {
210
+ setLoading(false);
211
+ }
212
+ };
213
+
214
+ // Search for potential duplicates
215
+ const searchDuplicates = useCallback(
216
+ async (searchQuery = '') => {
217
+ if (!endpoints.search) return;
218
+
219
+ try {
220
+ // Store the currently focused element before starting search
221
+ if (document.activeElement === searchInputRef.current) {
222
+ previousActiveElement.current = searchInputRef.current;
223
+ isSearching.current = true;
224
+ }
225
+
226
+ setLoading(true);
227
+ const url = processTemplate(endpoints.search.url);
228
+ const method = endpoints.search.method || 'POST';
229
+ const params = {
230
+ ...endpoints.search.params,
231
+ search: searchQuery,
232
+ exclude_id: dataId, // Exclude current entity from results
233
+ };
234
+
235
+ // Add search filters if provided - use configured search fields instead of invalid 'search' column
236
+ if (searchQuery.trim() && config.searchFields) {
237
+ params.where = params.where || [];
238
+ // Add OR conditions for each configured search field
239
+ config.searchFields.forEach((field) => {
240
+ params.where.push({
241
+ id: field,
242
+ value: searchQuery.trim(),
243
+ operator: 'contains',
244
+ });
245
+ });
246
+ }
247
+
248
+ const response = await CustomFetch(url, method, params);
249
+ const dataPath = endpoints.search.dataPath || 'data';
250
+ const rawResults =
251
+ dataPath
252
+ .split('.')
253
+ .reduce((obj, key) => obj?.[key], response) || [];
254
+
255
+ // Transform dropdown response format {id, label} to expected format
256
+ const transformedResults = rawResults.map((item) => {
257
+ if (item.id && item.label) {
258
+ // Transform dropdown format to entity format
259
+ return {
260
+ id: item.id,
261
+ [config.displayField || 'name']: item.label,
262
+ // Add any other fields that might be needed
263
+ ...item,
264
+ };
265
+ }
266
+ return item;
267
+ });
268
+
269
+ // Filter out the current entity from results to prevent self-merge
270
+ const filteredResults = transformedResults.filter(
271
+ (result) =>
272
+ result.id && result.id.toString() !== dataId.toString()
273
+ );
274
+
275
+ setSearchResults(filteredResults);
276
+ } catch (error) {
277
+ toast.error(
278
+ config.messages?.searchError ||
279
+ 'Failed to search for duplicates'
280
+ );
281
+ console.error('Error searching duplicates:', error);
282
+ } finally {
283
+ setLoading(false);
284
+
285
+ // Restore focus to search input after results are loaded
286
+ if (isSearching.current && previousActiveElement.current) {
287
+ setTimeout(() => {
288
+ try {
289
+ previousActiveElement.current.focus();
290
+ isSearching.current = false;
291
+ } catch (e) {
292
+ // Handle case where element no longer exists
293
+ }
294
+ }, 0);
295
+ }
296
+ }
297
+ },
298
+ [endpoints.search, config.searchFields, dataId, processTemplate]
299
+ );
300
+
301
+ // Load target entity details for step 2 preview
302
+ const loadTargetEntityDetails = useCallback(async (entityId) => {
303
+ console.log('=== LOAD TARGET ENTITY START ===');
304
+ console.log('Loading entity ID:', entityId);
305
+ console.log('Current source dataId:', dataId);
306
+
307
+ if (!endpoints.getEntity) {
308
+ console.error('No getEntity endpoint configured');
309
+ return;
310
+ }
311
+
312
+ try {
313
+ // Use the entityId directly in the URL template
314
+ const urlTemplate = endpoints.getEntity.url || endpoints.getEntity;
315
+ const url = urlTemplate.replace(/\{dataId\}/g, entityId);
316
+ console.log('URL template:', urlTemplate);
317
+ console.log('Final URL:', url);
318
+ console.log('Method:', endpoints.getEntity.method || 'GET');
319
+
320
+ const method = endpoints.getEntity.method || 'GET';
321
+ const response = await CustomFetch(url, method);
322
+ console.log('API Response:', response);
323
+
324
+ const dataPath = config.dataPath || 'data';
325
+ console.log('Data path:', dataPath);
326
+ const entityData = dataPath
327
+ .split('.')
328
+ .reduce((obj, key) => obj?.[key], response);
329
+ console.log('Extracted entity data:', entityData);
330
+ console.log('Entity data ID:', entityData?.id);
331
+ console.log('Does response ID match requested ID?', entityData?.id === entityId || entityData?.id?.toString() === entityId?.toString());
332
+
333
+ // Update the selectedEntity with full details for step 2 preview
334
+ setSelectedEntity(entityData);
335
+ console.log('=== LOAD TARGET ENTITY END ===\n');
336
+ } catch (error) {
337
+ console.error('Error loading target entity details:', error);
338
+ toast.error(
339
+ config.messages?.loadError ||
340
+ 'Failed to load target entity details'
341
+ );
342
+ }
343
+ }, [endpoints.getEntity, config.dataPath, dataId]);
344
+
345
+ // Memoized search change handler with stable reference
346
+ const handleSearchChange = useCallback(
347
+ (e) => {
348
+ const value = e.target.value;
349
+ setSearchQuery(value);
350
+
351
+ // Mark that user is actively searching
352
+ isSearching.current = true;
353
+ previousActiveElement.current = e.target;
354
+
355
+ // Debounce search to avoid excessive API calls
356
+ if (searchTimeoutRef.current) {
357
+ clearTimeout(searchTimeoutRef.current);
358
+ }
359
+ searchTimeoutRef.current = setTimeout(() => {
360
+ searchDuplicatesRef.current(value);
361
+ }, 300);
362
+ },
363
+ [] // Remove searchDuplicates dependency to make it completely stable
364
+ );
365
+
366
+ // Store searchDuplicates in ref to avoid dependency issues
367
+ const searchDuplicatesRef = useRef(searchDuplicates);
368
+ searchDuplicatesRef.current = searchDuplicates;
369
+
370
+ // Memoized entity selection handler
371
+ const handleSelectEntity = useCallback(async (entity) => {
372
+ console.log('=== ENTITY SELECTION START ===');
373
+ console.log('Selected entity from search:', entity);
374
+ console.log('Selected entity ID:', entity.id);
375
+ console.log('Current dataId (source):', dataId);
376
+ console.log('Are they the same?', entity.id === dataId || entity.id?.toString() === dataId?.toString());
377
+
378
+ setSelectedEntity(entity);
379
+ setStep(2);
380
+
381
+ // Load full entity details in the background
382
+ if (entity.id) {
383
+ console.log('About to load target entity details for ID:', entity.id);
384
+ await loadTargetEntityDetails(entity.id);
385
+ } else {
386
+ console.error('Selected entity has no ID:', entity);
387
+ }
388
+ console.log('=== ENTITY SELECTION END ===\n');
389
+ }, [loadTargetEntityDetails]);
390
+
391
+ // Auto-detect potential duplicates based on source entity
392
+ const autoDetectDuplicates = async () => {
393
+ if (!sourceEntity || !endpoints.detectDuplicates) {
394
+ // If no detect duplicates endpoint, skip auto-detection
395
+ console.info(
396
+ 'No duplicate detection endpoint configured, skipping auto-detection'
397
+ );
398
+ return;
399
+ }
400
+
401
+ try {
402
+ setLoading(true);
403
+ const url = processTemplate(endpoints.detectDuplicates.url);
404
+ const method = endpoints.detectDuplicates.method || 'POST';
405
+ const params = {
406
+ ...endpoints.detectDuplicates.params,
407
+ entity_id: dataId,
408
+ fields: config.duplicateDetection?.fields || ['name', 'email'],
409
+ };
410
+
411
+ const response = await CustomFetch(url, method, params);
412
+ const dataPath = endpoints.detectDuplicates.dataPath || 'data';
413
+ const results =
414
+ dataPath
415
+ .split('.')
416
+ .reduce((obj, key) => obj?.[key], response) || [];
417
+
418
+ setSearchResults(results);
419
+ if (results.length > 0) {
420
+ toast.info(`Found ${results.length} potential duplicate(s)`);
421
+ }
422
+ } catch (error) {
423
+ console.warn('Auto-duplicate detection failed:', error);
424
+ // Don't show error toast for auto-detection failures
425
+ // Fallback to empty results - user can still search manually
426
+ setSearchResults([]);
427
+ } finally {
428
+ setLoading(false);
429
+ }
430
+ };
431
+
432
+ // Load target entity details and detect conflicts for step 3
433
+ const loadTargetEntity = async (entityId) => {
434
+ if (!endpoints.getEntity) return;
435
+
436
+ try {
437
+ setLoading(true);
438
+ const url = processTemplate(
439
+ endpoints.getEntity.url || endpoints.getEntity,
440
+ { dataId: entityId }
441
+ );
442
+ const method = endpoints.getEntity.method || 'GET';
443
+ const response = await CustomFetch(url, method);
444
+
445
+ const dataPath = config.dataPath || 'data';
446
+ const entityData = dataPath
447
+ .split('.')
448
+ .reduce((obj, key) => obj?.[key], response);
449
+ setTargetEntity(entityData);
450
+
451
+ // Detect conflicts
452
+ console.log('About to detect conflicts with:', {
453
+ sourceEntity,
454
+ targetEntity: entityData,
455
+ configConflictFields: config.conflictFields
456
+ });
457
+ detectConflicts(sourceEntity, entityData);
458
+ setStep(3);
459
+ } catch (error) {
460
+ toast.error(
461
+ config.messages?.loadError ||
462
+ 'Failed to load target entity details'
463
+ );
464
+ console.error('Error loading target entity:', error);
465
+ } finally {
466
+ setLoading(false);
467
+ }
468
+ };
469
+
470
+ // Detect conflicts between source and target entities
471
+ const detectConflicts = (source, target) => {
472
+ console.log('=== CONFLICT DETECTION START ===');
473
+ console.log('Source entity:', source);
474
+ console.log('Target entity:', target);
475
+ console.log('Config conflict fields:', config.conflictFields);
476
+
477
+ if (!source || !target) {
478
+ console.log('Missing source or target entity!');
479
+ setConflicts([]);
480
+ return;
481
+ }
482
+
483
+ const conflictList = [];
484
+
485
+ // Use all fields from both entities, or specific fields from config
486
+ const allFields = new Set([
487
+ ...Object.keys(source || {}),
488
+ ...Object.keys(target || {})
489
+ ]);
490
+ const fieldsToCheck = config.conflictFields || Array.from(allFields);
491
+
492
+ console.log('Fields to check for conflicts:', fieldsToCheck);
493
+
494
+ fieldsToCheck.forEach((field) => {
495
+ const sourceValue = source?.[field];
496
+ const targetValue = target?.[field];
497
+
498
+ // Normalize values - treat null, undefined, empty string, and 'N/A' as empty
499
+ const normalizeValue = (val) => {
500
+ if (!val || val === 'N/A' || val === '' || val === null || val === undefined) {
501
+ return null;
502
+ }
503
+ return String(val).trim();
504
+ };
505
+
506
+ const normalizedSource = normalizeValue(sourceValue);
507
+ const normalizedTarget = normalizeValue(targetValue);
508
+
509
+ console.log(`\n--- Field: ${field} ---`);
510
+ console.log('Source value:', sourceValue);
511
+ console.log('Target value:', targetValue);
512
+ console.log('Normalized source:', normalizedSource);
513
+ console.log('Normalized target:', normalizedTarget);
514
+ console.log('Are they different?', normalizedSource !== normalizedTarget);
515
+
516
+ // Detect conflict if values are different (including one being empty and other having value)
517
+ if (normalizedSource !== normalizedTarget) {
518
+ const conflict = {
519
+ field,
520
+ sourceValue: sourceValue || 'N/A',
521
+ targetValue: targetValue || 'N/A',
522
+ resolution: 'source', // Default to keeping source value
523
+ };
524
+ console.log('CONFLICT FOUND:', conflict);
525
+ conflictList.push(conflict);
526
+ }
527
+ });
528
+
529
+ console.log('\n=== FINAL CONFLICT LIST ===');
530
+ console.log('Total conflicts found:', conflictList.length);
531
+ console.log('Conflicts:', conflictList);
532
+ console.log('=== CONFLICT DETECTION END ===\n');
533
+
534
+ setConflicts(conflictList);
535
+ };
536
+
537
+ // Update conflict resolution
538
+ const updateConflictResolution = (field, resolution) => {
539
+ setConflicts((prev) =>
540
+ prev.map((conflict) =>
541
+ conflict.field === field
542
+ ? { ...conflict, resolution }
543
+ : conflict
544
+ )
545
+ );
546
+ };
547
+
548
+ // Perform the merge
549
+ const performMerge = async () => {
550
+ if (!endpoints.merge || !selectedEntity) return;
551
+
552
+ try {
553
+ setLoading(true);
554
+
555
+ // Prepare merge options based on conflict resolutions
556
+ const finalOptions = { ...mergeOptions };
557
+
558
+ // Create explicit field overrides based on conflict resolutions
559
+ const fieldOverrides = {};
560
+ const excludeFields = [...(finalOptions.exclude || [])];
561
+
562
+ console.log('=== MERGE CONFLICT RESOLUTION ===');
563
+ console.log('Conflicts to resolve:', conflicts);
564
+ console.log('Source entity (current):', sourceEntity);
565
+ console.log('Target entity (selected):', selectedEntity);
566
+
567
+ // Handle conflict resolutions by creating explicit field overrides
568
+ conflicts.forEach((conflict) => {
569
+ console.log(`Processing conflict for field: ${conflict.field}`);
570
+ console.log(`Resolution choice: ${conflict.resolution}`);
571
+ console.log(`Source value: "${conflict.sourceValue}"`);
572
+ console.log(`Target value: "${conflict.targetValue}"`);
573
+
574
+ if (conflict.resolution === 'target') {
575
+ // User chose to keep the target (selected entity) value
576
+ // We need to explicitly set this field value in the source entity
577
+ const targetValue = selectedEntity?.[conflict.field];
578
+ fieldOverrides[conflict.field] = targetValue;
579
+ console.log(`Will override ${conflict.field} with target value: "${targetValue}"`);
580
+ } else {
581
+ // User chose to keep the source (current entity) value - this is default behavior
582
+ console.log(`Keeping source value for ${conflict.field}: "${sourceEntity?.[conflict.field]}"`);
583
+ }
584
+ });
585
+
586
+ console.log('Final field overrides:', fieldOverrides);
587
+ console.log('=== END MERGE CONFLICT RESOLUTION ===');
588
+
589
+ const url = processTemplate(endpoints.merge.url);
590
+ const method = endpoints.merge.method || 'POST';
591
+
592
+ // Map frontend options to backend expected format
593
+ const params = {
594
+ source_id: dataId, // Current entity is source
595
+ target_id: selectedEntity.id,
596
+ relationships: finalOptions.includeAllRelationships ? [] : (finalOptions.relationships || []), // Backend handles empty array as "all relationships"
597
+ attributes: finalOptions.attributes || [],
598
+ exclude: finalOptions.exclude || ['id', 'created_at', 'updated_at', 'deleted_at'],
599
+ overwriteWithNull: finalOptions.overwriteWithNull || false,
600
+ mergeTimestamps: finalOptions.mergeTimestamps || false,
601
+ field_overrides: fieldOverrides, // Send explicit field overrides
602
+ ...endpoints.merge.params,
603
+ };
604
+
605
+ console.log('Sending merge request with params:', params);
606
+ await CustomFetch(url, method, params);
607
+
608
+ toast.success(
609
+ config.messages?.mergeSuccess || 'Entities merged successfully'
610
+ );
611
+ setStep(4);
612
+ onUpdate();
613
+ } catch (error) {
614
+ toast.error(
615
+ config.messages?.mergeError || 'Failed to merge entities'
616
+ );
617
+ console.error('Error performing merge:', error);
618
+ } finally {
619
+ setLoading(false);
620
+ }
621
+ };
622
+
623
+ // Reset workflow
624
+ const resetWorkflow = () => {
625
+ setStep(1);
626
+ setSearchResults([]);
627
+ setSelectedEntity(null);
628
+ setTargetEntity(null);
629
+ setConflicts([]);
630
+ setSearchQuery('');
631
+ setMergeOptions({
632
+ includeAllRelationships: true,
633
+ attributes: [],
634
+ exclude: ['id', 'created_at', 'updated_at', 'deleted_at'],
635
+ overwriteWithNull: false,
636
+ mergeTimestamps: false,
637
+ prioritizeSource: true,
638
+ });
639
+ };
640
+
641
+ useEffect(() => {
642
+ if (dataId) {
643
+ loadSourceEntity();
644
+ }
645
+ }, [dataId]);
646
+
647
+ useEffect(() => {
648
+ if (sourceEntity && step === 1) {
649
+ autoDetectDuplicates();
650
+ }
651
+ }, [sourceEntity, step]);
652
+
653
+ // Effect to maintain focus on search input during re-renders
654
+ useEffect(() => {
655
+ if (isSearching.current && searchInputRef.current && step === 1) {
656
+ searchInputRef.current.focus();
657
+ }
658
+ }, [searchResults, loading, step]);
659
+
660
+ if (loading) {
661
+ return (
662
+ <div className={styles.loading}>
663
+ {config.messages?.loading || 'Loading...'}
664
+ </div>
665
+ );
666
+ }
667
+
668
+ return (
669
+ <div className={styles.container}>
670
+ <div className={styles.header}>
671
+ <h3>
672
+ <Shuffle
673
+ size={20}
674
+ style={{ marginRight: '8px', verticalAlign: 'middle' }}
675
+ />
676
+ {config.title || 'Merge Entity'}
677
+ </h3>
678
+ <p>
679
+ {config.description ||
680
+ 'Merge this entity with another to eliminate duplicates'}
681
+ </p>
682
+ </div>
683
+
684
+ {/* Progress Steps */}
685
+ <div className={styles.progressSteps}>
686
+ {[
687
+ { id: 1, label: 'Search', icon: Search },
688
+ { id: 2, label: 'Select', icon: Users },
689
+ { id: 3, label: 'Configure', icon: AlertTriangle },
690
+ { id: 4, label: 'Complete', icon: CheckCircle },
691
+ ].map(({ id, label, icon: Icon }) => (
692
+ <div
693
+ key={id}
694
+ className={`${styles.step} ${
695
+ step >= id ? styles.active : ''
696
+ } ${step === id ? styles.current : ''}`}
697
+ >
698
+ <Icon size={16} />
699
+ <span>{label}</span>
700
+ </div>
701
+ ))}
702
+ </div>
703
+
704
+ {/* Step 1: Search for Duplicates */}
705
+ {step === 1 && (
706
+ <div className={styles.stepContent}>
707
+ <h4>
708
+ {config.labels?.searchTitle || 'Search for Duplicates'}
709
+ </h4>
710
+
711
+ <div className={styles.searchSection}>
712
+ <SearchInput
713
+ searchQuery={searchQuery}
714
+ onSearchChange={handleSearchChange}
715
+ placeholder={
716
+ config.labels?.searchPlaceholder ||
717
+ 'Search for potential duplicates...'
718
+ }
719
+ searchInputRef={searchInputRef}
720
+ />
721
+ <SearchResults
722
+ searchResults={searchResults}
723
+ searchQuery={searchQuery}
724
+ loading={loading}
725
+ config={config}
726
+ onSelectEntity={handleSelectEntity}
727
+ />
728
+ </div>
729
+ </div>
730
+ )}
731
+
732
+ {/* Step 2: Confirm Selection */}
733
+ {step === 2 && selectedEntity && (
734
+ <div className={styles.stepContent}>
735
+ <h4>
736
+ {config.labels?.confirmSelection ||
737
+ 'Confirm Merge Target'}
738
+ </h4>
739
+
740
+ <div className={styles.selectionPreview}>
741
+ <div className={styles.entityCard}>
742
+ <h5>
743
+ {config.labels?.sourceEntity ||
744
+ 'Source Entity (Current)'}
745
+ </h5>
746
+ <div className={styles.entityDetails}>
747
+ <div className={styles.entityName}>
748
+ {
749
+ sourceEntity?.[
750
+ config.displayField || 'name'
751
+ ]
752
+ }
753
+ </div>
754
+ {config.previewFields?.map((field) => (
755
+ <div
756
+ key={field.key}
757
+ className={styles.entityField}
758
+ >
759
+ <span className={styles.fieldLabel}>
760
+ {field.label}:
761
+ </span>
762
+ <span className={styles.fieldValue}>
763
+ {sourceEntity?.[field.key] || 'N/A'}
764
+ </span>
765
+ </div>
766
+ ))}
767
+ </div>
768
+ </div>
769
+
770
+ <ArrowRight size={24} className={styles.mergeArrow} />
771
+
772
+ <div className={styles.entityCard}>
773
+ <h5>
774
+ {config.labels?.targetEntity ||
775
+ 'Target Entity (Merge Into)'}
776
+ </h5>
777
+ <div className={styles.entityDetails}>
778
+ <div className={styles.entityName}>
779
+ {
780
+ selectedEntity[
781
+ config.displayField || 'name'
782
+ ]
783
+ }
784
+ </div>
785
+ {config.previewFields?.map((field) => (
786
+ <div
787
+ key={field.key}
788
+ className={styles.entityField}
789
+ >
790
+ <span className={styles.fieldLabel}>
791
+ {field.label}:
792
+ </span>
793
+ <span className={styles.fieldValue}>
794
+ {selectedEntity[field.key] || 'N/A'}
795
+ </span>
796
+ </div>
797
+ ))}
798
+ </div>
799
+ </div>
800
+ </div>
801
+
802
+ <div className={styles.actionButtons}>
803
+ <button
804
+ className={styles.secondaryBtn}
805
+ onClick={() => setStep(1)}
806
+ >
807
+ {config.labels?.back || 'Back'}
808
+ </button>
809
+ <button
810
+ className={styles.primaryBtn}
811
+ onClick={() => {
812
+ console.log('Continue button clicked - detecting conflicts directly');
813
+ setTargetEntity(selectedEntity);
814
+ detectConflicts(sourceEntity, selectedEntity);
815
+ setStep(3);
816
+ }}
817
+ >
818
+ {config.labels?.continue || 'Continue'}
819
+ </button>
820
+ </div>
821
+ </div>
822
+ )}
823
+
824
+ {/* Step 3: Configure Merge (Conflicts & Options) */}
825
+ {step === 3 && (
826
+ <div className={styles.stepContent}>
827
+ <h4>
828
+ {config.labels?.conflictResolution ||
829
+ 'Configure Merge'}
830
+ </h4>
831
+
832
+ {conflicts.length > 0 ? (
833
+ <div className={styles.conflictsSection}>
834
+ <div className={styles.conflictNote}>
835
+ <AlertTriangle size={16} />
836
+ <span>
837
+ {config.messages?.conflictsFound ||
838
+ `Found ${conflicts.length} field(s) with different values. Choose which value to keep.`}
839
+ </span>
840
+ </div>
841
+
842
+ {conflicts.map((conflict) => (
843
+ <div
844
+ key={conflict.field}
845
+ className={styles.conflictItem}
846
+ >
847
+ <div className={styles.conflictField}>
848
+ <strong>
849
+ {config.fieldLabels?.[
850
+ conflict.field
851
+ ] || conflict.field}
852
+ </strong>
853
+ </div>
854
+ <div className={styles.conflictOptions}>
855
+ <label
856
+ className={styles.conflictOption}
857
+ >
858
+ <input
859
+ type="radio"
860
+ name={`conflict-${conflict.field}`}
861
+ value="source"
862
+ checked={
863
+ conflict.resolution ===
864
+ 'source'
865
+ }
866
+ onChange={() =>
867
+ updateConflictResolution(
868
+ conflict.field,
869
+ 'source'
870
+ )
871
+ }
872
+ />
873
+ <div
874
+ className={styles.optionContent}
875
+ >
876
+ <span
877
+ className={
878
+ styles.optionLabel
879
+ }
880
+ >
881
+ Keep Current
882
+ </span>
883
+ <span
884
+ className={
885
+ styles.optionValue
886
+ }
887
+ >
888
+ {conflict.sourceValue}
889
+ </span>
890
+ </div>
891
+ </label>
892
+ <label
893
+ className={styles.conflictOption}
894
+ >
895
+ <input
896
+ type="radio"
897
+ name={`conflict-${conflict.field}`}
898
+ value="target"
899
+ checked={
900
+ conflict.resolution ===
901
+ 'target'
902
+ }
903
+ onChange={() =>
904
+ updateConflictResolution(
905
+ conflict.field,
906
+ 'target'
907
+ )
908
+ }
909
+ />
910
+ <div
911
+ className={styles.optionContent}
912
+ >
913
+ <span
914
+ className={
915
+ styles.optionLabel
916
+ }
917
+ >
918
+ Use Target
919
+ </span>
920
+ <span
921
+ className={
922
+ styles.optionValue
923
+ }
924
+ >
925
+ {conflict.targetValue}
926
+ </span>
927
+ </div>
928
+ </label>
929
+ </div>
930
+ </div>
931
+ ))}
932
+ </div>
933
+ ) : (
934
+ <div className={styles.noConflicts}>
935
+ <CheckCircle
936
+ size={24}
937
+ className={styles.successIcon}
938
+ />
939
+ <p>
940
+ {config.messages?.noConflicts ||
941
+ 'No conflicts detected. The merge can proceed safely.'}
942
+ </p>
943
+ </div>
944
+ )}
945
+
946
+ {/* Advanced Options */}
947
+ <div className={styles.advancedSection}>
948
+ <button
949
+ className={styles.advancedToggle}
950
+ onClick={() => setShowAdvanced(!showAdvanced)}
951
+ aria-expanded={showAdvanced}
952
+ aria-controls="advanced-options"
953
+ >
954
+ {showAdvanced ? (
955
+ <EyeOff size={16} />
956
+ ) : (
957
+ <Eye size={16} />
958
+ )}
959
+ {config.labels?.advancedOptions ||
960
+ 'Advanced Options'}
961
+ </button>
962
+
963
+ {showAdvanced && (
964
+ <div id="advanced-options" className={styles.advancedOptions}>
965
+ <label className={styles.option}>
966
+ <input
967
+ type="checkbox"
968
+ checked={mergeOptions.mergeTimestamps}
969
+ onChange={(e) =>
970
+ setMergeOptions((prev) => ({
971
+ ...prev,
972
+ mergeTimestamps:
973
+ e.target.checked,
974
+ }))
975
+ }
976
+ />
977
+ {config.labels?.mergeTimestamps ||
978
+ 'Merge timestamp fields'}
979
+ </label>
980
+
981
+ <label className={styles.option}>
982
+ <input
983
+ type="checkbox"
984
+ checked={mergeOptions.overwriteWithNull}
985
+ onChange={(e) =>
986
+ setMergeOptions((prev) => ({
987
+ ...prev,
988
+ overwriteWithNull:
989
+ e.target.checked,
990
+ }))
991
+ }
992
+ />
993
+ {config.labels?.overwriteWithNull ||
994
+ 'Allow overwriting with null values'}
995
+ </label>
996
+
997
+ <div className={styles.note}>
998
+ <p>Note: All related data will be included in the merge automatically.</p>
999
+ </div>
1000
+ </div>
1001
+ )}
1002
+ </div>
1003
+
1004
+ <div className={styles.actionButtons}>
1005
+ <button
1006
+ className={styles.secondaryBtn}
1007
+ onClick={() => setStep(2)}
1008
+ >
1009
+ {config.labels?.back || 'Back'}
1010
+ </button>
1011
+ <button
1012
+ className={styles.primaryBtn}
1013
+ onClick={performMerge}
1014
+ >
1015
+ {config.labels?.performMerge || 'Perform Merge'}
1016
+ </button>
1017
+ </div>
1018
+ </div>
1019
+ )}
1020
+
1021
+ {/* Step 4: Complete */}
1022
+ {step === 4 && (
1023
+ <div className={styles.stepContent}>
1024
+ <div className={styles.successMessage}>
1025
+ <CheckCircle size={48} className={styles.successIcon} />
1026
+ <h4>
1027
+ {config.labels?.mergeComplete || 'Merge Complete!'}
1028
+ </h4>
1029
+ <p>
1030
+ {config.messages?.mergeCompleteDescription ||
1031
+ 'The entities have been successfully merged. The source entity data has been consolidated into the target entity.'}
1032
+ </p>
1033
+
1034
+ <div className={styles.actionButtons}>
1035
+ <button
1036
+ className={styles.secondaryBtn}
1037
+ onClick={resetWorkflow}
1038
+ >
1039
+ {config.labels?.mergeAnother || 'Merge Another'}
1040
+ </button>
1041
+ </div>
1042
+ </div>
1043
+ </div>
1044
+ )}
1045
+ </div>
1046
+ );
1047
+ };
1048
+
1049
+ export default MergeEntity;