@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
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
Zap,
|
|
19
19
|
ZapOff,
|
|
20
20
|
Focus,
|
|
21
|
+
Bot,
|
|
22
|
+
Play,
|
|
21
23
|
} from 'lucide-react';
|
|
22
24
|
|
|
23
25
|
import CustomFetch from './Fetch';
|
|
@@ -50,10 +52,20 @@ const BusinessCardOcr = ({
|
|
|
50
52
|
const [contactDecision, setContactDecision] = useState(null); // 'create', 'update', 'merge'
|
|
51
53
|
const [isConsulting, setIsConsulting] = useState(false);
|
|
52
54
|
const [isSaving, setIsSaving] = useState(false);
|
|
55
|
+
|
|
56
|
+
// Auto-capture states
|
|
57
|
+
const [autoCapture, setAutoCapture] = useState(true);
|
|
58
|
+
const [focusScore, setFocusScore] = useState(0);
|
|
59
|
+
const [isCountingDown, setIsCountingDown] = useState(false);
|
|
60
|
+
const [countdown, setCountdown] = useState(0);
|
|
61
|
+
const [focusThreshold] = useState(0.15); // Adjustable focus threshold
|
|
53
62
|
|
|
54
63
|
const videoRef = useRef(null);
|
|
55
64
|
const canvasRef = useRef(null);
|
|
56
65
|
const fileInputRef = useRef(null);
|
|
66
|
+
const focusDetectionRef = useRef(null);
|
|
67
|
+
const countdownRef = useRef(null);
|
|
68
|
+
const capturePhotoRef = useRef(null);
|
|
57
69
|
|
|
58
70
|
// Detect available cameras when component opens
|
|
59
71
|
useEffect(() => {
|
|
@@ -203,6 +215,121 @@ const BusinessCardOcr = ({
|
|
|
203
215
|
}
|
|
204
216
|
};
|
|
205
217
|
|
|
218
|
+
// Calculate image sharpness/focus score using Laplacian variance
|
|
219
|
+
const calculateFocusScore = useCallback((videoElement) => {
|
|
220
|
+
if (!videoElement || videoElement.videoWidth === 0) return 0;
|
|
221
|
+
|
|
222
|
+
const canvas = document.createElement('canvas');
|
|
223
|
+
const ctx = canvas.getContext('2d');
|
|
224
|
+
|
|
225
|
+
// Use smaller canvas for performance
|
|
226
|
+
const width = 320;
|
|
227
|
+
const height = 240;
|
|
228
|
+
canvas.width = width;
|
|
229
|
+
canvas.height = height;
|
|
230
|
+
|
|
231
|
+
ctx.drawImage(videoElement, 0, 0, width, height);
|
|
232
|
+
const imageData = ctx.getImageData(0, 0, width, height);
|
|
233
|
+
const data = imageData.data;
|
|
234
|
+
|
|
235
|
+
// Convert to grayscale and apply Laplacian operator
|
|
236
|
+
let sum = 0;
|
|
237
|
+
let count = 0;
|
|
238
|
+
|
|
239
|
+
for (let y = 1; y < height - 1; y++) {
|
|
240
|
+
for (let x = 1; x < width - 1; x++) {
|
|
241
|
+
const i = (y * width + x) * 4;
|
|
242
|
+
|
|
243
|
+
// Convert to grayscale
|
|
244
|
+
const gray = data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114;
|
|
245
|
+
|
|
246
|
+
// Apply Laplacian operator (edge detection)
|
|
247
|
+
const laplacian = Math.abs(
|
|
248
|
+
-4 * gray +
|
|
249
|
+
data[((y - 1) * width + x) * 4] * 0.299 + data[((y - 1) * width + x) * 4 + 1] * 0.587 + data[((y - 1) * width + x) * 4 + 2] * 0.114 +
|
|
250
|
+
data[((y + 1) * width + x) * 4] * 0.299 + data[((y + 1) * width + x) * 4 + 1] * 0.587 + data[((y + 1) * width + x) * 4 + 2] * 0.114 +
|
|
251
|
+
data[(y * width + (x - 1)) * 4] * 0.299 + data[(y * width + (x - 1)) * 4 + 1] * 0.587 + data[(y * width + (x - 1)) * 4 + 2] * 0.114 +
|
|
252
|
+
data[(y * width + (x + 1)) * 4] * 0.299 + data[(y * width + (x + 1)) * 4 + 1] * 0.587 + data[(y * width + (x + 1)) * 4 + 2] * 0.114
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
sum += laplacian * laplacian;
|
|
256
|
+
count++;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Return normalized variance (higher = sharper)
|
|
261
|
+
return count > 0 ? Math.sqrt(sum / count) / 255 : 0;
|
|
262
|
+
}, []);
|
|
263
|
+
|
|
264
|
+
// Auto-capture countdown logic
|
|
265
|
+
const startCountdown = useCallback(async () => {
|
|
266
|
+
if (isCountingDown || isFocusing) return;
|
|
267
|
+
|
|
268
|
+
setIsCountingDown(true);
|
|
269
|
+
|
|
270
|
+
for (let i = 3; i > 0; i--) {
|
|
271
|
+
setCountdown(i);
|
|
272
|
+
await new Promise(resolve => {
|
|
273
|
+
countdownRef.current = setTimeout(resolve, 1000);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Check if still focused during countdown
|
|
277
|
+
if (videoRef.current) {
|
|
278
|
+
const currentFocus = calculateFocusScore(videoRef.current);
|
|
279
|
+
if (currentFocus < focusThreshold) {
|
|
280
|
+
setIsCountingDown(false);
|
|
281
|
+
setCountdown(0);
|
|
282
|
+
return; // Cancel if focus lost
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
setCountdown(0);
|
|
288
|
+
setIsCountingDown(false);
|
|
289
|
+
|
|
290
|
+
// Trigger capture using ref
|
|
291
|
+
if (capturePhotoRef.current) {
|
|
292
|
+
await capturePhotoRef.current();
|
|
293
|
+
}
|
|
294
|
+
}, [isCountingDown, isFocusing, calculateFocusScore, focusThreshold]);
|
|
295
|
+
|
|
296
|
+
// Focus detection loop
|
|
297
|
+
useEffect(() => {
|
|
298
|
+
if (!videoRef.current || !cameraReady || !autoCapture || currentStep !== 'camera') {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const detectFocus = () => {
|
|
303
|
+
if (videoRef.current && !isCountingDown && !isFocusing) {
|
|
304
|
+
const score = calculateFocusScore(videoRef.current);
|
|
305
|
+
setFocusScore(score);
|
|
306
|
+
|
|
307
|
+
// Start countdown if image is in focus
|
|
308
|
+
if (score > focusThreshold && !isCountingDown) {
|
|
309
|
+
startCountdown();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Check focus every 500ms
|
|
315
|
+
focusDetectionRef.current = setInterval(detectFocus, 500);
|
|
316
|
+
|
|
317
|
+
return () => {
|
|
318
|
+
if (focusDetectionRef.current) {
|
|
319
|
+
clearInterval(focusDetectionRef.current);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
}, [cameraReady, autoCapture, currentStep, isCountingDown, isFocusing, calculateFocusScore, focusThreshold, startCountdown]);
|
|
323
|
+
|
|
324
|
+
// Cleanup countdown on unmount
|
|
325
|
+
useEffect(() => {
|
|
326
|
+
return () => {
|
|
327
|
+
if (countdownRef.current) {
|
|
328
|
+
clearTimeout(countdownRef.current);
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
}, []);
|
|
332
|
+
|
|
206
333
|
// Image preprocessing pipeline
|
|
207
334
|
const preprocessImage = async (imageBlob) => {
|
|
208
335
|
return new Promise((resolve) => {
|
|
@@ -274,6 +401,11 @@ const BusinessCardOcr = ({
|
|
|
274
401
|
);
|
|
275
402
|
}, [cameraStream]);
|
|
276
403
|
|
|
404
|
+
// Assign capturePhoto to ref for use in startCountdown
|
|
405
|
+
useEffect(() => {
|
|
406
|
+
capturePhotoRef.current = capturePhoto;
|
|
407
|
+
}, [capturePhoto]);
|
|
408
|
+
|
|
277
409
|
const switchCamera = () => {
|
|
278
410
|
if (availableCameras.length > 1) {
|
|
279
411
|
setCurrentCameraIndex(
|
|
@@ -1652,7 +1784,20 @@ const BusinessCardOcr = ({
|
|
|
1652
1784
|
{currentStep === 'camera' && (
|
|
1653
1785
|
<div className={ocrStyles.cameraContainer}>
|
|
1654
1786
|
<div className={ocrStyles.captureGuide}>
|
|
1655
|
-
<p>
|
|
1787
|
+
<p>
|
|
1788
|
+
{autoCapture
|
|
1789
|
+
? 'Position business card within frame - auto-capture when focused'
|
|
1790
|
+
: 'Position business card within the frame and tap to focus'
|
|
1791
|
+
}
|
|
1792
|
+
</p>
|
|
1793
|
+
<button
|
|
1794
|
+
onClick={() => setAutoCapture(!autoCapture)}
|
|
1795
|
+
className={`${ocrStyles.autoCaptureToggle} ${autoCapture ? ocrStyles.active : ''}`}
|
|
1796
|
+
title={autoCapture ? 'Disable auto-capture' : 'Enable auto-capture'}
|
|
1797
|
+
>
|
|
1798
|
+
{autoCapture ? <Bot size={16} /> : <Play size={16} />}
|
|
1799
|
+
{autoCapture ? 'Auto' : 'Manual'}
|
|
1800
|
+
</button>
|
|
1656
1801
|
</div>
|
|
1657
1802
|
<div className={ocrStyles.videoWrapper}>
|
|
1658
1803
|
<video
|
|
@@ -1663,15 +1808,54 @@ const BusinessCardOcr = ({
|
|
|
1663
1808
|
className={ocrStyles.cameraVideo}
|
|
1664
1809
|
onClick={triggerAutofocus}
|
|
1665
1810
|
/>
|
|
1811
|
+
<canvas
|
|
1812
|
+
ref={canvasRef}
|
|
1813
|
+
style={{ display: 'none' }}
|
|
1814
|
+
/>
|
|
1666
1815
|
{/* Enhanced camera overlay with focus frame */}
|
|
1667
1816
|
<div className={ocrStyles.cameraOverlay}>
|
|
1668
|
-
<div className={ocrStyles.focusFrame}
|
|
1817
|
+
<div className={`${ocrStyles.focusFrame} ${
|
|
1818
|
+
focusScore > focusThreshold ? ocrStyles.focused : ''
|
|
1819
|
+
} ${isCountingDown ? ocrStyles.countingDown : ''}`}>
|
|
1820
|
+
|
|
1821
|
+
{/* Focus corners */}
|
|
1822
|
+
<div className={`${ocrStyles.focusCorner} ${ocrStyles.topLeft}`}></div>
|
|
1823
|
+
<div className={`${ocrStyles.focusCorner} ${ocrStyles.topRight}`}></div>
|
|
1824
|
+
<div className={`${ocrStyles.focusCorner} ${ocrStyles.bottomLeft}`}></div>
|
|
1825
|
+
<div className={`${ocrStyles.focusCorner} ${ocrStyles.bottomRight}`}></div>
|
|
1826
|
+
|
|
1827
|
+
{/* Countdown display */}
|
|
1828
|
+
{isCountingDown && countdown > 0 && (
|
|
1829
|
+
<div className={ocrStyles.countdownDisplay}>
|
|
1830
|
+
<div className={ocrStyles.countdownNumber}>{countdown}</div>
|
|
1831
|
+
<div className={ocrStyles.countdownText}>Auto-capturing...</div>
|
|
1832
|
+
</div>
|
|
1833
|
+
)}
|
|
1834
|
+
|
|
1835
|
+
{/* Focus indicator */}
|
|
1669
1836
|
{isFocusing && (
|
|
1670
1837
|
<div className={ocrStyles.focusIndicator}>
|
|
1671
1838
|
<Focus className={ocrStyles.focusIcon} />
|
|
1672
1839
|
<span>Focusing...</span>
|
|
1673
1840
|
</div>
|
|
1674
1841
|
)}
|
|
1842
|
+
|
|
1843
|
+
{/* Focus status */}
|
|
1844
|
+
{autoCapture && !isFocusing && !isCountingDown && (
|
|
1845
|
+
<div className={ocrStyles.focusStatus}>
|
|
1846
|
+
<div className={`${ocrStyles.focusBar} ${
|
|
1847
|
+
focusScore > focusThreshold ? ocrStyles.good : ocrStyles.poor
|
|
1848
|
+
}`}>
|
|
1849
|
+
<div
|
|
1850
|
+
className={ocrStyles.focusBarFill}
|
|
1851
|
+
style={{ width: `${Math.min(focusScore * 300, 100)}%` }}
|
|
1852
|
+
></div>
|
|
1853
|
+
</div>
|
|
1854
|
+
<span className={ocrStyles.focusText}>
|
|
1855
|
+
{focusScore > focusThreshold ? '✓ In Focus' : 'Move closer or adjust angle'}
|
|
1856
|
+
</span>
|
|
1857
|
+
</div>
|
|
1858
|
+
)}
|
|
1675
1859
|
</div>
|
|
1676
1860
|
</div>
|
|
1677
1861
|
</div>
|
|
@@ -1704,8 +1888,9 @@ const BusinessCardOcr = ({
|
|
|
1704
1888
|
fileInputRef.current?.click()
|
|
1705
1889
|
}
|
|
1706
1890
|
className={ocrStyles.secondaryButton}
|
|
1891
|
+
title="Upload image from device"
|
|
1707
1892
|
>
|
|
1708
|
-
<ImagePlus size={
|
|
1893
|
+
<ImagePlus size={18} />
|
|
1709
1894
|
Upload
|
|
1710
1895
|
</button>
|
|
1711
1896
|
|
|
@@ -1713,9 +1898,10 @@ const BusinessCardOcr = ({
|
|
|
1713
1898
|
onClick={capturePhoto}
|
|
1714
1899
|
className={`${ocrStyles.captureButton} ${isFocusing ? ocrStyles.focusing : ''}`}
|
|
1715
1900
|
disabled={isFocusing}
|
|
1901
|
+
title={isFocusing ? 'Focusing camera...' : 'Capture business card'}
|
|
1716
1902
|
>
|
|
1717
1903
|
<Camera size={28} />
|
|
1718
|
-
{isFocusing ? '
|
|
1904
|
+
{isFocusing ? 'Focus...' : 'Capture'}
|
|
1719
1905
|
</button>
|
|
1720
1906
|
|
|
1721
1907
|
<input
|
|
@@ -1725,8 +1911,6 @@ const BusinessCardOcr = ({
|
|
|
1725
1911
|
accept="image/jpeg,image/png,image/jpg,image/gif,image/webp"
|
|
1726
1912
|
style={{ display: 'none' }}
|
|
1727
1913
|
/>
|
|
1728
|
-
|
|
1729
|
-
<div className={ocrStyles.placeholder}></div>
|
|
1730
1914
|
</div>
|
|
1731
1915
|
</div>
|
|
1732
1916
|
</div>
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { toast } from 'react-toastify';
|
|
3
|
+
import CustomFetch from './Fetch';
|
|
4
|
+
import MultiSelect from './MultiSelect';
|
|
5
|
+
import styles from './styles/ClientAssociationManager.module.scss';
|
|
6
|
+
|
|
7
|
+
const ClientAssociationManager = ({
|
|
8
|
+
endpoints,
|
|
9
|
+
config,
|
|
10
|
+
dataId,
|
|
11
|
+
onUpdate = () => {}
|
|
12
|
+
}) => {
|
|
13
|
+
const [associations, setAssociations] = useState([]);
|
|
14
|
+
const [availableOptions, setAvailableOptions] = useState([]);
|
|
15
|
+
const [loading, setLoading] = useState(true);
|
|
16
|
+
|
|
17
|
+
// Dynamic state based on config
|
|
18
|
+
const [newAssociation, setNewAssociation] = useState(() => {
|
|
19
|
+
const initialState = {};
|
|
20
|
+
|
|
21
|
+
// Set up dynamic fields based on config
|
|
22
|
+
if (config.fields) {
|
|
23
|
+
config.fields.forEach(field => {
|
|
24
|
+
initialState[field.id] = field.defaultValue || (field.type === 'boolean' ? false :
|
|
25
|
+
field.type === 'select' ? (field.options?.[0]?.value || '') :
|
|
26
|
+
field.type === 'multi-select' ? [] : '');
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return initialState;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Replace placeholders in endpoint URLs and data
|
|
34
|
+
const processTemplate = (template, data = {}) => {
|
|
35
|
+
let processed = template;
|
|
36
|
+
|
|
37
|
+
// Replace {dataId} with current dataId
|
|
38
|
+
processed = processed.replace(/\{dataId\}/g, dataId);
|
|
39
|
+
|
|
40
|
+
// Replace any other placeholders from provided data
|
|
41
|
+
Object.keys(data).forEach(key => {
|
|
42
|
+
const placeholder = new RegExp(`\\{${key}\\}`, 'g');
|
|
43
|
+
processed = processed.replace(placeholder, data[key]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return processed;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Load existing associations
|
|
50
|
+
const loadAssociations = async () => {
|
|
51
|
+
if (!endpoints.list) return;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
setLoading(true);
|
|
55
|
+
const url = processTemplate(endpoints.list);
|
|
56
|
+
const method = endpoints.listMethod || 'GET';
|
|
57
|
+
const response = await CustomFetch(url, method, endpoints.listParams || {});
|
|
58
|
+
|
|
59
|
+
const dataPath = config.dataPath || 'data';
|
|
60
|
+
const associationsData = dataPath.split('.').reduce((obj, key) => obj?.[key], response);
|
|
61
|
+
|
|
62
|
+
// Ensure associationsData is always an array
|
|
63
|
+
const validAssociations = Array.isArray(associationsData) ? associationsData : [];
|
|
64
|
+
setAssociations(validAssociations);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
toast.error(config.messages?.loadError || 'Failed to load associations');
|
|
67
|
+
console.error('Error loading associations:', error);
|
|
68
|
+
setAssociations([]); // Set empty array on error
|
|
69
|
+
} finally {
|
|
70
|
+
setLoading(false);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Load available options for dropdown/select fields
|
|
75
|
+
const loadAvailableOptions = async () => {
|
|
76
|
+
if (!config.optionsEndpoint) return;
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const url = processTemplate(config.optionsEndpoint.url);
|
|
80
|
+
const method = config.optionsEndpoint.method || 'POST';
|
|
81
|
+
const params = config.optionsEndpoint.params || {};
|
|
82
|
+
|
|
83
|
+
const response = await CustomFetch(url, method, params);
|
|
84
|
+
const dataPath = config.optionsEndpoint.dataPath || 'data.data';
|
|
85
|
+
const optionsData = dataPath.split('.').reduce((obj, key) => obj?.[key], response) || [];
|
|
86
|
+
|
|
87
|
+
setAvailableOptions(optionsData);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.error('Failed to load options:', error);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Generic action handler
|
|
94
|
+
const handleAction = async (actionKey, data = {}) => {
|
|
95
|
+
const endpoint = endpoints[actionKey];
|
|
96
|
+
if (!endpoint) return;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const url = processTemplate(endpoint.url || endpoint, data);
|
|
100
|
+
const method = endpoint.method || 'POST';
|
|
101
|
+
const params = { ...endpoint.params, ...data };
|
|
102
|
+
|
|
103
|
+
await CustomFetch(url, method, params);
|
|
104
|
+
|
|
105
|
+
const successMessage = config.messages?.[`${actionKey}Success`] ||
|
|
106
|
+
endpoint.successMessage ||
|
|
107
|
+
'Action completed successfully';
|
|
108
|
+
toast.success(successMessage);
|
|
109
|
+
|
|
110
|
+
// Reset form if it's an associate action
|
|
111
|
+
if (actionKey === 'associate') {
|
|
112
|
+
const resetState = {};
|
|
113
|
+
if (config.fields) {
|
|
114
|
+
config.fields.forEach(field => {
|
|
115
|
+
resetState[field.id] = field.defaultValue || (field.type === 'boolean' ? false :
|
|
116
|
+
field.type === 'select' ? (field.options?.[0]?.value || '') :
|
|
117
|
+
field.type === 'multi-select' ? [] : '');
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
setNewAssociation(resetState);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
loadAssociations();
|
|
124
|
+
onUpdate();
|
|
125
|
+
} catch (error) {
|
|
126
|
+
const errorMessage = config.messages?.[`${actionKey}Error`] ||
|
|
127
|
+
endpoint.errorMessage ||
|
|
128
|
+
'Action failed';
|
|
129
|
+
toast.error(errorMessage);
|
|
130
|
+
console.error(error);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Associate handler
|
|
135
|
+
const handleAssociate = async () => {
|
|
136
|
+
// Validate required fields
|
|
137
|
+
const requiredField = config.fields?.find(field => field.required);
|
|
138
|
+
const primaryField = config.fields?.find(field => field.isPrimary);
|
|
139
|
+
|
|
140
|
+
if (requiredField && !newAssociation[requiredField.id]) {
|
|
141
|
+
toast.error(`Please select a ${requiredField.label || requiredField.id}`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Prepare data for submission
|
|
146
|
+
const submitData = { ...newAssociation };
|
|
147
|
+
|
|
148
|
+
// Handle complex field values (like objects from MultiSelect)
|
|
149
|
+
Object.keys(submitData).forEach(key => {
|
|
150
|
+
const value = submitData[key];
|
|
151
|
+
if (value && typeof value === 'object' && value.value !== undefined) {
|
|
152
|
+
submitData[key] = value.value;
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await handleAction('associate', submitData);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Remove association handler
|
|
160
|
+
const handleDissociate = async (itemId) => {
|
|
161
|
+
const deleteField = config.deleteField || 'id';
|
|
162
|
+
await handleAction('dissociate', { [deleteField]: itemId });
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Set primary handler
|
|
166
|
+
const handleSetPrimary = async (itemId) => {
|
|
167
|
+
if (!endpoints.setPrimary) return;
|
|
168
|
+
|
|
169
|
+
const primaryField = config.fields?.find(field => field.isPrimary);
|
|
170
|
+
const idField = primaryField?.targetField || 'id';
|
|
171
|
+
|
|
172
|
+
await handleAction('setPrimary', { [idField]: itemId });
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (dataId) {
|
|
177
|
+
loadAssociations();
|
|
178
|
+
loadAvailableOptions();
|
|
179
|
+
}
|
|
180
|
+
}, [dataId]);
|
|
181
|
+
|
|
182
|
+
if (loading) {
|
|
183
|
+
return <div className={styles.loading}>{config.messages?.loading || 'Loading associations...'}</div>;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<div className={styles.container}>
|
|
188
|
+
<div className={styles.header}>
|
|
189
|
+
<h3>{config.title || 'Associations'}</h3>
|
|
190
|
+
<p>{config.description || 'Manage associations'}</p>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
{/* Existing Associations */}
|
|
194
|
+
<div className={styles.existingAssociations}>
|
|
195
|
+
<h4>{config.labels?.currentAssociations || 'Current Associations'}</h4>
|
|
196
|
+
{associations.length === 0 ? (
|
|
197
|
+
<div className={styles.noAssociations}>
|
|
198
|
+
{config.messages?.noAssociations || 'No associations found.'}
|
|
199
|
+
</div>
|
|
200
|
+
) : (
|
|
201
|
+
<div className={styles.associationsList}>
|
|
202
|
+
{associations.map((association) => {
|
|
203
|
+
const nameField = config.displayFields?.name || 'name';
|
|
204
|
+
const primaryField = config.displayFields?.primary || 'is_primary';
|
|
205
|
+
const relationshipField = config.displayFields?.relationship || 'relationship_type';
|
|
206
|
+
const notesField = config.displayFields?.notes || 'notes';
|
|
207
|
+
const dateField = config.displayFields?.date || 'associated_at';
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div
|
|
211
|
+
key={association.id}
|
|
212
|
+
className={`${styles.associationItem} ${association[primaryField] ? styles.primary : ''}`}
|
|
213
|
+
>
|
|
214
|
+
<div className={styles.associationInfo}>
|
|
215
|
+
<div className={styles.companyName}>
|
|
216
|
+
{association[nameField]}
|
|
217
|
+
{association[primaryField] && (
|
|
218
|
+
<span className={styles.primaryBadge}>{config.labels?.primaryBadge || 'Primary'}</span>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
<div className={styles.relationshipInfo}>
|
|
222
|
+
<span className={styles.relationshipType}>
|
|
223
|
+
{association[relationshipField]}
|
|
224
|
+
</span>
|
|
225
|
+
{association[notesField] && (
|
|
226
|
+
<span className={styles.notes}>
|
|
227
|
+
- {association[notesField]}
|
|
228
|
+
</span>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
<div className={styles.associatedAt}>
|
|
232
|
+
{config.labels?.associatedAt || 'Associated'}: {new Date(association[dateField]).toLocaleDateString()}
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
<div className={styles.associationActions}>
|
|
236
|
+
{!association[primaryField] && endpoints.setPrimary && (
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
className={styles.setPrimaryBtn}
|
|
240
|
+
onClick={() => handleSetPrimary(association.id)}
|
|
241
|
+
>
|
|
242
|
+
{config.labels?.setPrimary || 'Set Primary'}
|
|
243
|
+
</button>
|
|
244
|
+
)}
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
className={styles.removeBtn}
|
|
248
|
+
onClick={() => handleDissociate(association.id)}
|
|
249
|
+
>
|
|
250
|
+
{config.labels?.remove || 'Remove'}
|
|
251
|
+
</button>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
})}
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
{/* Add New Association */}
|
|
261
|
+
<div className={styles.newAssociation}>
|
|
262
|
+
<h4>{config.labels?.addNew || 'Add New Association'}</h4>
|
|
263
|
+
<div className={styles.associationForm}>
|
|
264
|
+
{config.fields && config.fields.map((field, index) => {
|
|
265
|
+
const fieldValue = newAssociation[field.id] || '';
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
<div key={field.id} className={styles.formRow}>
|
|
269
|
+
<div className={styles.formField}>
|
|
270
|
+
<label>
|
|
271
|
+
{field.label}{field.required && ' *'}
|
|
272
|
+
</label>
|
|
273
|
+
|
|
274
|
+
{field.type === 'multi-select' && (
|
|
275
|
+
<MultiSelect
|
|
276
|
+
settings={{ id: field.id }}
|
|
277
|
+
multi={field.multi || false}
|
|
278
|
+
onChange={(value) => setNewAssociation(prev => ({ ...prev, [field.id]: value }))}
|
|
279
|
+
inputValue={fieldValue}
|
|
280
|
+
options={availableOptions}
|
|
281
|
+
placeholder={field.placeholder || `Select ${field.label}...`}
|
|
282
|
+
/>
|
|
283
|
+
)}
|
|
284
|
+
|
|
285
|
+
{field.type === 'select' && (
|
|
286
|
+
<select
|
|
287
|
+
value={fieldValue}
|
|
288
|
+
onChange={(e) => setNewAssociation(prev => ({
|
|
289
|
+
...prev,
|
|
290
|
+
[field.id]: e.target.value
|
|
291
|
+
}))}
|
|
292
|
+
className={styles.selectField}
|
|
293
|
+
>
|
|
294
|
+
{field.options && field.options.map(option => (
|
|
295
|
+
<option key={option.value || option} value={option.value || option}>
|
|
296
|
+
{option.label || option}
|
|
297
|
+
</option>
|
|
298
|
+
))}
|
|
299
|
+
</select>
|
|
300
|
+
)}
|
|
301
|
+
|
|
302
|
+
{field.type === 'boolean' && (
|
|
303
|
+
<label>
|
|
304
|
+
<input
|
|
305
|
+
type="checkbox"
|
|
306
|
+
checked={fieldValue}
|
|
307
|
+
onChange={(e) => setNewAssociation(prev => ({
|
|
308
|
+
...prev,
|
|
309
|
+
[field.id]: e.target.checked
|
|
310
|
+
}))}
|
|
311
|
+
/>
|
|
312
|
+
{field.checkboxLabel || field.label}
|
|
313
|
+
</label>
|
|
314
|
+
)}
|
|
315
|
+
|
|
316
|
+
{field.type === 'textarea' && (
|
|
317
|
+
<textarea
|
|
318
|
+
value={fieldValue}
|
|
319
|
+
onChange={(e) => setNewAssociation(prev => ({
|
|
320
|
+
...prev,
|
|
321
|
+
[field.id]: e.target.value
|
|
322
|
+
}))}
|
|
323
|
+
placeholder={field.placeholder || ''}
|
|
324
|
+
className={styles.textareaField}
|
|
325
|
+
rows={field.rows || 3}
|
|
326
|
+
/>
|
|
327
|
+
)}
|
|
328
|
+
|
|
329
|
+
{field.type === 'text' && (
|
|
330
|
+
<input
|
|
331
|
+
type="text"
|
|
332
|
+
value={fieldValue}
|
|
333
|
+
onChange={(e) => setNewAssociation(prev => ({
|
|
334
|
+
...prev,
|
|
335
|
+
[field.id]: e.target.value
|
|
336
|
+
}))}
|
|
337
|
+
placeholder={field.placeholder || ''}
|
|
338
|
+
className={styles.textField}
|
|
339
|
+
/>
|
|
340
|
+
)}
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
);
|
|
344
|
+
})}
|
|
345
|
+
|
|
346
|
+
<div className={styles.formActions}>
|
|
347
|
+
<button
|
|
348
|
+
type="button"
|
|
349
|
+
className={styles.associateBtn}
|
|
350
|
+
onClick={handleAssociate}
|
|
351
|
+
disabled={!newAssociation[config.fields?.find(f => f.required)?.id || 'id']}
|
|
352
|
+
>
|
|
353
|
+
{config.labels?.addButton || 'Add Association'}
|
|
354
|
+
</button>
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
);
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
export default ClientAssociationManager;
|