@visns-studio/visns-components 5.9.7 → 5.9.9
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/package.json +5 -5
- package/src/components/crm/BusinessCardOcr.jsx +1480 -193
- package/src/components/crm/DataGrid.jsx +5 -6
- package/src/components/crm/Navigation.jsx +16 -2
- package/src/components/crm/generic/GenericReportImproved.jsx +4083 -0
- package/src/components/crm/generic/styles/GenericReportImproved.module.scss +2032 -0
- package/src/components/crm/styles/BusinessCardOcr.module.scss +263 -0
- package/src/index.js +2 -0
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
Plus,
|
|
18
18
|
} from 'akar-icons';
|
|
19
19
|
|
|
20
|
+
import CustomFetch from './Fetch';
|
|
20
21
|
import ocrStyles from './styles/BusinessCardOcr.module.scss';
|
|
21
22
|
|
|
22
23
|
const BusinessCardOcr = ({
|
|
@@ -25,8 +26,9 @@ const BusinessCardOcr = ({
|
|
|
25
26
|
isOpen = false,
|
|
26
27
|
onClose,
|
|
27
28
|
fields = [],
|
|
29
|
+
fetchConfig = null, // { url, method } from Navigation settings
|
|
28
30
|
}) => {
|
|
29
|
-
const [currentStep, setCurrentStep] = useState('camera'); // camera, preview, processing, results
|
|
31
|
+
const [currentStep, setCurrentStep] = useState('camera'); // camera, preview, processing, results, consultation, decisions
|
|
30
32
|
const [capturedImage, setCapturedImage] = useState(null);
|
|
31
33
|
const [cameraStream, setCameraStream] = useState(null);
|
|
32
34
|
const [cameraFacing, setCameraFacing] = useState('environment'); // environment (back), user (front)
|
|
@@ -37,6 +39,11 @@ const BusinessCardOcr = ({
|
|
|
37
39
|
const [ocrProgress, setOcrProgress] = useState(0);
|
|
38
40
|
const [showAssignmentDropdown, setShowAssignmentDropdown] = useState(false);
|
|
39
41
|
const [selectedUnusedText, setSelectedUnusedText] = useState('');
|
|
42
|
+
const [backendResponse, setBackendResponse] = useState(null);
|
|
43
|
+
const [selectedClient, setSelectedClient] = useState(null);
|
|
44
|
+
const [contactDecision, setContactDecision] = useState(null); // 'create', 'update', 'merge'
|
|
45
|
+
const [isConsulting, setIsConsulting] = useState(false);
|
|
46
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
40
47
|
|
|
41
48
|
const videoRef = useRef(null);
|
|
42
49
|
const canvasRef = useRef(null);
|
|
@@ -49,6 +56,7 @@ const BusinessCardOcr = ({
|
|
|
49
56
|
}
|
|
50
57
|
}, [isOpen]);
|
|
51
58
|
|
|
59
|
+
|
|
52
60
|
// Initialize camera when component opens
|
|
53
61
|
useEffect(() => {
|
|
54
62
|
if (isOpen && currentStep === 'camera') {
|
|
@@ -57,32 +65,44 @@ const BusinessCardOcr = ({
|
|
|
57
65
|
return () => {
|
|
58
66
|
stopCamera();
|
|
59
67
|
};
|
|
60
|
-
}, [
|
|
68
|
+
}, [
|
|
69
|
+
isOpen,
|
|
70
|
+
currentStep,
|
|
71
|
+
cameraFacing,
|
|
72
|
+
currentCameraIndex,
|
|
73
|
+
availableCameras,
|
|
74
|
+
]);
|
|
61
75
|
|
|
62
76
|
const detectCameras = async () => {
|
|
63
77
|
try {
|
|
64
78
|
// Request permission first to get proper device labels
|
|
65
79
|
await navigator.mediaDevices.getUserMedia({ video: true });
|
|
66
|
-
|
|
80
|
+
|
|
67
81
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
68
82
|
const videoDevices = devices.filter(
|
|
69
83
|
(device) => device.kind === 'videoinput' && device.deviceId
|
|
70
84
|
);
|
|
71
|
-
|
|
85
|
+
|
|
72
86
|
// Filter out devices that might not work
|
|
73
87
|
const workingCameras = [];
|
|
74
88
|
for (const device of videoDevices) {
|
|
75
89
|
try {
|
|
76
|
-
const testStream =
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
90
|
+
const testStream =
|
|
91
|
+
await navigator.mediaDevices.getUserMedia({
|
|
92
|
+
video: { deviceId: device.deviceId },
|
|
93
|
+
});
|
|
94
|
+
testStream.getTracks().forEach((track) => track.stop());
|
|
80
95
|
workingCameras.push(device);
|
|
81
96
|
} catch (err) {
|
|
82
|
-
console.warn(
|
|
97
|
+
console.warn(
|
|
98
|
+
`Camera ${
|
|
99
|
+
device.label || device.deviceId
|
|
100
|
+
} not accessible:`,
|
|
101
|
+
err
|
|
102
|
+
);
|
|
83
103
|
}
|
|
84
104
|
}
|
|
85
|
-
|
|
105
|
+
|
|
86
106
|
setAvailableCameras(workingCameras);
|
|
87
107
|
} catch (error) {
|
|
88
108
|
console.error('Error detecting cameras:', error);
|
|
@@ -93,12 +113,18 @@ const BusinessCardOcr = ({
|
|
|
93
113
|
const initializeCamera = async () => {
|
|
94
114
|
try {
|
|
95
115
|
let constraints;
|
|
96
|
-
|
|
116
|
+
|
|
97
117
|
// Use specific device if available cameras are detected
|
|
98
|
-
if (
|
|
118
|
+
if (
|
|
119
|
+
availableCameras.length > 0 &&
|
|
120
|
+
availableCameras[currentCameraIndex]
|
|
121
|
+
) {
|
|
99
122
|
constraints = {
|
|
100
123
|
video: {
|
|
101
|
-
deviceId: {
|
|
124
|
+
deviceId: {
|
|
125
|
+
exact: availableCameras[currentCameraIndex]
|
|
126
|
+
.deviceId,
|
|
127
|
+
},
|
|
102
128
|
width: { ideal: 1280 },
|
|
103
129
|
height: { ideal: 720 },
|
|
104
130
|
},
|
|
@@ -114,7 +140,9 @@ const BusinessCardOcr = ({
|
|
|
114
140
|
};
|
|
115
141
|
}
|
|
116
142
|
|
|
117
|
-
const stream = await navigator.mediaDevices.getUserMedia(
|
|
143
|
+
const stream = await navigator.mediaDevices.getUserMedia(
|
|
144
|
+
constraints
|
|
145
|
+
);
|
|
118
146
|
setCameraStream(stream);
|
|
119
147
|
|
|
120
148
|
if (videoRef.current) {
|
|
@@ -157,8 +185,8 @@ const BusinessCardOcr = ({
|
|
|
157
185
|
|
|
158
186
|
const switchCamera = () => {
|
|
159
187
|
if (availableCameras.length > 1) {
|
|
160
|
-
setCurrentCameraIndex(
|
|
161
|
-
(prev + 1) % availableCameras.length
|
|
188
|
+
setCurrentCameraIndex(
|
|
189
|
+
(prev) => (prev + 1) % availableCameras.length
|
|
162
190
|
);
|
|
163
191
|
} else {
|
|
164
192
|
// Fallback to facing mode switch
|
|
@@ -175,47 +203,53 @@ const BusinessCardOcr = ({
|
|
|
175
203
|
|
|
176
204
|
const handleFileUpload = (event) => {
|
|
177
205
|
const file = event.target.files[0];
|
|
178
|
-
|
|
206
|
+
|
|
179
207
|
if (!file) {
|
|
180
208
|
return;
|
|
181
209
|
}
|
|
182
|
-
|
|
210
|
+
|
|
183
211
|
// Validate file type
|
|
184
212
|
if (!file.type.startsWith('image/')) {
|
|
185
213
|
toast.error('Please select a valid image file (JPG, PNG, etc.)');
|
|
186
214
|
return;
|
|
187
215
|
}
|
|
188
|
-
|
|
216
|
+
|
|
189
217
|
// Validate file size (max 10MB)
|
|
190
218
|
const maxSize = 10 * 1024 * 1024; // 10MB
|
|
191
219
|
if (file.size > maxSize) {
|
|
192
|
-
toast.error(
|
|
220
|
+
toast.error(
|
|
221
|
+
'Image file is too large. Please select a file smaller than 10MB.'
|
|
222
|
+
);
|
|
193
223
|
return;
|
|
194
224
|
}
|
|
195
|
-
|
|
225
|
+
|
|
196
226
|
// Check if it's a reasonable image size
|
|
197
227
|
const img = new Image();
|
|
198
|
-
img.onload = function() {
|
|
228
|
+
img.onload = function () {
|
|
199
229
|
if (this.width < 100 || this.height < 100) {
|
|
200
|
-
toast.error(
|
|
230
|
+
toast.error(
|
|
231
|
+
'Image is too small. Please select a larger image.'
|
|
232
|
+
);
|
|
201
233
|
URL.revokeObjectURL(this.src); // Clean up memory
|
|
202
234
|
return;
|
|
203
235
|
}
|
|
204
|
-
|
|
236
|
+
|
|
205
237
|
URL.revokeObjectURL(this.src); // Clean up memory
|
|
206
238
|
setCapturedImage(file);
|
|
207
239
|
setCurrentStep('preview');
|
|
208
240
|
stopCamera();
|
|
209
241
|
};
|
|
210
|
-
|
|
211
|
-
img.onerror = function() {
|
|
212
|
-
toast.error(
|
|
242
|
+
|
|
243
|
+
img.onerror = function () {
|
|
244
|
+
toast.error(
|
|
245
|
+
'Unable to load the selected image. Please try another file.'
|
|
246
|
+
);
|
|
213
247
|
URL.revokeObjectURL(this.src); // Clean up memory
|
|
214
248
|
};
|
|
215
|
-
|
|
249
|
+
|
|
216
250
|
const objectUrl = URL.createObjectURL(file);
|
|
217
251
|
img.src = objectUrl;
|
|
218
|
-
|
|
252
|
+
|
|
219
253
|
// Clear the input to allow re-selecting the same file
|
|
220
254
|
event.target.value = '';
|
|
221
255
|
};
|
|
@@ -255,40 +289,54 @@ const BusinessCardOcr = ({
|
|
|
255
289
|
// Helper functions for data quality validation
|
|
256
290
|
const isValidName = (name) => {
|
|
257
291
|
if (!name || typeof name !== 'string') return false;
|
|
258
|
-
|
|
292
|
+
|
|
259
293
|
const trimmed = name.trim();
|
|
260
|
-
|
|
294
|
+
|
|
261
295
|
// Must be at least 2 characters for first name, 1 for last name
|
|
262
296
|
if (trimmed.length < 1) return false;
|
|
263
|
-
|
|
297
|
+
|
|
264
298
|
// No single character first names except common ones
|
|
265
|
-
const commonSingleChars = [
|
|
266
|
-
|
|
299
|
+
const commonSingleChars = [
|
|
300
|
+
'J',
|
|
301
|
+
'D',
|
|
302
|
+
'R',
|
|
303
|
+
'M',
|
|
304
|
+
'K',
|
|
305
|
+
'T',
|
|
306
|
+
'S',
|
|
307
|
+
'B',
|
|
308
|
+
'C',
|
|
309
|
+
'A',
|
|
310
|
+
];
|
|
311
|
+
if (
|
|
312
|
+
trimmed.length === 1 &&
|
|
313
|
+
!commonSingleChars.includes(trimmed.toUpperCase())
|
|
314
|
+
) {
|
|
267
315
|
return false;
|
|
268
316
|
}
|
|
269
|
-
|
|
317
|
+
|
|
270
318
|
// Must contain mostly letters
|
|
271
319
|
const letterCount = (trimmed.match(/[a-zA-Z]/g) || []).length;
|
|
272
320
|
const letterRatio = letterCount / trimmed.length;
|
|
273
321
|
if (letterRatio < 0.6) return false;
|
|
274
|
-
|
|
322
|
+
|
|
275
323
|
// No operators or excessive special characters
|
|
276
324
|
const invalidChars = /[=+\-*/<>|\\#$%^&@!~`{}[\]]/;
|
|
277
325
|
if (invalidChars.test(trimmed)) return false;
|
|
278
|
-
|
|
326
|
+
|
|
279
327
|
// Allow common name characters
|
|
280
328
|
const validNamePattern = /^[a-zA-Z\s'.,-]+$/;
|
|
281
329
|
return validNamePattern.test(trimmed);
|
|
282
330
|
};
|
|
283
|
-
|
|
331
|
+
|
|
284
332
|
const isLikelyOCRArtifact = (line) => {
|
|
285
333
|
if (!line || typeof line !== 'string') return true;
|
|
286
|
-
|
|
334
|
+
|
|
287
335
|
const trimmed = line.trim();
|
|
288
|
-
|
|
336
|
+
|
|
289
337
|
// Too short or too long
|
|
290
338
|
if (trimmed.length < 2 || trimmed.length > 50) return true;
|
|
291
|
-
|
|
339
|
+
|
|
292
340
|
// Calculate character type ratios
|
|
293
341
|
const letters = (trimmed.match(/[a-zA-Z]/g) || []).length;
|
|
294
342
|
const numbers = (trimmed.match(/[0-9]/g) || []).length;
|
|
@@ -298,85 +346,94 @@ const BusinessCardOcr = ({
|
|
|
298
346
|
const letterRatio = letters / total;
|
|
299
347
|
const symbolRatio = symbols / total;
|
|
300
348
|
const spaceRatio = spaces / total;
|
|
301
|
-
|
|
349
|
+
|
|
302
350
|
// Skip if mostly symbols
|
|
303
351
|
if (symbolRatio > 0.3) return true;
|
|
304
|
-
|
|
352
|
+
|
|
305
353
|
// Skip if too many consecutive spaces or weird spacing
|
|
306
354
|
if (spaceRatio > 0.4) return true;
|
|
307
|
-
|
|
355
|
+
|
|
308
356
|
// Skip if it's all numbers or mostly numbers (unless it looks like a phone)
|
|
309
|
-
if (
|
|
310
|
-
|
|
357
|
+
if (
|
|
358
|
+
numbers > 0 &&
|
|
359
|
+
letterRatio < 0.2 &&
|
|
360
|
+
!trimmed.match(/^\+?[\d\s\-()]+$/)
|
|
361
|
+
)
|
|
362
|
+
return true;
|
|
363
|
+
|
|
311
364
|
// Skip lines that look like formatting artifacts
|
|
312
|
-
const formatArtifacts =
|
|
365
|
+
const formatArtifacts =
|
|
366
|
+
/^[.\-_|]+$|^[^a-zA-Z]*[|]+[^a-zA-Z]*$|^\s*[.]\s*[A-Z]?\s*$/;
|
|
313
367
|
if (formatArtifacts.test(trimmed)) return true;
|
|
314
|
-
|
|
368
|
+
|
|
315
369
|
// Skip if it has random character combinations
|
|
316
|
-
const randomPattern =
|
|
370
|
+
const randomPattern =
|
|
371
|
+
/^[a-zA-Z]{1,2}\s+[a-zA-Z]{1,2}(\s+[a-zA-Z]{1,2})*$/;
|
|
317
372
|
if (randomPattern.test(trimmed) && trimmed.length < 8) return true;
|
|
318
|
-
|
|
373
|
+
|
|
319
374
|
// Enhanced gibberish detection
|
|
320
375
|
if (isGibberish(trimmed)) return true;
|
|
321
|
-
|
|
376
|
+
|
|
322
377
|
return false;
|
|
323
378
|
};
|
|
324
|
-
|
|
379
|
+
|
|
325
380
|
// Advanced gibberish detection
|
|
326
381
|
const isGibberish = (text) => {
|
|
327
382
|
if (!text || text.length < 3) return false;
|
|
328
|
-
|
|
329
|
-
const words = text.split(/\s+/).filter(word => word.length > 0);
|
|
330
|
-
|
|
383
|
+
|
|
384
|
+
const words = text.split(/\s+/).filter((word) => word.length > 0);
|
|
385
|
+
|
|
331
386
|
// Check for excessive mixed case patterns like "Li FOR go > ips"
|
|
332
387
|
let mixedCaseWords = 0;
|
|
333
388
|
let gibberishWords = 0;
|
|
334
389
|
let totalWords = words.length;
|
|
335
|
-
|
|
390
|
+
|
|
336
391
|
for (const word of words) {
|
|
337
392
|
// Skip very short words
|
|
338
393
|
if (word.length < 2) continue;
|
|
339
|
-
|
|
394
|
+
|
|
340
395
|
// Check for excessive symbol contamination in words
|
|
341
396
|
const symbolsInWord = (word.match(/[^a-zA-Z0-9]/g) || []).length;
|
|
342
397
|
if (symbolsInWord / word.length > 0.3) {
|
|
343
398
|
gibberishWords++;
|
|
344
399
|
continue;
|
|
345
400
|
}
|
|
346
|
-
|
|
401
|
+
|
|
347
402
|
// Check for random mixed case patterns
|
|
348
403
|
const hasRandomCase = /[a-z][A-Z]|[A-Z][a-z][A-Z]/.test(word);
|
|
349
404
|
if (hasRandomCase && word.length > 2) {
|
|
350
405
|
mixedCaseWords++;
|
|
351
406
|
}
|
|
352
|
-
|
|
407
|
+
|
|
353
408
|
// Check for consonant/vowel patterns that don't make sense
|
|
354
|
-
const consonantClusters = word.match(
|
|
409
|
+
const consonantClusters = word.match(
|
|
410
|
+
/[bcdfghjklmnpqrstvwxzBCDFGHJKLMNPQRSTVWXZ]{4,}/g
|
|
411
|
+
);
|
|
355
412
|
if (consonantClusters && consonantClusters.length > 0) {
|
|
356
413
|
gibberishWords++;
|
|
357
414
|
continue;
|
|
358
415
|
}
|
|
359
|
-
|
|
360
|
-
// Check for excessive single character "words"
|
|
416
|
+
|
|
417
|
+
// Check for excessive single character "words"
|
|
361
418
|
if (word.length === 1 && /[a-zA-Z]/.test(word)) {
|
|
362
419
|
gibberishWords++;
|
|
363
420
|
continue;
|
|
364
421
|
}
|
|
365
|
-
|
|
422
|
+
|
|
366
423
|
// Check for patterns like "FOR", "ips", "ED", "RE" in sequence (common OCR errors)
|
|
367
424
|
if (word.length <= 3 && word.match(/^[A-Z]{2,3}$|^[a-z]{2,3}$/)) {
|
|
368
425
|
// Count as potential gibberish if there are many of these short words
|
|
369
426
|
gibberishWords += 0.5;
|
|
370
427
|
}
|
|
371
428
|
}
|
|
372
|
-
|
|
429
|
+
|
|
373
430
|
// If more than 60% of words are gibberish or mixed case, consider it gibberish
|
|
374
431
|
const gibberishRatio = gibberishWords / Math.max(totalWords, 1);
|
|
375
432
|
const mixedCaseRatio = mixedCaseWords / Math.max(totalWords, 1);
|
|
376
|
-
|
|
433
|
+
|
|
377
434
|
if (gibberishRatio > 0.6) return true;
|
|
378
435
|
if (mixedCaseRatio > 0.5 && totalWords > 2) return true;
|
|
379
|
-
|
|
436
|
+
|
|
380
437
|
// Check for specific gibberish patterns
|
|
381
438
|
const gibberishPatterns = [
|
|
382
439
|
// Patterns like ": Re : Bei oc oC"
|
|
@@ -386,47 +443,62 @@ const BusinessCardOcr = ({
|
|
|
386
443
|
// Patterns with excessive brackets, symbols mixed with short words
|
|
387
444
|
/[>\[\]]{2,}|[A-Za-z]{1,3}\s+[>\[\]<]+\s+[A-Za-z]{1,3}/,
|
|
388
445
|
// Excessive punctuation mixed with short letter sequences
|
|
389
|
-
/^[^a-zA-Z]*[a-zA-Z]{1,2}[^a-zA-Z]+[a-zA-Z]{1,3}[^a-zA-Z]
|
|
446
|
+
/^[^a-zA-Z]*[a-zA-Z]{1,2}[^a-zA-Z]+[a-zA-Z]{1,3}[^a-zA-Z]+.*$/,
|
|
390
447
|
];
|
|
391
|
-
|
|
448
|
+
|
|
392
449
|
for (const pattern of gibberishPatterns) {
|
|
393
450
|
if (pattern.test(text)) return true;
|
|
394
451
|
}
|
|
395
|
-
|
|
452
|
+
|
|
396
453
|
// Check for excessive spacing patterns
|
|
397
454
|
if (/\s{3,}/.test(text)) return true;
|
|
398
|
-
|
|
455
|
+
|
|
399
456
|
// Check for lines with too many single characters
|
|
400
457
|
const singleChars = text.match(/\b[a-zA-Z]\b/g);
|
|
401
|
-
if (singleChars && singleChars.length > 3 && text.length < 30)
|
|
402
|
-
|
|
458
|
+
if (singleChars && singleChars.length > 3 && text.length < 30)
|
|
459
|
+
return true;
|
|
460
|
+
|
|
403
461
|
return false;
|
|
404
462
|
};
|
|
405
|
-
|
|
463
|
+
|
|
406
464
|
const hasValidNameStructure = (words) => {
|
|
407
465
|
if (!words || words.length === 0) return false;
|
|
408
|
-
|
|
466
|
+
|
|
409
467
|
// Check each word for name validity
|
|
410
468
|
for (const word of words) {
|
|
411
469
|
if (!isValidName(word)) return false;
|
|
412
470
|
}
|
|
413
|
-
|
|
471
|
+
|
|
414
472
|
// First word should be at least 2 characters (unless common single char)
|
|
415
473
|
const firstName = words[0];
|
|
416
|
-
const commonSingleChars = [
|
|
417
|
-
|
|
474
|
+
const commonSingleChars = [
|
|
475
|
+
'J',
|
|
476
|
+
'D',
|
|
477
|
+
'R',
|
|
478
|
+
'M',
|
|
479
|
+
'K',
|
|
480
|
+
'T',
|
|
481
|
+
'S',
|
|
482
|
+
'B',
|
|
483
|
+
'C',
|
|
484
|
+
'A',
|
|
485
|
+
];
|
|
486
|
+
if (
|
|
487
|
+
firstName.length === 1 &&
|
|
488
|
+
!commonSingleChars.includes(firstName.toUpperCase())
|
|
489
|
+
) {
|
|
418
490
|
return false;
|
|
419
491
|
}
|
|
420
|
-
|
|
492
|
+
|
|
421
493
|
return true;
|
|
422
494
|
};
|
|
423
495
|
|
|
424
496
|
// Helper function to clean and validate field values
|
|
425
497
|
const cleanFieldValue = (value, fieldType = 'general') => {
|
|
426
498
|
if (!value || typeof value !== 'string') return '';
|
|
427
|
-
|
|
499
|
+
|
|
428
500
|
let cleaned = value.trim();
|
|
429
|
-
|
|
501
|
+
|
|
430
502
|
// Remove common OCR artifacts and invalid characters
|
|
431
503
|
switch (fieldType) {
|
|
432
504
|
case 'name':
|
|
@@ -437,7 +509,7 @@ const BusinessCardOcr = ({
|
|
|
437
509
|
// Remove leading/trailing punctuation
|
|
438
510
|
cleaned = cleaned.replace(/^[^a-zA-Z]+|[^a-zA-Z]+$/g, '');
|
|
439
511
|
break;
|
|
440
|
-
|
|
512
|
+
|
|
441
513
|
case 'company':
|
|
442
514
|
// For companies: letters, numbers, spaces, common punctuation
|
|
443
515
|
cleaned = cleaned.replace(/[^a-zA-Z0-9\s&.,'-]/g, '');
|
|
@@ -446,9 +518,12 @@ const BusinessCardOcr = ({
|
|
|
446
518
|
// Remove excessive spaces
|
|
447
519
|
cleaned = cleaned.replace(/\s+/g, ' ');
|
|
448
520
|
// Remove leading/trailing punctuation except periods
|
|
449
|
-
cleaned = cleaned.replace(
|
|
521
|
+
cleaned = cleaned.replace(
|
|
522
|
+
/^[^a-zA-Z0-9]+|[^a-zA-Z0-9.]+$/g,
|
|
523
|
+
''
|
|
524
|
+
);
|
|
450
525
|
break;
|
|
451
|
-
|
|
526
|
+
|
|
452
527
|
case 'position':
|
|
453
528
|
// For positions: letters, spaces, common punctuation
|
|
454
529
|
cleaned = cleaned.replace(/[^a-zA-Z\s&.,'-]/g, '');
|
|
@@ -459,34 +534,37 @@ const BusinessCardOcr = ({
|
|
|
459
534
|
// Remove leading/trailing punctuation
|
|
460
535
|
cleaned = cleaned.replace(/^[^a-zA-Z]+|[^a-zA-Z]+$/g, '');
|
|
461
536
|
break;
|
|
462
|
-
|
|
537
|
+
|
|
463
538
|
case 'email':
|
|
464
539
|
// For emails: standard email characters only
|
|
465
540
|
cleaned = cleaned.replace(/[^a-zA-Z0-9@._+-]/g, '');
|
|
466
541
|
break;
|
|
467
|
-
|
|
542
|
+
|
|
468
543
|
case 'phone':
|
|
469
544
|
// For phones: numbers, spaces, parentheses, hyphens, plus
|
|
470
545
|
cleaned = cleaned.replace(/[^0-9\s()+-]/g, '');
|
|
471
546
|
break;
|
|
472
|
-
|
|
547
|
+
|
|
473
548
|
case 'website':
|
|
474
549
|
// For websites: standard URL characters
|
|
475
|
-
cleaned = cleaned.replace(
|
|
550
|
+
cleaned = cleaned.replace(
|
|
551
|
+
/[^a-zA-Z0-9./:?#@!$&'()*+,;=-]/g,
|
|
552
|
+
''
|
|
553
|
+
);
|
|
476
554
|
break;
|
|
477
|
-
|
|
555
|
+
|
|
478
556
|
default:
|
|
479
557
|
// General cleaning: remove obvious artifacts
|
|
480
558
|
cleaned = cleaned.replace(/[|="]/g, '');
|
|
481
559
|
cleaned = cleaned.replace(/\s+/g, ' ');
|
|
482
560
|
}
|
|
483
|
-
|
|
561
|
+
|
|
484
562
|
// Final cleanup
|
|
485
563
|
cleaned = cleaned.trim();
|
|
486
|
-
|
|
564
|
+
|
|
487
565
|
// Return empty string if result is too short or invalid
|
|
488
566
|
if (cleaned.length < 1) return '';
|
|
489
|
-
|
|
567
|
+
|
|
490
568
|
return cleaned;
|
|
491
569
|
};
|
|
492
570
|
|
|
@@ -497,15 +575,61 @@ const BusinessCardOcr = ({
|
|
|
497
575
|
const usedLineIndices = new Set();
|
|
498
576
|
|
|
499
577
|
// Enhanced regex patterns
|
|
500
|
-
const emailRegex =
|
|
501
|
-
|
|
502
|
-
const
|
|
503
|
-
|
|
578
|
+
const emailRegex =
|
|
579
|
+
/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/;
|
|
580
|
+
const phoneRegex =
|
|
581
|
+
/(?:\+?1[-.\s]?)?\(?([0-9]{3})\)?[-.\s]?([0-9]{3})[-.\s]?([0-9]{4})/;
|
|
582
|
+
const websiteRegex =
|
|
583
|
+
/(?:https?:\/\/)?(?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z]{2,}(?:\.[a-zA-Z]{2,})?/;
|
|
584
|
+
|
|
504
585
|
// Job title keywords for better position detection
|
|
505
|
-
const jobTitleKeywords = [
|
|
506
|
-
|
|
586
|
+
const jobTitleKeywords = [
|
|
587
|
+
'ceo',
|
|
588
|
+
'cto',
|
|
589
|
+
'cfo',
|
|
590
|
+
'manager',
|
|
591
|
+
'director',
|
|
592
|
+
'president',
|
|
593
|
+
'vice',
|
|
594
|
+
'senior',
|
|
595
|
+
'lead',
|
|
596
|
+
'head',
|
|
597
|
+
'chief',
|
|
598
|
+
'supervisor',
|
|
599
|
+
'coordinator',
|
|
600
|
+
'specialist',
|
|
601
|
+
'analyst',
|
|
602
|
+
'engineer',
|
|
603
|
+
'developer',
|
|
604
|
+
'designer',
|
|
605
|
+
'consultant',
|
|
606
|
+
'sales',
|
|
607
|
+
'marketing',
|
|
608
|
+
'hr',
|
|
609
|
+
'owner',
|
|
610
|
+
'founder',
|
|
611
|
+
];
|
|
612
|
+
|
|
507
613
|
// Company indicators
|
|
508
|
-
const companyIndicators = [
|
|
614
|
+
const companyIndicators = [
|
|
615
|
+
'inc',
|
|
616
|
+
'corp',
|
|
617
|
+
'ltd',
|
|
618
|
+
'llc',
|
|
619
|
+
'company',
|
|
620
|
+
'co.',
|
|
621
|
+
'corporation',
|
|
622
|
+
'incorporated',
|
|
623
|
+
'limited',
|
|
624
|
+
'enterprises',
|
|
625
|
+
'group',
|
|
626
|
+
'solutions',
|
|
627
|
+
'services',
|
|
628
|
+
'systems',
|
|
629
|
+
'technologies',
|
|
630
|
+
'tech',
|
|
631
|
+
'consulting',
|
|
632
|
+
];
|
|
509
633
|
|
|
510
634
|
// Smart field extraction with confidence scoring
|
|
511
635
|
lines.forEach((line, index) => {
|
|
@@ -543,7 +667,10 @@ const BusinessCardOcr = ({
|
|
|
543
667
|
// Extract website (high confidence)
|
|
544
668
|
const websiteMatch = trimmedLine.match(websiteRegex);
|
|
545
669
|
if (websiteMatch && !parsed.website && !emailMatch) {
|
|
546
|
-
const cleanedWebsite = cleanFieldValue(
|
|
670
|
+
const cleanedWebsite = cleanFieldValue(
|
|
671
|
+
websiteMatch[0],
|
|
672
|
+
'website'
|
|
673
|
+
);
|
|
547
674
|
if (cleanedWebsite) {
|
|
548
675
|
parsed.website = cleanedWebsite;
|
|
549
676
|
usedLineIndices.add(index);
|
|
@@ -552,26 +679,51 @@ const BusinessCardOcr = ({
|
|
|
552
679
|
}
|
|
553
680
|
|
|
554
681
|
// Smart name detection with enhanced validation
|
|
555
|
-
if (
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
682
|
+
if (
|
|
683
|
+
!parsed.firstname &&
|
|
684
|
+
!emailMatch &&
|
|
685
|
+
!phoneMatch &&
|
|
686
|
+
!websiteMatch
|
|
687
|
+
) {
|
|
688
|
+
const words = trimmedLine
|
|
689
|
+
.split(/\s+/)
|
|
690
|
+
.filter((word) => word.length > 0);
|
|
691
|
+
const hasJobTitle = jobTitleKeywords.some((keyword) =>
|
|
692
|
+
lowerLine.includes(keyword)
|
|
693
|
+
);
|
|
694
|
+
const hasCompanyIndicator = companyIndicators.some(
|
|
695
|
+
(indicator) => lowerLine.includes(indicator)
|
|
696
|
+
);
|
|
697
|
+
|
|
560
698
|
// Enhanced name validation
|
|
561
|
-
if (
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
!
|
|
565
|
-
|
|
566
|
-
|
|
699
|
+
if (
|
|
700
|
+
words.length >= 1 &&
|
|
701
|
+
words.length <= 3 &&
|
|
702
|
+
!hasJobTitle &&
|
|
703
|
+
!hasCompanyIndicator &&
|
|
704
|
+
!(
|
|
705
|
+
trimmedLine === trimmedLine.toUpperCase() &&
|
|
706
|
+
trimmedLine.length > 3
|
|
707
|
+
) &&
|
|
708
|
+
hasValidNameStructure(words)
|
|
709
|
+
) {
|
|
567
710
|
const firstName = words[0];
|
|
568
711
|
const lastName = words.slice(1).join(' ');
|
|
569
|
-
|
|
712
|
+
|
|
570
713
|
// Final validation for names
|
|
571
|
-
if (
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
714
|
+
if (
|
|
715
|
+
isValidName(firstName) &&
|
|
716
|
+
(lastName === '' || isValidName(lastName))
|
|
717
|
+
) {
|
|
718
|
+
const cleanedFirstName = cleanFieldValue(
|
|
719
|
+
firstName,
|
|
720
|
+
'name'
|
|
721
|
+
);
|
|
722
|
+
const cleanedLastName = cleanFieldValue(
|
|
723
|
+
lastName,
|
|
724
|
+
'name'
|
|
725
|
+
);
|
|
726
|
+
|
|
575
727
|
if (cleanedFirstName) {
|
|
576
728
|
parsed.firstname = cleanedFirstName;
|
|
577
729
|
parsed.lastname = cleanedLastName;
|
|
@@ -587,15 +739,30 @@ const BusinessCardOcr = ({
|
|
|
587
739
|
|
|
588
740
|
// Smart position/job title detection with validation
|
|
589
741
|
if (!parsed.position) {
|
|
590
|
-
const hasJobTitle = jobTitleKeywords.some(keyword =>
|
|
742
|
+
const hasJobTitle = jobTitleKeywords.some((keyword) =>
|
|
743
|
+
lowerLine.includes(keyword)
|
|
744
|
+
);
|
|
591
745
|
const words = trimmedLine.split(/\s+/);
|
|
592
|
-
const letterRatio =
|
|
593
|
-
|
|
746
|
+
const letterRatio =
|
|
747
|
+
(trimmedLine.match(/[a-zA-Z]/g) || []).length /
|
|
748
|
+
trimmedLine.length;
|
|
749
|
+
|
|
594
750
|
// Must have good letter ratio and reasonable length
|
|
595
|
-
if (
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
751
|
+
if (
|
|
752
|
+
letterRatio > 0.6 &&
|
|
753
|
+
trimmedLine.length >= 3 &&
|
|
754
|
+
trimmedLine.length <= 40 &&
|
|
755
|
+
(hasJobTitle ||
|
|
756
|
+
(words.length >= 2 &&
|
|
757
|
+
words.length <= 6 &&
|
|
758
|
+
!companyIndicators.some((indicator) =>
|
|
759
|
+
lowerLine.includes(indicator)
|
|
760
|
+
)))
|
|
761
|
+
) {
|
|
762
|
+
const cleanedPosition = cleanFieldValue(
|
|
763
|
+
trimmedLine,
|
|
764
|
+
'position'
|
|
765
|
+
);
|
|
599
766
|
if (cleanedPosition) {
|
|
600
767
|
parsed.position = cleanedPosition;
|
|
601
768
|
usedLineIndices.add(index);
|
|
@@ -606,17 +773,35 @@ const BusinessCardOcr = ({
|
|
|
606
773
|
|
|
607
774
|
// Smart company detection with validation
|
|
608
775
|
if (!parsed.company) {
|
|
609
|
-
const hasCompanyIndicator = companyIndicators.some(
|
|
610
|
-
|
|
776
|
+
const hasCompanyIndicator = companyIndicators.some(
|
|
777
|
+
(indicator) => lowerLine.includes(indicator)
|
|
778
|
+
);
|
|
779
|
+
const isAllCaps =
|
|
780
|
+
trimmedLine === trimmedLine.toUpperCase() &&
|
|
781
|
+
trimmedLine.length > 2;
|
|
611
782
|
const words = trimmedLine.split(/\s+/);
|
|
612
|
-
const letterRatio =
|
|
613
|
-
|
|
783
|
+
const letterRatio =
|
|
784
|
+
(trimmedLine.match(/[a-zA-Z]/g) || []).length /
|
|
785
|
+
trimmedLine.length;
|
|
786
|
+
|
|
614
787
|
// Must have good letter ratio and reasonable structure
|
|
615
|
-
if (
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
788
|
+
if (
|
|
789
|
+
letterRatio > 0.5 &&
|
|
790
|
+
trimmedLine.length >= 2 &&
|
|
791
|
+
trimmedLine.length <= 50 &&
|
|
792
|
+
(hasCompanyIndicator ||
|
|
793
|
+
isAllCaps ||
|
|
794
|
+
(words.length >= 1 &&
|
|
795
|
+
words.length <= 4 &&
|
|
796
|
+
index > 0 &&
|
|
797
|
+
!jobTitleKeywords.some((keyword) =>
|
|
798
|
+
lowerLine.includes(keyword)
|
|
799
|
+
)))
|
|
800
|
+
) {
|
|
801
|
+
const cleanedCompany = cleanFieldValue(
|
|
802
|
+
trimmedLine,
|
|
803
|
+
'company'
|
|
804
|
+
);
|
|
620
805
|
if (cleanedCompany) {
|
|
621
806
|
parsed.company = cleanedCompany;
|
|
622
807
|
usedLineIndices.add(index);
|
|
@@ -628,12 +813,15 @@ const BusinessCardOcr = ({
|
|
|
628
813
|
|
|
629
814
|
// Collect unused lines for manual assignment (excluding artifacts)
|
|
630
815
|
lines.forEach((line, index) => {
|
|
631
|
-
if (
|
|
816
|
+
if (
|
|
817
|
+
!usedLineIndices.has(index) &&
|
|
818
|
+
!isLikelyOCRArtifact(line.trim())
|
|
819
|
+
) {
|
|
632
820
|
const cleanedLine = cleanFieldValue(line.trim(), 'general');
|
|
633
821
|
if (cleanedLine && cleanedLine.length > 1) {
|
|
634
822
|
unusedLines.push({
|
|
635
823
|
text: cleanedLine,
|
|
636
|
-
index: index
|
|
824
|
+
index: index,
|
|
637
825
|
});
|
|
638
826
|
}
|
|
639
827
|
}
|
|
@@ -692,10 +880,12 @@ const BusinessCardOcr = ({
|
|
|
692
880
|
const hasMinimumRequiredData = () => {
|
|
693
881
|
if (fields.length > 0) {
|
|
694
882
|
// Check if at least one field has data
|
|
695
|
-
return fields.some(field => extractedData[field.id]?.trim());
|
|
883
|
+
return fields.some((field) => extractedData[field.id]?.trim());
|
|
696
884
|
} else {
|
|
697
885
|
// Fallback for hardcoded fields
|
|
698
|
-
return
|
|
886
|
+
return (
|
|
887
|
+
extractedData.first_name?.trim() || extractedData.email?.trim()
|
|
888
|
+
);
|
|
699
889
|
}
|
|
700
890
|
};
|
|
701
891
|
|
|
@@ -713,16 +903,60 @@ const BusinessCardOcr = ({
|
|
|
713
903
|
setExtractedData((prev) => ({
|
|
714
904
|
...prev,
|
|
715
905
|
[fieldId]: text,
|
|
716
|
-
_unusedData:
|
|
906
|
+
_unusedData:
|
|
907
|
+
prev._unusedData?.filter((item) => item.text !== text) || [],
|
|
717
908
|
}));
|
|
718
909
|
setShowAssignmentDropdown(false);
|
|
719
910
|
setSelectedUnusedText('');
|
|
720
911
|
};
|
|
721
912
|
|
|
722
|
-
|
|
913
|
+
// Backend consultation step
|
|
914
|
+
const consultBackend = async () => {
|
|
915
|
+
if (!fetchConfig?.url) {
|
|
916
|
+
// Fallback to direct save if no backend configuration
|
|
917
|
+
return handleDirectSave();
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
setIsConsulting(true);
|
|
921
|
+
setCurrentStep('consultation');
|
|
922
|
+
|
|
923
|
+
try {
|
|
924
|
+
const response = await CustomFetch(
|
|
925
|
+
fetchConfig.url,
|
|
926
|
+
fetchConfig.method || 'POST',
|
|
927
|
+
extractedData
|
|
928
|
+
);
|
|
929
|
+
|
|
930
|
+
if (!response.data.success) {
|
|
931
|
+
throw new Error(
|
|
932
|
+
response.data.message || 'Backend consultation failed'
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
setBackendResponse(response.data.data);
|
|
937
|
+
|
|
938
|
+
// Determine next step based on recommendations
|
|
939
|
+
const recommendations = response.data.data.recommendations || [];
|
|
940
|
+
if (recommendations.length > 0) {
|
|
941
|
+
setCurrentStep('decisions');
|
|
942
|
+
} else {
|
|
943
|
+
// No conflicts, proceed to direct save
|
|
944
|
+
await handleFinalSave();
|
|
945
|
+
}
|
|
946
|
+
} catch (error) {
|
|
947
|
+
console.error('Backend consultation error:', error);
|
|
948
|
+
toast.error(
|
|
949
|
+
'Failed to check for existing contacts. Proceeding with direct save.'
|
|
950
|
+
);
|
|
951
|
+
await handleDirectSave();
|
|
952
|
+
} finally {
|
|
953
|
+
setIsConsulting(false);
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
// Direct save for fallback scenarios
|
|
958
|
+
const handleDirectSave = async () => {
|
|
723
959
|
try {
|
|
724
|
-
// For now, just simulate saving and pass the data to the callback
|
|
725
|
-
// Real implementation would make API call to save contact
|
|
726
960
|
toast.success('Contact information extracted successfully!');
|
|
727
961
|
|
|
728
962
|
if (onContactSaved) {
|
|
@@ -736,6 +970,74 @@ const BusinessCardOcr = ({
|
|
|
736
970
|
}
|
|
737
971
|
};
|
|
738
972
|
|
|
973
|
+
// Final save after user decisions
|
|
974
|
+
const handleFinalSave = async () => {
|
|
975
|
+
setIsSaving(true);
|
|
976
|
+
|
|
977
|
+
try {
|
|
978
|
+
// Prepare final data based on user decisions
|
|
979
|
+
const finalData = {
|
|
980
|
+
...extractedData,
|
|
981
|
+
_clientDecision: selectedClient,
|
|
982
|
+
_contactDecision: contactDecision,
|
|
983
|
+
_backendResponse: backendResponse,
|
|
984
|
+
};
|
|
985
|
+
|
|
986
|
+
if (
|
|
987
|
+
fetchConfig?.url &&
|
|
988
|
+
fetchConfig?.url.includes('processBusinessCard')
|
|
989
|
+
) {
|
|
990
|
+
// Make actual API call to save contact
|
|
991
|
+
const saveUrl = fetchConfig.url.replace(
|
|
992
|
+
'processBusinessCard',
|
|
993
|
+
'saveContact'
|
|
994
|
+
);
|
|
995
|
+
|
|
996
|
+
const response = await CustomFetch(saveUrl, 'POST', finalData);
|
|
997
|
+
|
|
998
|
+
if (!response.data.success) {
|
|
999
|
+
throw new Error(response.data.message || 'Failed to save contact');
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
toast.success(response.data.message || 'Contact saved successfully!');
|
|
1003
|
+
|
|
1004
|
+
if (onContactSaved) {
|
|
1005
|
+
onContactSaved({
|
|
1006
|
+
...finalData,
|
|
1007
|
+
_savedContact: response.data.data.contact,
|
|
1008
|
+
_actionTaken: response.data.data.action_taken,
|
|
1009
|
+
_clientAction: response.data.data.client_action,
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
} else {
|
|
1013
|
+
// Fallback to callback only
|
|
1014
|
+
toast.success('Contact saved successfully!');
|
|
1015
|
+
|
|
1016
|
+
if (onContactSaved) {
|
|
1017
|
+
onContactSaved(finalData);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
handleClose();
|
|
1022
|
+
} catch (error) {
|
|
1023
|
+
console.error('Final save error:', error);
|
|
1024
|
+
toast.error('Failed to save contact. Please try again.');
|
|
1025
|
+
} finally {
|
|
1026
|
+
setIsSaving(false);
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
|
|
1030
|
+
// Main save handler - "Next" triggers consultation, "Save Contact" does direct save
|
|
1031
|
+
const handleSave = async () => {
|
|
1032
|
+
if (fetchConfig?.url) {
|
|
1033
|
+
// With backend: "Next" → Consultation → Decisions → "Confirm & Save"
|
|
1034
|
+
await consultBackend();
|
|
1035
|
+
} else {
|
|
1036
|
+
// Without backend: "Save Contact" → Direct save
|
|
1037
|
+
await handleDirectSave();
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
|
|
739
1041
|
const handleClose = () => {
|
|
740
1042
|
stopCamera();
|
|
741
1043
|
setCapturedImage(null);
|
|
@@ -745,6 +1047,11 @@ const BusinessCardOcr = ({
|
|
|
745
1047
|
setShowAssignmentDropdown(false);
|
|
746
1048
|
setSelectedUnusedText('');
|
|
747
1049
|
setCurrentCameraIndex(0);
|
|
1050
|
+
setBackendResponse(null);
|
|
1051
|
+
setSelectedClient(null);
|
|
1052
|
+
setContactDecision(null);
|
|
1053
|
+
setIsConsulting(false);
|
|
1054
|
+
setIsSaving(false);
|
|
748
1055
|
|
|
749
1056
|
if (onClose) onClose();
|
|
750
1057
|
if (onCancel) onCancel();
|
|
@@ -771,6 +1078,9 @@ const BusinessCardOcr = ({
|
|
|
771
1078
|
{currentStep === 'preview' && 'Review Capture'}
|
|
772
1079
|
{currentStep === 'processing' && 'Processing...'}
|
|
773
1080
|
{currentStep === 'results' && 'Contact Information'}
|
|
1081
|
+
{currentStep === 'consultation' &&
|
|
1082
|
+
'Checking for Duplicates...'}
|
|
1083
|
+
{currentStep === 'decisions' && 'Resolve Conflicts'}
|
|
774
1084
|
</h2>
|
|
775
1085
|
<button
|
|
776
1086
|
onClick={handleClose}
|
|
@@ -784,9 +1094,7 @@ const BusinessCardOcr = ({
|
|
|
784
1094
|
{currentStep === 'camera' && (
|
|
785
1095
|
<div className={ocrStyles.cameraContainer}>
|
|
786
1096
|
<div className={ocrStyles.captureGuide}>
|
|
787
|
-
<p>
|
|
788
|
-
Position business card within the frame
|
|
789
|
-
</p>
|
|
1097
|
+
<p>Position business card within the frame</p>
|
|
790
1098
|
</div>
|
|
791
1099
|
<div className={ocrStyles.videoWrapper}>
|
|
792
1100
|
<video
|
|
@@ -796,8 +1104,7 @@ const BusinessCardOcr = ({
|
|
|
796
1104
|
muted
|
|
797
1105
|
className={ocrStyles.cameraVideo}
|
|
798
1106
|
/>
|
|
799
|
-
<div className={ocrStyles.cameraOverlay}>
|
|
800
|
-
</div>
|
|
1107
|
+
<div className={ocrStyles.cameraOverlay}></div>
|
|
801
1108
|
</div>
|
|
802
1109
|
|
|
803
1110
|
<div className={ocrStyles.cameraControls}>
|
|
@@ -889,21 +1196,32 @@ const BusinessCardOcr = ({
|
|
|
889
1196
|
<div className={ocrStyles.formGrid}>
|
|
890
1197
|
{fields.length > 0 ? (
|
|
891
1198
|
fields.map((field) => (
|
|
892
|
-
<div
|
|
1199
|
+
<div
|
|
1200
|
+
key={field.id}
|
|
1201
|
+
className={ocrStyles.fieldGroup}
|
|
1202
|
+
>
|
|
893
1203
|
<label>
|
|
894
1204
|
{getFieldIcon(field.id)}
|
|
895
1205
|
{field.label}
|
|
896
1206
|
</label>
|
|
897
1207
|
<input
|
|
898
|
-
type={getFieldInputType(
|
|
899
|
-
|
|
1208
|
+
type={getFieldInputType(
|
|
1209
|
+
field.id
|
|
1210
|
+
)}
|
|
1211
|
+
value={
|
|
1212
|
+
extractedData[field.id] ||
|
|
1213
|
+
''
|
|
1214
|
+
}
|
|
900
1215
|
onChange={(e) =>
|
|
901
1216
|
handleInputChange(
|
|
902
1217
|
field.id,
|
|
903
1218
|
e.target.value
|
|
904
1219
|
)
|
|
905
1220
|
}
|
|
906
|
-
placeholder={getFieldPlaceholder(
|
|
1221
|
+
placeholder={getFieldPlaceholder(
|
|
1222
|
+
field.id,
|
|
1223
|
+
field.label
|
|
1224
|
+
)}
|
|
907
1225
|
className={ocrStyles.inputField}
|
|
908
1226
|
/>
|
|
909
1227
|
</div>
|
|
@@ -918,7 +1236,10 @@ const BusinessCardOcr = ({
|
|
|
918
1236
|
</label>
|
|
919
1237
|
<input
|
|
920
1238
|
type="text"
|
|
921
|
-
value={
|
|
1239
|
+
value={
|
|
1240
|
+
extractedData.first_name ||
|
|
1241
|
+
''
|
|
1242
|
+
}
|
|
922
1243
|
onChange={(e) =>
|
|
923
1244
|
handleInputChange(
|
|
924
1245
|
'first_name',
|
|
@@ -936,7 +1257,10 @@ const BusinessCardOcr = ({
|
|
|
936
1257
|
</label>
|
|
937
1258
|
<input
|
|
938
1259
|
type="text"
|
|
939
|
-
value={
|
|
1260
|
+
value={
|
|
1261
|
+
extractedData.last_name ||
|
|
1262
|
+
''
|
|
1263
|
+
}
|
|
940
1264
|
onChange={(e) =>
|
|
941
1265
|
handleInputChange(
|
|
942
1266
|
'last_name',
|
|
@@ -954,7 +1278,9 @@ const BusinessCardOcr = ({
|
|
|
954
1278
|
</label>
|
|
955
1279
|
<input
|
|
956
1280
|
type="email"
|
|
957
|
-
value={
|
|
1281
|
+
value={
|
|
1282
|
+
extractedData.email || ''
|
|
1283
|
+
}
|
|
958
1284
|
onChange={(e) =>
|
|
959
1285
|
handleInputChange(
|
|
960
1286
|
'email',
|
|
@@ -969,7 +1295,9 @@ const BusinessCardOcr = ({
|
|
|
969
1295
|
<label>Phone</label>
|
|
970
1296
|
<input
|
|
971
1297
|
type="tel"
|
|
972
|
-
value={
|
|
1298
|
+
value={
|
|
1299
|
+
extractedData.phone || ''
|
|
1300
|
+
}
|
|
973
1301
|
onChange={(e) =>
|
|
974
1302
|
handleInputChange(
|
|
975
1303
|
'phone',
|
|
@@ -987,7 +1315,9 @@ const BusinessCardOcr = ({
|
|
|
987
1315
|
</label>
|
|
988
1316
|
<input
|
|
989
1317
|
type="text"
|
|
990
|
-
value={
|
|
1318
|
+
value={
|
|
1319
|
+
extractedData.company || ''
|
|
1320
|
+
}
|
|
991
1321
|
onChange={(e) =>
|
|
992
1322
|
handleInputChange(
|
|
993
1323
|
'company',
|
|
@@ -1002,56 +1332,101 @@ const BusinessCardOcr = ({
|
|
|
1002
1332
|
)}
|
|
1003
1333
|
</div>
|
|
1004
1334
|
|
|
1005
|
-
{extractedData._unusedData &&
|
|
1006
|
-
|
|
1007
|
-
<
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1335
|
+
{extractedData._unusedData &&
|
|
1336
|
+
extractedData._unusedData.length > 0 && (
|
|
1337
|
+
<div
|
|
1338
|
+
className={ocrStyles.unusedDataSection}
|
|
1339
|
+
>
|
|
1340
|
+
<h4>Unassigned Data</h4>
|
|
1341
|
+
<p>
|
|
1342
|
+
The following text wasn't
|
|
1343
|
+
automatically assigned. Drag or
|
|
1344
|
+
click to assign to a field:
|
|
1345
|
+
</p>
|
|
1346
|
+
<div
|
|
1347
|
+
className={ocrStyles.unusedDataGrid}
|
|
1348
|
+
>
|
|
1349
|
+
{extractedData._unusedData.map(
|
|
1350
|
+
(item, index) => (
|
|
1351
|
+
<div
|
|
1352
|
+
key={`unused-${index}`}
|
|
1353
|
+
className={
|
|
1354
|
+
ocrStyles.unusedDataItem
|
|
1355
|
+
}
|
|
1356
|
+
onClick={() =>
|
|
1357
|
+
handleUnusedDataClick(
|
|
1358
|
+
item.text
|
|
1359
|
+
)
|
|
1360
|
+
}
|
|
1361
|
+
>
|
|
1362
|
+
{item.text}
|
|
1363
|
+
</div>
|
|
1364
|
+
)
|
|
1365
|
+
)}
|
|
1366
|
+
</div>
|
|
1367
|
+
{showAssignmentDropdown && (
|
|
1368
|
+
<div
|
|
1369
|
+
className={
|
|
1370
|
+
ocrStyles.assignmentDropdown
|
|
1371
|
+
}
|
|
1015
1372
|
>
|
|
1016
|
-
|
|
1373
|
+
<p>
|
|
1374
|
+
Assign "{selectedUnusedText}
|
|
1375
|
+
" to:
|
|
1376
|
+
</p>
|
|
1377
|
+
<div
|
|
1378
|
+
className={
|
|
1379
|
+
ocrStyles.assignmentOptions
|
|
1380
|
+
}
|
|
1381
|
+
>
|
|
1382
|
+
{fields.map((field) => (
|
|
1383
|
+
<button
|
|
1384
|
+
key={field.id}
|
|
1385
|
+
onClick={() =>
|
|
1386
|
+
assignToField(
|
|
1387
|
+
field.id,
|
|
1388
|
+
selectedUnusedText
|
|
1389
|
+
)
|
|
1390
|
+
}
|
|
1391
|
+
className={
|
|
1392
|
+
ocrStyles.assignmentOption
|
|
1393
|
+
}
|
|
1394
|
+
>
|
|
1395
|
+
{field.label}
|
|
1396
|
+
</button>
|
|
1397
|
+
))}
|
|
1398
|
+
</div>
|
|
1399
|
+
<button
|
|
1400
|
+
onClick={() =>
|
|
1401
|
+
setShowAssignmentDropdown(
|
|
1402
|
+
false
|
|
1403
|
+
)
|
|
1404
|
+
}
|
|
1405
|
+
className={
|
|
1406
|
+
ocrStyles.cancelAssignment
|
|
1407
|
+
}
|
|
1408
|
+
>
|
|
1409
|
+
Cancel
|
|
1410
|
+
</button>
|
|
1017
1411
|
</div>
|
|
1018
|
-
)
|
|
1412
|
+
)}
|
|
1019
1413
|
</div>
|
|
1020
|
-
|
|
1021
|
-
<div className={ocrStyles.assignmentDropdown}>
|
|
1022
|
-
<p>Assign "{selectedUnusedText}" to:</p>
|
|
1023
|
-
<div className={ocrStyles.assignmentOptions}>
|
|
1024
|
-
{fields.map((field) => (
|
|
1025
|
-
<button
|
|
1026
|
-
key={field.id}
|
|
1027
|
-
onClick={() => assignToField(field.id, selectedUnusedText)}
|
|
1028
|
-
className={ocrStyles.assignmentOption}
|
|
1029
|
-
>
|
|
1030
|
-
{field.label}
|
|
1031
|
-
</button>
|
|
1032
|
-
))}
|
|
1033
|
-
</div>
|
|
1034
|
-
<button
|
|
1035
|
-
onClick={() => setShowAssignmentDropdown(false)}
|
|
1036
|
-
className={ocrStyles.cancelAssignment}
|
|
1037
|
-
>
|
|
1038
|
-
Cancel
|
|
1039
|
-
</button>
|
|
1040
|
-
</div>
|
|
1041
|
-
)}
|
|
1042
|
-
</div>
|
|
1043
|
-
)}
|
|
1414
|
+
)}
|
|
1044
1415
|
|
|
1045
1416
|
{extractedData.rawText && (
|
|
1046
1417
|
<details className={ocrStyles.rawTextSection}>
|
|
1047
|
-
<summary
|
|
1418
|
+
<summary
|
|
1419
|
+
className={ocrStyles.rawTextSummary}
|
|
1420
|
+
>
|
|
1048
1421
|
<span className={ocrStyles.summaryText}>
|
|
1049
1422
|
📄 Raw Extracted Text
|
|
1050
1423
|
</span>
|
|
1051
1424
|
<span className={ocrStyles.summaryHint}>
|
|
1052
1425
|
(Click to expand)
|
|
1053
1426
|
</span>
|
|
1054
|
-
<span className={ocrStyles.expandIcon}
|
|
1427
|
+
<span className={ocrStyles.expandIcon}>
|
|
1428
|
+
▶
|
|
1429
|
+
</span>
|
|
1055
1430
|
</summary>
|
|
1056
1431
|
<div className={ocrStyles.rawTextContent}>
|
|
1057
1432
|
{extractedData.rawText}
|
|
@@ -1071,9 +1446,921 @@ const BusinessCardOcr = ({
|
|
|
1071
1446
|
<button
|
|
1072
1447
|
onClick={handleSave}
|
|
1073
1448
|
className={ocrStyles.primaryButton}
|
|
1074
|
-
disabled={
|
|
1449
|
+
disabled={
|
|
1450
|
+
!hasMinimumRequiredData() ||
|
|
1451
|
+
isConsulting
|
|
1452
|
+
}
|
|
1453
|
+
>
|
|
1454
|
+
{isConsulting
|
|
1455
|
+
? 'Checking...'
|
|
1456
|
+
: fetchConfig?.url
|
|
1457
|
+
? 'Next'
|
|
1458
|
+
: 'Save Contact'}
|
|
1459
|
+
</button>
|
|
1460
|
+
</div>
|
|
1461
|
+
</div>
|
|
1462
|
+
)}
|
|
1463
|
+
|
|
1464
|
+
{currentStep === 'consultation' && (
|
|
1465
|
+
<div className={ocrStyles.processingContainer}>
|
|
1466
|
+
<div className={ocrStyles.loadingSpinner}></div>
|
|
1467
|
+
<p>
|
|
1468
|
+
Checking for existing companies and contacts...
|
|
1469
|
+
</p>
|
|
1470
|
+
<small>
|
|
1471
|
+
Searching for potential matches to avoid
|
|
1472
|
+
duplicates
|
|
1473
|
+
</small>
|
|
1474
|
+
</div>
|
|
1475
|
+
)}
|
|
1476
|
+
|
|
1477
|
+
{currentStep === 'decisions' && backendResponse && (
|
|
1478
|
+
<div className={ocrStyles.decisionsContainer}>
|
|
1479
|
+
{backendResponse.recommendations?.map(
|
|
1480
|
+
(recommendation, index) => (
|
|
1481
|
+
<div
|
|
1482
|
+
key={index}
|
|
1483
|
+
className={ocrStyles.recommendationCard}
|
|
1484
|
+
>
|
|
1485
|
+
{recommendation.type ===
|
|
1486
|
+
'existing_contact' && (
|
|
1487
|
+
<div
|
|
1488
|
+
className={
|
|
1489
|
+
ocrStyles.existingContactCard
|
|
1490
|
+
}
|
|
1491
|
+
>
|
|
1492
|
+
<div
|
|
1493
|
+
className={
|
|
1494
|
+
ocrStyles.cardHeader
|
|
1495
|
+
}
|
|
1496
|
+
>
|
|
1497
|
+
<h3>
|
|
1498
|
+
⚠️ Contact Already
|
|
1499
|
+
Exists
|
|
1500
|
+
</h3>
|
|
1501
|
+
<p>
|
|
1502
|
+
{recommendation.message}
|
|
1503
|
+
</p>
|
|
1504
|
+
</div>
|
|
1505
|
+
<div
|
|
1506
|
+
className={
|
|
1507
|
+
ocrStyles.contactDetails
|
|
1508
|
+
}
|
|
1509
|
+
>
|
|
1510
|
+
<div
|
|
1511
|
+
className={
|
|
1512
|
+
ocrStyles.existingContact
|
|
1513
|
+
}
|
|
1514
|
+
>
|
|
1515
|
+
<h4>
|
|
1516
|
+
Existing Contact:
|
|
1517
|
+
</h4>
|
|
1518
|
+
<p>
|
|
1519
|
+
<strong>
|
|
1520
|
+
Name:
|
|
1521
|
+
</strong>{' '}
|
|
1522
|
+
{
|
|
1523
|
+
recommendation
|
|
1524
|
+
.data
|
|
1525
|
+
.firstname
|
|
1526
|
+
}{' '}
|
|
1527
|
+
{
|
|
1528
|
+
recommendation
|
|
1529
|
+
.data
|
|
1530
|
+
.surname
|
|
1531
|
+
}
|
|
1532
|
+
</p>
|
|
1533
|
+
<p>
|
|
1534
|
+
<strong>
|
|
1535
|
+
Email:
|
|
1536
|
+
</strong>{' '}
|
|
1537
|
+
{
|
|
1538
|
+
recommendation
|
|
1539
|
+
.data.email
|
|
1540
|
+
}
|
|
1541
|
+
</p>
|
|
1542
|
+
<p>
|
|
1543
|
+
<strong>
|
|
1544
|
+
Mobile:
|
|
1545
|
+
</strong>{' '}
|
|
1546
|
+
{recommendation.data
|
|
1547
|
+
.mobile ||
|
|
1548
|
+
'N/A'}
|
|
1549
|
+
</p>
|
|
1550
|
+
<p>
|
|
1551
|
+
<strong>
|
|
1552
|
+
Position:
|
|
1553
|
+
</strong>{' '}
|
|
1554
|
+
{recommendation.data
|
|
1555
|
+
.position ||
|
|
1556
|
+
'N/A'}
|
|
1557
|
+
</p>
|
|
1558
|
+
{recommendation.data
|
|
1559
|
+
.client && (
|
|
1560
|
+
<p>
|
|
1561
|
+
<strong>
|
|
1562
|
+
Company:
|
|
1563
|
+
</strong>{' '}
|
|
1564
|
+
{
|
|
1565
|
+
recommendation
|
|
1566
|
+
.data
|
|
1567
|
+
.client
|
|
1568
|
+
.name
|
|
1569
|
+
}
|
|
1570
|
+
</p>
|
|
1571
|
+
)}
|
|
1572
|
+
</div>
|
|
1573
|
+
<div
|
|
1574
|
+
className={
|
|
1575
|
+
ocrStyles.newContact
|
|
1576
|
+
}
|
|
1577
|
+
>
|
|
1578
|
+
<h4>New Data:</h4>
|
|
1579
|
+
<p>
|
|
1580
|
+
<strong>
|
|
1581
|
+
Name:
|
|
1582
|
+
</strong>{' '}
|
|
1583
|
+
{
|
|
1584
|
+
extractedData.firstname
|
|
1585
|
+
}{' '}
|
|
1586
|
+
{
|
|
1587
|
+
extractedData.surname
|
|
1588
|
+
}
|
|
1589
|
+
</p>
|
|
1590
|
+
<p>
|
|
1591
|
+
<strong>
|
|
1592
|
+
Email:
|
|
1593
|
+
</strong>{' '}
|
|
1594
|
+
{
|
|
1595
|
+
extractedData.email
|
|
1596
|
+
}
|
|
1597
|
+
</p>
|
|
1598
|
+
<p>
|
|
1599
|
+
<strong>
|
|
1600
|
+
Mobile:
|
|
1601
|
+
</strong>{' '}
|
|
1602
|
+
{extractedData.mobile ||
|
|
1603
|
+
'N/A'}
|
|
1604
|
+
</p>
|
|
1605
|
+
<p>
|
|
1606
|
+
<strong>
|
|
1607
|
+
Position:
|
|
1608
|
+
</strong>{' '}
|
|
1609
|
+
{extractedData.position ||
|
|
1610
|
+
'N/A'}
|
|
1611
|
+
</p>
|
|
1612
|
+
<p>
|
|
1613
|
+
<strong>
|
|
1614
|
+
Company:
|
|
1615
|
+
</strong>{' '}
|
|
1616
|
+
{extractedData.company ||
|
|
1617
|
+
'N/A'}
|
|
1618
|
+
</p>
|
|
1619
|
+
</div>
|
|
1620
|
+
</div>
|
|
1621
|
+
<div
|
|
1622
|
+
className={
|
|
1623
|
+
ocrStyles.decisionButtons
|
|
1624
|
+
}
|
|
1625
|
+
>
|
|
1626
|
+
<button
|
|
1627
|
+
className={`${
|
|
1628
|
+
ocrStyles.decisionButton
|
|
1629
|
+
} ${
|
|
1630
|
+
contactDecision ===
|
|
1631
|
+
'update'
|
|
1632
|
+
? ocrStyles.selected
|
|
1633
|
+
: ''
|
|
1634
|
+
}`}
|
|
1635
|
+
onClick={() =>
|
|
1636
|
+
setContactDecision(
|
|
1637
|
+
'update'
|
|
1638
|
+
)
|
|
1639
|
+
}
|
|
1640
|
+
>
|
|
1641
|
+
Update Existing Contact
|
|
1642
|
+
</button>
|
|
1643
|
+
<button
|
|
1644
|
+
className={`${
|
|
1645
|
+
ocrStyles.decisionButton
|
|
1646
|
+
} ${
|
|
1647
|
+
contactDecision ===
|
|
1648
|
+
'create'
|
|
1649
|
+
? ocrStyles.selected
|
|
1650
|
+
: ''
|
|
1651
|
+
}`}
|
|
1652
|
+
onClick={() =>
|
|
1653
|
+
setContactDecision(
|
|
1654
|
+
'create'
|
|
1655
|
+
)
|
|
1656
|
+
}
|
|
1657
|
+
>
|
|
1658
|
+
Create New Contact
|
|
1659
|
+
</button>
|
|
1660
|
+
</div>
|
|
1661
|
+
</div>
|
|
1662
|
+
)}
|
|
1663
|
+
|
|
1664
|
+
{recommendation.type ===
|
|
1665
|
+
'similar_clients' && (
|
|
1666
|
+
<div
|
|
1667
|
+
className={
|
|
1668
|
+
ocrStyles.clientSuggestionsCard
|
|
1669
|
+
}
|
|
1670
|
+
>
|
|
1671
|
+
<div
|
|
1672
|
+
className={
|
|
1673
|
+
ocrStyles.cardHeader
|
|
1674
|
+
}
|
|
1675
|
+
>
|
|
1676
|
+
<h3>
|
|
1677
|
+
🔍 Similar Companies
|
|
1678
|
+
Found
|
|
1679
|
+
</h3>
|
|
1680
|
+
<p>
|
|
1681
|
+
{recommendation.message}
|
|
1682
|
+
</p>
|
|
1683
|
+
</div>
|
|
1684
|
+
<div
|
|
1685
|
+
className={
|
|
1686
|
+
ocrStyles.clientSuggestions
|
|
1687
|
+
}
|
|
1688
|
+
>
|
|
1689
|
+
{recommendation.suggestions.map(
|
|
1690
|
+
(
|
|
1691
|
+
client,
|
|
1692
|
+
clientIndex
|
|
1693
|
+
) => (
|
|
1694
|
+
<div
|
|
1695
|
+
key={
|
|
1696
|
+
clientIndex
|
|
1697
|
+
}
|
|
1698
|
+
className={`${
|
|
1699
|
+
ocrStyles.clientOption
|
|
1700
|
+
} ${
|
|
1701
|
+
selectedClient?.id ===
|
|
1702
|
+
client.id
|
|
1703
|
+
? ocrStyles.selected
|
|
1704
|
+
: ''
|
|
1705
|
+
}`}
|
|
1706
|
+
onClick={() =>
|
|
1707
|
+
setSelectedClient(
|
|
1708
|
+
client
|
|
1709
|
+
)
|
|
1710
|
+
}
|
|
1711
|
+
>
|
|
1712
|
+
<div
|
|
1713
|
+
className={
|
|
1714
|
+
ocrStyles.clientInfo
|
|
1715
|
+
}
|
|
1716
|
+
>
|
|
1717
|
+
<strong>
|
|
1718
|
+
{
|
|
1719
|
+
client.name
|
|
1720
|
+
}
|
|
1721
|
+
</strong>
|
|
1722
|
+
<span
|
|
1723
|
+
className={
|
|
1724
|
+
ocrStyles.similarity
|
|
1725
|
+
}
|
|
1726
|
+
>
|
|
1727
|
+
{Math.round(
|
|
1728
|
+
client.similarity
|
|
1729
|
+
)}
|
|
1730
|
+
% match
|
|
1731
|
+
</span>
|
|
1732
|
+
{client.abn && (
|
|
1733
|
+
<small>
|
|
1734
|
+
ABN:{' '}
|
|
1735
|
+
{
|
|
1736
|
+
client.abn
|
|
1737
|
+
}
|
|
1738
|
+
</small>
|
|
1739
|
+
)}
|
|
1740
|
+
</div>
|
|
1741
|
+
</div>
|
|
1742
|
+
)
|
|
1743
|
+
)}
|
|
1744
|
+
<div
|
|
1745
|
+
className={`${
|
|
1746
|
+
ocrStyles.clientOption
|
|
1747
|
+
} ${
|
|
1748
|
+
ocrStyles.newClientOption
|
|
1749
|
+
} ${
|
|
1750
|
+
selectedClient?.isNew
|
|
1751
|
+
? ocrStyles.selected
|
|
1752
|
+
: ''
|
|
1753
|
+
}`}
|
|
1754
|
+
onClick={() =>
|
|
1755
|
+
setSelectedClient({
|
|
1756
|
+
isNew: true,
|
|
1757
|
+
name: extractedData.company,
|
|
1758
|
+
})
|
|
1759
|
+
}
|
|
1760
|
+
>
|
|
1761
|
+
<div
|
|
1762
|
+
className={
|
|
1763
|
+
ocrStyles.clientInfo
|
|
1764
|
+
}
|
|
1765
|
+
>
|
|
1766
|
+
<strong>
|
|
1767
|
+
Create New
|
|
1768
|
+
Company: "
|
|
1769
|
+
{
|
|
1770
|
+
extractedData.company
|
|
1771
|
+
}
|
|
1772
|
+
"
|
|
1773
|
+
</strong>
|
|
1774
|
+
<span
|
|
1775
|
+
className={
|
|
1776
|
+
ocrStyles.similarity
|
|
1777
|
+
}
|
|
1778
|
+
>
|
|
1779
|
+
New
|
|
1780
|
+
</span>
|
|
1781
|
+
</div>
|
|
1782
|
+
</div>
|
|
1783
|
+
</div>
|
|
1784
|
+
</div>
|
|
1785
|
+
)}
|
|
1786
|
+
|
|
1787
|
+
{recommendation.type ===
|
|
1788
|
+
'new_contact_existing_client' && (
|
|
1789
|
+
<div
|
|
1790
|
+
className={
|
|
1791
|
+
ocrStyles.existingClientCard
|
|
1792
|
+
}
|
|
1793
|
+
>
|
|
1794
|
+
<div
|
|
1795
|
+
className={
|
|
1796
|
+
ocrStyles.cardHeader
|
|
1797
|
+
}
|
|
1798
|
+
>
|
|
1799
|
+
<h3>
|
|
1800
|
+
✅ Perfect Company Match
|
|
1801
|
+
</h3>
|
|
1802
|
+
<p>
|
|
1803
|
+
Create new contact for
|
|
1804
|
+
existing company:{' '}
|
|
1805
|
+
<strong>
|
|
1806
|
+
{
|
|
1807
|
+
backendResponse
|
|
1808
|
+
.client_exact_match
|
|
1809
|
+
?.name
|
|
1810
|
+
}
|
|
1811
|
+
</strong>
|
|
1812
|
+
</p>
|
|
1813
|
+
</div>
|
|
1814
|
+
<div
|
|
1815
|
+
className={
|
|
1816
|
+
ocrStyles.autoSelected
|
|
1817
|
+
}
|
|
1818
|
+
>
|
|
1819
|
+
Company automatically
|
|
1820
|
+
selected. Contact will be
|
|
1821
|
+
created.
|
|
1822
|
+
</div>
|
|
1823
|
+
</div>
|
|
1824
|
+
)}
|
|
1825
|
+
|
|
1826
|
+
{recommendation.type ===
|
|
1827
|
+
'new_client_and_contact' && (
|
|
1828
|
+
<div
|
|
1829
|
+
className={
|
|
1830
|
+
ocrStyles.newBothCard
|
|
1831
|
+
}
|
|
1832
|
+
>
|
|
1833
|
+
<div
|
|
1834
|
+
className={
|
|
1835
|
+
ocrStyles.cardHeader
|
|
1836
|
+
}
|
|
1837
|
+
>
|
|
1838
|
+
<h3>
|
|
1839
|
+
🆕 New Company & Contact
|
|
1840
|
+
</h3>
|
|
1841
|
+
<p>
|
|
1842
|
+
No matches found. Will
|
|
1843
|
+
create new company and
|
|
1844
|
+
contact.
|
|
1845
|
+
</p>
|
|
1846
|
+
</div>
|
|
1847
|
+
<div
|
|
1848
|
+
className={
|
|
1849
|
+
ocrStyles.autoSelected
|
|
1850
|
+
}
|
|
1851
|
+
>
|
|
1852
|
+
New company "
|
|
1853
|
+
{extractedData.company}" and
|
|
1854
|
+
contact will be created.
|
|
1855
|
+
</div>
|
|
1856
|
+
</div>
|
|
1857
|
+
)}
|
|
1858
|
+
|
|
1859
|
+
{recommendation.type ===
|
|
1860
|
+
'contact_exists_different_client' && (
|
|
1861
|
+
<div
|
|
1862
|
+
className={
|
|
1863
|
+
ocrStyles.conflictCard
|
|
1864
|
+
}
|
|
1865
|
+
>
|
|
1866
|
+
<div
|
|
1867
|
+
className={
|
|
1868
|
+
ocrStyles.cardHeader
|
|
1869
|
+
}
|
|
1870
|
+
>
|
|
1871
|
+
<h3>
|
|
1872
|
+
⚠️ Contact & Company Conflict
|
|
1873
|
+
</h3>
|
|
1874
|
+
<p>
|
|
1875
|
+
{recommendation.message}
|
|
1876
|
+
</p>
|
|
1877
|
+
</div>
|
|
1878
|
+
<div
|
|
1879
|
+
className={
|
|
1880
|
+
ocrStyles.contactDetails
|
|
1881
|
+
}
|
|
1882
|
+
>
|
|
1883
|
+
<div
|
|
1884
|
+
className={
|
|
1885
|
+
ocrStyles.existingContact
|
|
1886
|
+
}
|
|
1887
|
+
>
|
|
1888
|
+
<h4>
|
|
1889
|
+
Existing Contact:
|
|
1890
|
+
</h4>
|
|
1891
|
+
<p>
|
|
1892
|
+
<strong>Name:</strong>{' '}
|
|
1893
|
+
{recommendation.contact.firstname} {recommendation.contact.surname}
|
|
1894
|
+
</p>
|
|
1895
|
+
<p>
|
|
1896
|
+
<strong>Company:</strong>{' '}
|
|
1897
|
+
{recommendation.contact.client?.name || 'No company'}
|
|
1898
|
+
</p>
|
|
1899
|
+
<p>
|
|
1900
|
+
<strong>Email:</strong>{' '}
|
|
1901
|
+
{recommendation.contact.email}
|
|
1902
|
+
</p>
|
|
1903
|
+
</div>
|
|
1904
|
+
<div
|
|
1905
|
+
className={
|
|
1906
|
+
ocrStyles.newContact
|
|
1907
|
+
}
|
|
1908
|
+
>
|
|
1909
|
+
<h4>
|
|
1910
|
+
Matched Company:
|
|
1911
|
+
</h4>
|
|
1912
|
+
<p>
|
|
1913
|
+
<strong>Company:</strong>{' '}
|
|
1914
|
+
{recommendation.matched_client.name}
|
|
1915
|
+
</p>
|
|
1916
|
+
<p>
|
|
1917
|
+
<strong>Scanned Email:</strong>{' '}
|
|
1918
|
+
{extractedData.email}
|
|
1919
|
+
</p>
|
|
1920
|
+
</div>
|
|
1921
|
+
</div>
|
|
1922
|
+
<div
|
|
1923
|
+
className={
|
|
1924
|
+
ocrStyles.decisionButtons
|
|
1925
|
+
}
|
|
1926
|
+
>
|
|
1927
|
+
<button
|
|
1928
|
+
className={`${
|
|
1929
|
+
ocrStyles.decisionButton
|
|
1930
|
+
} ${
|
|
1931
|
+
contactDecision ===
|
|
1932
|
+
'update'
|
|
1933
|
+
? ocrStyles.selected
|
|
1934
|
+
: ''
|
|
1935
|
+
}`}
|
|
1936
|
+
onClick={() => {
|
|
1937
|
+
setContactDecision('update');
|
|
1938
|
+
setSelectedClient(recommendation.matched_client);
|
|
1939
|
+
}}
|
|
1940
|
+
>
|
|
1941
|
+
Move Contact to Matched Company
|
|
1942
|
+
</button>
|
|
1943
|
+
<button
|
|
1944
|
+
className={`${
|
|
1945
|
+
ocrStyles.decisionButton
|
|
1946
|
+
} ${
|
|
1947
|
+
contactDecision ===
|
|
1948
|
+
'create'
|
|
1949
|
+
? ocrStyles.selected
|
|
1950
|
+
: ''
|
|
1951
|
+
}`}
|
|
1952
|
+
onClick={() => {
|
|
1953
|
+
setContactDecision('create');
|
|
1954
|
+
setSelectedClient(recommendation.matched_client);
|
|
1955
|
+
}}
|
|
1956
|
+
>
|
|
1957
|
+
Create New Contact for Matched Company
|
|
1958
|
+
</button>
|
|
1959
|
+
</div>
|
|
1960
|
+
</div>
|
|
1961
|
+
)}
|
|
1962
|
+
|
|
1963
|
+
{recommendation.type ===
|
|
1964
|
+
'contact_with_suggested_clients' && (
|
|
1965
|
+
<div
|
|
1966
|
+
className={
|
|
1967
|
+
ocrStyles.existingContactCard
|
|
1968
|
+
}
|
|
1969
|
+
>
|
|
1970
|
+
<div
|
|
1971
|
+
className={
|
|
1972
|
+
ocrStyles.cardHeader
|
|
1973
|
+
}
|
|
1974
|
+
>
|
|
1975
|
+
<h3>
|
|
1976
|
+
✅ Contact Found in Suggested Companies
|
|
1977
|
+
</h3>
|
|
1978
|
+
<p>
|
|
1979
|
+
{recommendation.message}
|
|
1980
|
+
</p>
|
|
1981
|
+
</div>
|
|
1982
|
+
<div
|
|
1983
|
+
className={
|
|
1984
|
+
ocrStyles.contactDetails
|
|
1985
|
+
}
|
|
1986
|
+
>
|
|
1987
|
+
<div
|
|
1988
|
+
className={
|
|
1989
|
+
ocrStyles.existingContact
|
|
1990
|
+
}
|
|
1991
|
+
>
|
|
1992
|
+
<h4>
|
|
1993
|
+
Existing Contact:
|
|
1994
|
+
</h4>
|
|
1995
|
+
<p>
|
|
1996
|
+
<strong>Name:</strong>{' '}
|
|
1997
|
+
{recommendation.contact.firstname} {recommendation.contact.surname}
|
|
1998
|
+
</p>
|
|
1999
|
+
<p>
|
|
2000
|
+
<strong>Company:</strong>{' '}
|
|
2001
|
+
{recommendation.contact.client?.name}
|
|
2002
|
+
</p>
|
|
2003
|
+
<p>
|
|
2004
|
+
<strong>Email:</strong>{' '}
|
|
2005
|
+
{recommendation.contact.email}
|
|
2006
|
+
</p>
|
|
2007
|
+
</div>
|
|
2008
|
+
<div
|
|
2009
|
+
className={
|
|
2010
|
+
ocrStyles.newContact
|
|
2011
|
+
}
|
|
2012
|
+
>
|
|
2013
|
+
<h4>New Data:</h4>
|
|
2014
|
+
<p>
|
|
2015
|
+
<strong>Name:</strong>{' '}
|
|
2016
|
+
{extractedData.firstname} {extractedData.surname}
|
|
2017
|
+
</p>
|
|
2018
|
+
<p>
|
|
2019
|
+
<strong>Position:</strong>{' '}
|
|
2020
|
+
{extractedData.position || 'N/A'}
|
|
2021
|
+
</p>
|
|
2022
|
+
<p>
|
|
2023
|
+
<strong>Mobile:</strong>{' '}
|
|
2024
|
+
{extractedData.mobile || 'N/A'}
|
|
2025
|
+
</p>
|
|
2026
|
+
</div>
|
|
2027
|
+
</div>
|
|
2028
|
+
<div
|
|
2029
|
+
className={
|
|
2030
|
+
ocrStyles.decisionButtons
|
|
2031
|
+
}
|
|
2032
|
+
>
|
|
2033
|
+
<button
|
|
2034
|
+
className={`${
|
|
2035
|
+
ocrStyles.decisionButton
|
|
2036
|
+
} ${
|
|
2037
|
+
contactDecision ===
|
|
2038
|
+
'update'
|
|
2039
|
+
? ocrStyles.selected
|
|
2040
|
+
: ''
|
|
2041
|
+
}`}
|
|
2042
|
+
onClick={() => {
|
|
2043
|
+
setContactDecision('update');
|
|
2044
|
+
setSelectedClient(recommendation.contact.client);
|
|
2045
|
+
}}
|
|
2046
|
+
>
|
|
2047
|
+
Update Existing Contact
|
|
2048
|
+
</button>
|
|
2049
|
+
<button
|
|
2050
|
+
className={`${
|
|
2051
|
+
ocrStyles.decisionButton
|
|
2052
|
+
} ${
|
|
2053
|
+
contactDecision ===
|
|
2054
|
+
'create'
|
|
2055
|
+
? ocrStyles.selected
|
|
2056
|
+
: ''
|
|
2057
|
+
}`}
|
|
2058
|
+
onClick={() => {
|
|
2059
|
+
setContactDecision('create');
|
|
2060
|
+
}}
|
|
2061
|
+
>
|
|
2062
|
+
Create New Contact
|
|
2063
|
+
</button>
|
|
2064
|
+
</div>
|
|
2065
|
+
|
|
2066
|
+
{/* Show client suggestions below */}
|
|
2067
|
+
<div className={ocrStyles.clientSuggestions} style={{marginTop: '1rem'}}>
|
|
2068
|
+
<h4>Company Options:</h4>
|
|
2069
|
+
{recommendation.suggestions.map((client, clientIndex) => (
|
|
2070
|
+
<div
|
|
2071
|
+
key={clientIndex}
|
|
2072
|
+
className={`${ocrStyles.clientOption} ${selectedClient?.id === client.id ? ocrStyles.selected : ''}`}
|
|
2073
|
+
onClick={() => setSelectedClient(client)}
|
|
2074
|
+
>
|
|
2075
|
+
<div className={ocrStyles.clientInfo}>
|
|
2076
|
+
<strong>{client.name}</strong>
|
|
2077
|
+
<span className={ocrStyles.similarity}>{Math.round(client.similarity)}% match</span>
|
|
2078
|
+
{client.abn && <small>ABN: {client.abn}</small>}
|
|
2079
|
+
</div>
|
|
2080
|
+
</div>
|
|
2081
|
+
))}
|
|
2082
|
+
</div>
|
|
2083
|
+
</div>
|
|
2084
|
+
)}
|
|
2085
|
+
|
|
2086
|
+
{recommendation.type ===
|
|
2087
|
+
'contact_exists_with_client_suggestions' && (
|
|
2088
|
+
<div
|
|
2089
|
+
className={
|
|
2090
|
+
ocrStyles.conflictCard
|
|
2091
|
+
}
|
|
2092
|
+
>
|
|
2093
|
+
<div
|
|
2094
|
+
className={
|
|
2095
|
+
ocrStyles.cardHeader
|
|
2096
|
+
}
|
|
2097
|
+
>
|
|
2098
|
+
<h3>
|
|
2099
|
+
⚠️ Complex Contact & Company Conflict
|
|
2100
|
+
</h3>
|
|
2101
|
+
<p>
|
|
2102
|
+
{recommendation.message}
|
|
2103
|
+
</p>
|
|
2104
|
+
</div>
|
|
2105
|
+
<div
|
|
2106
|
+
className={
|
|
2107
|
+
ocrStyles.contactDetails
|
|
2108
|
+
}
|
|
2109
|
+
>
|
|
2110
|
+
<div
|
|
2111
|
+
className={
|
|
2112
|
+
ocrStyles.existingContact
|
|
2113
|
+
}
|
|
2114
|
+
>
|
|
2115
|
+
<h4>
|
|
2116
|
+
Existing Contact:
|
|
2117
|
+
</h4>
|
|
2118
|
+
<p>
|
|
2119
|
+
<strong>Name:</strong>{' '}
|
|
2120
|
+
{recommendation.contact.firstname} {recommendation.contact.surname}
|
|
2121
|
+
</p>
|
|
2122
|
+
<p>
|
|
2123
|
+
<strong>Company:</strong>{' '}
|
|
2124
|
+
{recommendation.contact.client?.name || 'No company'}
|
|
2125
|
+
</p>
|
|
2126
|
+
<p>
|
|
2127
|
+
<strong>Email:</strong>{' '}
|
|
2128
|
+
{recommendation.contact.email}
|
|
2129
|
+
</p>
|
|
2130
|
+
</div>
|
|
2131
|
+
<div
|
|
2132
|
+
className={
|
|
2133
|
+
ocrStyles.newContact
|
|
2134
|
+
}
|
|
2135
|
+
>
|
|
2136
|
+
<h4>Scanned Card Data:</h4>
|
|
2137
|
+
<p>
|
|
2138
|
+
<strong>Name:</strong>{' '}
|
|
2139
|
+
{extractedData.firstname} {extractedData.surname}
|
|
2140
|
+
</p>
|
|
2141
|
+
<p>
|
|
2142
|
+
<strong>Company:</strong>{' '}
|
|
2143
|
+
{extractedData.company}
|
|
2144
|
+
</p>
|
|
2145
|
+
<p>
|
|
2146
|
+
<strong>Email:</strong>{' '}
|
|
2147
|
+
{extractedData.email}
|
|
2148
|
+
</p>
|
|
2149
|
+
</div>
|
|
2150
|
+
</div>
|
|
2151
|
+
|
|
2152
|
+
{/* Show client suggestions */}
|
|
2153
|
+
<div className={ocrStyles.clientSuggestions} style={{margin: '1rem 0'}}>
|
|
2154
|
+
<h4>Similar Companies Found:</h4>
|
|
2155
|
+
{recommendation.suggestions.map((client, clientIndex) => (
|
|
2156
|
+
<div
|
|
2157
|
+
key={clientIndex}
|
|
2158
|
+
className={`${ocrStyles.clientOption} ${selectedClient?.id === client.id ? ocrStyles.selected : ''}`}
|
|
2159
|
+
onClick={() => setSelectedClient(client)}
|
|
2160
|
+
>
|
|
2161
|
+
<div className={ocrStyles.clientInfo}>
|
|
2162
|
+
<strong>{client.name}</strong>
|
|
2163
|
+
<span className={ocrStyles.similarity}>{Math.round(client.similarity)}% match</span>
|
|
2164
|
+
{client.abn && <small>ABN: {client.abn}</small>}
|
|
2165
|
+
</div>
|
|
2166
|
+
</div>
|
|
2167
|
+
))}
|
|
2168
|
+
<div
|
|
2169
|
+
className={`${ocrStyles.clientOption} ${ocrStyles.newClientOption} ${selectedClient?.isNew ? ocrStyles.selected : ''}`}
|
|
2170
|
+
onClick={() => setSelectedClient({ isNew: true, name: extractedData.company })}
|
|
2171
|
+
>
|
|
2172
|
+
<div className={ocrStyles.clientInfo}>
|
|
2173
|
+
<strong>Create New Company: "{extractedData.company}"</strong>
|
|
2174
|
+
<span className={ocrStyles.similarity}>New</span>
|
|
2175
|
+
</div>
|
|
2176
|
+
</div>
|
|
2177
|
+
</div>
|
|
2178
|
+
|
|
2179
|
+
<div
|
|
2180
|
+
className={
|
|
2181
|
+
ocrStyles.decisionButtons
|
|
2182
|
+
}
|
|
2183
|
+
>
|
|
2184
|
+
<button
|
|
2185
|
+
className={`${
|
|
2186
|
+
ocrStyles.decisionButton
|
|
2187
|
+
} ${
|
|
2188
|
+
contactDecision ===
|
|
2189
|
+
'update'
|
|
2190
|
+
? ocrStyles.selected
|
|
2191
|
+
: ''
|
|
2192
|
+
}`}
|
|
2193
|
+
onClick={() => setContactDecision('update')}
|
|
2194
|
+
>
|
|
2195
|
+
Update Contact's Company
|
|
2196
|
+
</button>
|
|
2197
|
+
<button
|
|
2198
|
+
className={`${
|
|
2199
|
+
ocrStyles.decisionButton
|
|
2200
|
+
} ${
|
|
2201
|
+
contactDecision ===
|
|
2202
|
+
'create'
|
|
2203
|
+
? ocrStyles.selected
|
|
2204
|
+
: ''
|
|
2205
|
+
}`}
|
|
2206
|
+
onClick={() => setContactDecision('create')}
|
|
2207
|
+
>
|
|
2208
|
+
Create New Contact
|
|
2209
|
+
</button>
|
|
2210
|
+
</div>
|
|
2211
|
+
</div>
|
|
2212
|
+
)}
|
|
2213
|
+
|
|
2214
|
+
{recommendation.type ===
|
|
2215
|
+
'contact_exists_no_client_match' && (
|
|
2216
|
+
<div
|
|
2217
|
+
className={
|
|
2218
|
+
ocrStyles.conflictCard
|
|
2219
|
+
}
|
|
2220
|
+
>
|
|
2221
|
+
<div
|
|
2222
|
+
className={
|
|
2223
|
+
ocrStyles.cardHeader
|
|
2224
|
+
}
|
|
2225
|
+
>
|
|
2226
|
+
<h3>
|
|
2227
|
+
⚠️ Contact Exists with
|
|
2228
|
+
Different Company
|
|
2229
|
+
</h3>
|
|
2230
|
+
<p>
|
|
2231
|
+
{recommendation.message}
|
|
2232
|
+
</p>
|
|
2233
|
+
</div>
|
|
2234
|
+
<div
|
|
2235
|
+
className={
|
|
2236
|
+
ocrStyles.contactDetails
|
|
2237
|
+
}
|
|
2238
|
+
>
|
|
2239
|
+
<div
|
|
2240
|
+
className={
|
|
2241
|
+
ocrStyles.existingContact
|
|
2242
|
+
}
|
|
2243
|
+
>
|
|
2244
|
+
<h4>
|
|
2245
|
+
Existing Contact:
|
|
2246
|
+
</h4>
|
|
2247
|
+
<p>
|
|
2248
|
+
<strong>
|
|
2249
|
+
Company:
|
|
2250
|
+
</strong>{' '}
|
|
2251
|
+
{recommendation
|
|
2252
|
+
.contact.client
|
|
2253
|
+
?.name ||
|
|
2254
|
+
'No company'}
|
|
2255
|
+
</p>
|
|
2256
|
+
<p>
|
|
2257
|
+
<strong>
|
|
2258
|
+
Email:
|
|
2259
|
+
</strong>{' '}
|
|
2260
|
+
{
|
|
2261
|
+
recommendation
|
|
2262
|
+
.contact
|
|
2263
|
+
.email
|
|
2264
|
+
}
|
|
2265
|
+
</p>
|
|
2266
|
+
</div>
|
|
2267
|
+
<div
|
|
2268
|
+
className={
|
|
2269
|
+
ocrStyles.newContact
|
|
2270
|
+
}
|
|
2271
|
+
>
|
|
2272
|
+
<h4>
|
|
2273
|
+
Scanned Card Says:
|
|
2274
|
+
</h4>
|
|
2275
|
+
<p>
|
|
2276
|
+
<strong>
|
|
2277
|
+
Company:
|
|
2278
|
+
</strong>{' '}
|
|
2279
|
+
{
|
|
2280
|
+
extractedData.company
|
|
2281
|
+
}
|
|
2282
|
+
</p>
|
|
2283
|
+
<p>
|
|
2284
|
+
<strong>
|
|
2285
|
+
Email:
|
|
2286
|
+
</strong>{' '}
|
|
2287
|
+
{
|
|
2288
|
+
extractedData.email
|
|
2289
|
+
}
|
|
2290
|
+
</p>
|
|
2291
|
+
</div>
|
|
2292
|
+
</div>
|
|
2293
|
+
<div
|
|
2294
|
+
className={
|
|
2295
|
+
ocrStyles.decisionButtons
|
|
2296
|
+
}
|
|
2297
|
+
>
|
|
2298
|
+
<button
|
|
2299
|
+
className={`${
|
|
2300
|
+
ocrStyles.decisionButton
|
|
2301
|
+
} ${
|
|
2302
|
+
contactDecision ===
|
|
2303
|
+
'update'
|
|
2304
|
+
? ocrStyles.selected
|
|
2305
|
+
: ''
|
|
2306
|
+
}`}
|
|
2307
|
+
onClick={() =>
|
|
2308
|
+
setContactDecision(
|
|
2309
|
+
'update'
|
|
2310
|
+
)
|
|
2311
|
+
}
|
|
2312
|
+
>
|
|
2313
|
+
Update Contact's Company
|
|
2314
|
+
</button>
|
|
2315
|
+
<button
|
|
2316
|
+
className={`${
|
|
2317
|
+
ocrStyles.decisionButton
|
|
2318
|
+
} ${
|
|
2319
|
+
contactDecision ===
|
|
2320
|
+
'create'
|
|
2321
|
+
? ocrStyles.selected
|
|
2322
|
+
: ''
|
|
2323
|
+
}`}
|
|
2324
|
+
onClick={() =>
|
|
2325
|
+
setContactDecision(
|
|
2326
|
+
'create'
|
|
2327
|
+
)
|
|
2328
|
+
}
|
|
2329
|
+
>
|
|
2330
|
+
Create New Contact
|
|
2331
|
+
</button>
|
|
2332
|
+
</div>
|
|
2333
|
+
</div>
|
|
2334
|
+
)}
|
|
2335
|
+
</div>
|
|
2336
|
+
)
|
|
2337
|
+
)}
|
|
2338
|
+
|
|
2339
|
+
<div className={ocrStyles.decisionActions}>
|
|
2340
|
+
<button
|
|
2341
|
+
onClick={() => setCurrentStep('results')}
|
|
2342
|
+
className={ocrStyles.secondaryButton}
|
|
2343
|
+
>
|
|
2344
|
+
Back to Edit
|
|
2345
|
+
</button>
|
|
2346
|
+
<button
|
|
2347
|
+
onClick={handleFinalSave}
|
|
2348
|
+
className={ocrStyles.primaryButton}
|
|
2349
|
+
disabled={
|
|
2350
|
+
isSaving ||
|
|
2351
|
+
(!selectedClient &&
|
|
2352
|
+
!contactDecision &&
|
|
2353
|
+
backendResponse.recommendations?.some(
|
|
2354
|
+
(r) =>
|
|
2355
|
+
[
|
|
2356
|
+
'similar_clients',
|
|
2357
|
+
'existing_contact',
|
|
2358
|
+
'contact_exists_no_client_match',
|
|
2359
|
+
].includes(r.type)
|
|
2360
|
+
))
|
|
2361
|
+
}
|
|
1075
2362
|
>
|
|
1076
|
-
Save
|
|
2363
|
+
{isSaving ? 'Saving...' : 'Confirm & Save'}
|
|
1077
2364
|
</button>
|
|
1078
2365
|
</div>
|
|
1079
2366
|
</div>
|