@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.
- package/README.md +55 -0
- package/package.json +1 -1
- package/src/components/crm/AssociationManager.jsx +431 -0
- package/src/components/crm/BusinessCardOcr.jsx +190 -6
- package/src/components/crm/ClientAssociationManager.jsx +362 -0
- package/src/components/crm/MergeEntity.jsx +1049 -0
- package/src/components/crm/MultiSelect.jsx +14 -3
- package/src/components/crm/Navigation.jsx +67 -8
- package/src/components/crm/cells/CellWithTooltip.jsx +35 -28
- package/src/components/crm/generic/GenericDetail.jsx +25 -0
- package/src/components/crm/generic/GenericReport.jsx +1457 -434
- package/src/components/crm/generic/styles/GenericReport.module.scss +598 -24
- package/src/components/crm/styles/AssociationManager.module.scss +364 -0
- package/src/components/crm/styles/BusinessCardOcr.module.scss +362 -94
- package/src/components/crm/styles/ClientAssociationManager.module.scss +290 -0
- package/src/components/crm/styles/MergeEntity.module.scss +690 -0
- package/src/components/crm/styles/MultiSelect.module.scss +18 -0
- package/src/components/crm/styles/Navigation.module.scss +148 -16
- package/src/components/crm/styles/global-datagrid.css +9 -0
- package/src/index.js +6 -0
|
@@ -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;
|