@visns-studio/visns-components 5.11.3 → 5.11.5
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 +915 -11
- package/package.json +1 -1
- package/src/components/crm/AssociationManager.jsx +151 -33
- package/src/components/crm/BusinessCardOcr.jsx +190 -6
- package/src/components/crm/Field.jsx +27 -0
- package/src/components/crm/Navigation.jsx +65 -5
- 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/BusinessCardOcr.module.scss +362 -94
- package/src/components/crm/styles/Navigation.module.scss +95 -23
package/package.json
CHANGED
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"react-dom": "^17.0.0 || ^18.0.0"
|
|
88
88
|
},
|
|
89
89
|
"name": "@visns-studio/visns-components",
|
|
90
|
-
"version": "5.11.
|
|
90
|
+
"version": "5.11.5",
|
|
91
91
|
"description": "Various packages to assist in the development of our Custom Applications.",
|
|
92
92
|
"main": "src/index.js",
|
|
93
93
|
"files": [
|
|
@@ -13,6 +13,7 @@ const AssociationManager = ({
|
|
|
13
13
|
}) => {
|
|
14
14
|
const [associations, setAssociations] = useState([]);
|
|
15
15
|
const [availableOptions, setAvailableOptions] = useState([]);
|
|
16
|
+
const [childOptions, setChildOptions] = useState({});
|
|
16
17
|
const [loading, setLoading] = useState(true);
|
|
17
18
|
|
|
18
19
|
// Dynamic state based on config
|
|
@@ -49,23 +50,42 @@ const AssociationManager = ({
|
|
|
49
50
|
|
|
50
51
|
// Load existing associations
|
|
51
52
|
const loadAssociations = async () => {
|
|
52
|
-
|
|
53
|
+
console.log('🔍 AssociationManager - loadAssociations called');
|
|
54
|
+
console.log('📡 Endpoints:', endpoints);
|
|
55
|
+
console.log('⚙️ Config:', config);
|
|
56
|
+
console.log('🆔 DataId:', dataId);
|
|
57
|
+
|
|
58
|
+
if (!endpoints.list) {
|
|
59
|
+
console.error('❌ No endpoints.list defined');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
53
62
|
|
|
54
63
|
try {
|
|
55
64
|
setLoading(true);
|
|
56
65
|
const url = processTemplate(endpoints.list);
|
|
66
|
+
console.log('🌐 Loading associations from URL:', url);
|
|
67
|
+
|
|
57
68
|
const method = endpoints.listMethod || 'GET';
|
|
69
|
+
console.log('📤 HTTP Method:', method);
|
|
70
|
+
|
|
58
71
|
const response = await CustomFetch(url, method, endpoints.listParams || {});
|
|
72
|
+
console.log('📥 Raw API Response:', response);
|
|
59
73
|
|
|
60
74
|
const dataPath = config.dataPath || 'data';
|
|
75
|
+
console.log('🗂️ Using dataPath:', dataPath);
|
|
76
|
+
|
|
61
77
|
const associationsData = dataPath.split('.').reduce((obj, key) => obj?.[key], response);
|
|
78
|
+
console.log('📊 Extracted associations data:', associationsData);
|
|
62
79
|
|
|
63
80
|
// Ensure associationsData is always an array
|
|
64
81
|
const validAssociations = Array.isArray(associationsData) ? associationsData : [];
|
|
82
|
+
console.log('✅ Valid associations (final):', validAssociations);
|
|
83
|
+
console.log('📈 Associations count:', validAssociations.length);
|
|
84
|
+
|
|
65
85
|
setAssociations(validAssociations);
|
|
66
86
|
} catch (error) {
|
|
67
87
|
toast.error(config.messages?.loadError || 'Failed to load associations');
|
|
68
|
-
console.error('Error loading associations:', error);
|
|
88
|
+
console.error('❌ Error loading associations:', error);
|
|
69
89
|
setAssociations([]); // Set empty array on error
|
|
70
90
|
} finally {
|
|
71
91
|
setLoading(false);
|
|
@@ -74,20 +94,102 @@ const AssociationManager = ({
|
|
|
74
94
|
|
|
75
95
|
// Load available options for dropdown/select fields
|
|
76
96
|
const loadAvailableOptions = async () => {
|
|
77
|
-
|
|
97
|
+
console.log('🔍 AssociationManager - loadAvailableOptions called');
|
|
98
|
+
console.log('⚙️ Config.optionsEndpoint:', config.optionsEndpoint);
|
|
99
|
+
|
|
100
|
+
if (!config.optionsEndpoint) {
|
|
101
|
+
console.error('❌ No config.optionsEndpoint defined');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
78
104
|
|
|
79
105
|
try {
|
|
80
106
|
const url = processTemplate(config.optionsEndpoint.url);
|
|
107
|
+
console.log('🌐 Loading options from URL:', url);
|
|
108
|
+
|
|
81
109
|
const method = config.optionsEndpoint.method || 'POST';
|
|
110
|
+
console.log('📤 HTTP Method:', method);
|
|
111
|
+
|
|
82
112
|
const params = config.optionsEndpoint.params || {};
|
|
113
|
+
console.log('📋 Request params:', params);
|
|
83
114
|
|
|
84
115
|
const response = await CustomFetch(url, method, params);
|
|
116
|
+
console.log('📥 Raw options API Response:', response);
|
|
117
|
+
|
|
85
118
|
const dataPath = config.optionsEndpoint.dataPath || 'data';
|
|
119
|
+
console.log('🗂️ Using options dataPath:', dataPath);
|
|
120
|
+
|
|
86
121
|
const optionsData = dataPath.split('.').reduce((obj, key) => obj?.[key], response) || [];
|
|
122
|
+
console.log('📊 Extracted options data:', optionsData);
|
|
123
|
+
console.log('📈 Options count:', optionsData.length);
|
|
87
124
|
|
|
88
125
|
setAvailableOptions(optionsData);
|
|
89
126
|
} catch (error) {
|
|
90
|
-
console.error('Failed to load options:', error);
|
|
127
|
+
console.error('❌ Failed to load options:', error);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Load child dropdown options based on parent selection
|
|
132
|
+
const loadChildOptions = async (parentFieldId, parentValue, childConfig) => {
|
|
133
|
+
if (!parentValue || !childConfig.url) return [];
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
console.log(`🔍 Loading child options for ${childConfig.id} based on ${parentFieldId}:`, parentValue);
|
|
137
|
+
|
|
138
|
+
const filter = {
|
|
139
|
+
where: [
|
|
140
|
+
{
|
|
141
|
+
id: parentFieldId,
|
|
142
|
+
value: parentValue,
|
|
143
|
+
...(childConfig.whereHas && { whereHas: childConfig.whereHas })
|
|
144
|
+
}
|
|
145
|
+
]
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if (childConfig.fields) {
|
|
149
|
+
filter.fields = childConfig.fields;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
console.log('📋 Child filter params:', filter);
|
|
153
|
+
|
|
154
|
+
const response = await CustomFetch(childConfig.url, 'POST', filter);
|
|
155
|
+
console.log('📥 Child options response:', response);
|
|
156
|
+
|
|
157
|
+
// Extract child options - response structure is {data: {data: [...]}}
|
|
158
|
+
const childData = response.data?.data || response.data || [];
|
|
159
|
+
console.log('📊 Extracted child options:', childData);
|
|
160
|
+
|
|
161
|
+
return childData;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.error(`❌ Failed to load child options for ${childConfig.id}:`, error);
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Handle parent field changes and load child options
|
|
169
|
+
const handleFieldChange = async (fieldId, value, field) => {
|
|
170
|
+
// Update the field value
|
|
171
|
+
setNewAssociation(prev => ({ ...prev, [fieldId]: value }));
|
|
172
|
+
|
|
173
|
+
// Load child options if this field has children
|
|
174
|
+
if (field.child && Array.isArray(field.child)) {
|
|
175
|
+
console.log(`🔗 Parent field ${fieldId} changed, loading child options...`);
|
|
176
|
+
|
|
177
|
+
const newChildOptions = { ...childOptions };
|
|
178
|
+
|
|
179
|
+
for (const childConfig of field.child) {
|
|
180
|
+
const childFieldOptions = await loadChildOptions(fieldId, value?.value || value, childConfig);
|
|
181
|
+
newChildOptions[childConfig.id] = childFieldOptions;
|
|
182
|
+
console.log(`✅ Loaded ${childFieldOptions.length} options for ${childConfig.id}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
setChildOptions(newChildOptions);
|
|
186
|
+
|
|
187
|
+
// Clear child field values when parent changes
|
|
188
|
+
const updatedAssociation = { ...newAssociation, [fieldId]: value };
|
|
189
|
+
field.child.forEach(childConfig => {
|
|
190
|
+
updatedAssociation[childConfig.id] = '';
|
|
191
|
+
});
|
|
192
|
+
setNewAssociation(updatedAssociation);
|
|
91
193
|
}
|
|
92
194
|
};
|
|
93
195
|
|
|
@@ -164,21 +266,29 @@ const AssociationManager = ({
|
|
|
164
266
|
};
|
|
165
267
|
|
|
166
268
|
// Set primary handler
|
|
167
|
-
const handleSetPrimary = async (
|
|
269
|
+
const handleSetPrimary = async (association) => {
|
|
168
270
|
if (!endpoints.setPrimary) return;
|
|
169
271
|
|
|
170
272
|
const idField = config.primaryTargetField || config.deleteField || 'id';
|
|
273
|
+
const targetValue = association[idField] || association.customer_id || association.id;
|
|
171
274
|
|
|
172
|
-
|
|
275
|
+
console.log('🌟 Setting primary for association:', association);
|
|
276
|
+
console.log('🎯 Using field:', idField, 'with value:', targetValue);
|
|
277
|
+
|
|
278
|
+
await handleAction('setPrimary', { [idField]: targetValue });
|
|
173
279
|
};
|
|
174
280
|
|
|
175
281
|
// Remove primary handler
|
|
176
|
-
const handleRemovePrimary = async (
|
|
282
|
+
const handleRemovePrimary = async (association) => {
|
|
177
283
|
if (!endpoints.removePrimary) return;
|
|
178
284
|
|
|
179
285
|
const idField = config.primaryTargetField || config.deleteField || 'id';
|
|
286
|
+
const targetValue = association[idField] || association.customer_id || association.id;
|
|
287
|
+
|
|
288
|
+
console.log('🌟 Removing primary for association:', association);
|
|
289
|
+
console.log('🎯 Using field:', idField, 'with value:', targetValue);
|
|
180
290
|
|
|
181
|
-
await handleAction('removePrimary', { [idField]:
|
|
291
|
+
await handleAction('removePrimary', { [idField]: targetValue });
|
|
182
292
|
};
|
|
183
293
|
|
|
184
294
|
// Clear all primary statuses (useful after merge)
|
|
@@ -195,9 +305,17 @@ const AssociationManager = ({
|
|
|
195
305
|
};
|
|
196
306
|
|
|
197
307
|
useEffect(() => {
|
|
308
|
+
console.log('🚀 AssociationManager - Component initialized');
|
|
309
|
+
console.log('🆔 DataId:', dataId);
|
|
310
|
+
console.log('📡 Endpoints:', endpoints);
|
|
311
|
+
console.log('⚙️ Config:', config);
|
|
312
|
+
|
|
198
313
|
if (dataId) {
|
|
314
|
+
console.log('✅ DataId exists, loading data...');
|
|
199
315
|
loadAssociations();
|
|
200
316
|
loadAvailableOptions();
|
|
317
|
+
} else {
|
|
318
|
+
console.warn('⚠️ No dataId provided, skipping data load');
|
|
201
319
|
}
|
|
202
320
|
}, [dataId]);
|
|
203
321
|
|
|
@@ -245,21 +363,31 @@ const AssociationManager = ({
|
|
|
245
363
|
) : (
|
|
246
364
|
<div className={styles.associationsList}>
|
|
247
365
|
{associations.map((association) => {
|
|
366
|
+
console.log('🔍 Rendering association:', association);
|
|
367
|
+
|
|
248
368
|
const nameField = config.displayFields?.name || 'name';
|
|
249
369
|
const primaryField = config.displayFields?.primary || 'is_primary';
|
|
250
370
|
const relationshipField = config.displayFields?.relationship || 'relationship_type';
|
|
251
371
|
const notesField = config.displayFields?.notes || 'notes';
|
|
252
372
|
const dateField = config.displayFields?.date || 'associated_at';
|
|
253
373
|
|
|
374
|
+
console.log('📊 Field values:', {
|
|
375
|
+
name: association[nameField],
|
|
376
|
+
primary: association[primaryField],
|
|
377
|
+
relationship: association[relationshipField],
|
|
378
|
+
notes: association[notesField],
|
|
379
|
+
date: association[dateField]
|
|
380
|
+
});
|
|
381
|
+
|
|
254
382
|
return (
|
|
255
383
|
<div
|
|
256
384
|
key={association.id}
|
|
257
|
-
className={`${styles.associationItem} ${association[primaryField] ? styles.primary : ''}`}
|
|
385
|
+
className={`${styles.associationItem} ${Boolean(association[primaryField]) ? styles.primary : ''}`}
|
|
258
386
|
>
|
|
259
387
|
<div className={styles.associationInfo}>
|
|
260
388
|
<div className={styles.entityName}>
|
|
261
389
|
{association[nameField]}
|
|
262
|
-
{association[primaryField] && (
|
|
390
|
+
{Boolean(association[primaryField]) && (
|
|
263
391
|
<span className={styles.primaryBadge}>
|
|
264
392
|
<Star size={12} style={{ marginRight: '4px' }} />
|
|
265
393
|
{config.labels?.primaryBadge || 'Primary'}
|
|
@@ -288,21 +416,22 @@ const AssociationManager = ({
|
|
|
288
416
|
)}
|
|
289
417
|
</div>
|
|
290
418
|
<div className={styles.associationActions}>
|
|
291
|
-
{
|
|
419
|
+
{console.log('🎯 Actions for:', association[nameField], 'Primary:', association[primaryField], 'Boolean:', Boolean(association[primaryField]))}
|
|
420
|
+
{!Boolean(association[primaryField]) && endpoints.setPrimary && (
|
|
292
421
|
<button
|
|
293
422
|
type="button"
|
|
294
423
|
className={styles.setPrimaryBtn}
|
|
295
|
-
onClick={() => handleSetPrimary(association
|
|
424
|
+
onClick={() => handleSetPrimary(association)}
|
|
296
425
|
>
|
|
297
426
|
<Star size={14} style={{ marginRight: '4px' }} />
|
|
298
427
|
{config.labels?.setPrimary || 'Set Primary'}
|
|
299
428
|
</button>
|
|
300
429
|
)}
|
|
301
|
-
{association[primaryField] && endpoints.removePrimary && (
|
|
430
|
+
{Boolean(association[primaryField]) && endpoints.removePrimary && (
|
|
302
431
|
<button
|
|
303
432
|
type="button"
|
|
304
433
|
className={styles.removePrimaryBtn}
|
|
305
|
-
onClick={() => handleRemovePrimary(association
|
|
434
|
+
onClick={() => handleRemovePrimary(association)}
|
|
306
435
|
title={config.labels?.removePrimaryTooltip || 'Remove primary status from this association'}
|
|
307
436
|
>
|
|
308
437
|
<X size={14} style={{ marginRight: '4px' }} />
|
|
@@ -343,9 +472,9 @@ const AssociationManager = ({
|
|
|
343
472
|
<MultiSelect
|
|
344
473
|
settings={{ id: field.id }}
|
|
345
474
|
multi={field.multi || false}
|
|
346
|
-
onChange={(value) =>
|
|
475
|
+
onChange={(value) => handleFieldChange(field.id, value, field)}
|
|
347
476
|
inputValue={fieldValue}
|
|
348
|
-
options={availableOptions}
|
|
477
|
+
options={field.id === 'customer_id' ? availableOptions : (childOptions[field.id] || [])}
|
|
349
478
|
placeholder={field.placeholder || `Select ${field.label}...`}
|
|
350
479
|
/>
|
|
351
480
|
)}
|
|
@@ -353,13 +482,11 @@ const AssociationManager = ({
|
|
|
353
482
|
{field.type === 'select' && (
|
|
354
483
|
<select
|
|
355
484
|
value={fieldValue}
|
|
356
|
-
onChange={(e) =>
|
|
357
|
-
...prev,
|
|
358
|
-
[field.id]: e.target.value
|
|
359
|
-
}))}
|
|
485
|
+
onChange={(e) => handleFieldChange(field.id, e.target.value, field)}
|
|
360
486
|
className={styles.selectField}
|
|
361
487
|
>
|
|
362
|
-
|
|
488
|
+
<option value="">Select {field.label}...</option>
|
|
489
|
+
{(field.options || childOptions[field.id] || []).map(option => (
|
|
363
490
|
<option key={option.value || option} value={option.value || option}>
|
|
364
491
|
{option.label || option}
|
|
365
492
|
</option>
|
|
@@ -372,10 +499,7 @@ const AssociationManager = ({
|
|
|
372
499
|
<input
|
|
373
500
|
type="checkbox"
|
|
374
501
|
checked={fieldValue}
|
|
375
|
-
onChange={(e) =>
|
|
376
|
-
...prev,
|
|
377
|
-
[field.id]: e.target.checked
|
|
378
|
-
}))}
|
|
502
|
+
onChange={(e) => handleFieldChange(field.id, e.target.checked, field)}
|
|
379
503
|
/>
|
|
380
504
|
{field.checkboxLabel || field.label}
|
|
381
505
|
</label>
|
|
@@ -384,10 +508,7 @@ const AssociationManager = ({
|
|
|
384
508
|
{field.type === 'textarea' && (
|
|
385
509
|
<textarea
|
|
386
510
|
value={fieldValue}
|
|
387
|
-
onChange={(e) =>
|
|
388
|
-
...prev,
|
|
389
|
-
[field.id]: e.target.value
|
|
390
|
-
}))}
|
|
511
|
+
onChange={(e) => handleFieldChange(field.id, e.target.value, field)}
|
|
391
512
|
placeholder={field.placeholder || ''}
|
|
392
513
|
className={styles.textareaField}
|
|
393
514
|
rows={field.rows || 3}
|
|
@@ -398,10 +519,7 @@ const AssociationManager = ({
|
|
|
398
519
|
<input
|
|
399
520
|
type="text"
|
|
400
521
|
value={fieldValue}
|
|
401
|
-
onChange={(e) =>
|
|
402
|
-
...prev,
|
|
403
|
-
[field.id]: e.target.value
|
|
404
|
-
}))}
|
|
522
|
+
onChange={(e) => handleFieldChange(field.id, e.target.value, field)}
|
|
405
523
|
placeholder={field.placeholder || ''}
|
|
406
524
|
className={styles.textField}
|
|
407
525
|
/>
|
|
@@ -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>
|
|
@@ -157,6 +157,33 @@ function Field({
|
|
|
157
157
|
fetchAutoValue();
|
|
158
158
|
}, [settings.url, settings.type, settings.id, settings.autoValueKey]);
|
|
159
159
|
|
|
160
|
+
// Handle profileId parameter - auto-populate field value with userProfile.id
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (
|
|
163
|
+
settings.param === 'profileId' &&
|
|
164
|
+
(!settings.value || settings.value === '') &&
|
|
165
|
+
userProfile?.id &&
|
|
166
|
+
(!inputValue || inputValue === '')
|
|
167
|
+
) {
|
|
168
|
+
// Simulate an onChange event to update the form data with userProfile.id
|
|
169
|
+
const simulatedEvent = {
|
|
170
|
+
target: {
|
|
171
|
+
dataset: { name: settings.id },
|
|
172
|
+
value: userProfile.id,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
onChange(simulatedEvent);
|
|
176
|
+
|
|
177
|
+
// Also directly update the form data to ensure it's synchronized
|
|
178
|
+
if (setFormData) {
|
|
179
|
+
setFormData((prevState) => ({
|
|
180
|
+
...prevState,
|
|
181
|
+
[settings.id]: userProfile.id,
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}, [settings.param, settings.value, settings.id, userProfile?.id, inputValue, onChange, setFormData]);
|
|
186
|
+
|
|
160
187
|
useEffect(() => {
|
|
161
188
|
const fetchOptions = () => {
|
|
162
189
|
let filter = {};
|