@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.
@@ -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>Position business card within the frame and tap to focus</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={20} />
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 ? 'Focusing...' : 'Capture'}
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;