@visns-studio/visns-components 5.9.6 → 5.9.8
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.
|
@@ -0,0 +1,2376 @@
|
|
|
1
|
+
import '../styles/global.css';
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
4
|
+
import { motion } from 'framer-motion';
|
|
5
|
+
import { toast } from 'react-toastify';
|
|
6
|
+
import Tesseract from 'tesseract.js';
|
|
7
|
+
import {
|
|
8
|
+
Camera,
|
|
9
|
+
Image as ImageIcon,
|
|
10
|
+
CircleX,
|
|
11
|
+
ArrowCycle,
|
|
12
|
+
CircleCheck,
|
|
13
|
+
ArrowCounterClockwise,
|
|
14
|
+
Envelope,
|
|
15
|
+
Network,
|
|
16
|
+
Edit,
|
|
17
|
+
Plus,
|
|
18
|
+
} from 'akar-icons';
|
|
19
|
+
|
|
20
|
+
import CustomFetch from './Fetch';
|
|
21
|
+
import ocrStyles from './styles/BusinessCardOcr.module.scss';
|
|
22
|
+
|
|
23
|
+
const BusinessCardOcr = ({
|
|
24
|
+
onContactSaved,
|
|
25
|
+
onCancel,
|
|
26
|
+
isOpen = false,
|
|
27
|
+
onClose,
|
|
28
|
+
fields = [],
|
|
29
|
+
fetchConfig = null, // { url, method } from Navigation settings
|
|
30
|
+
}) => {
|
|
31
|
+
const [currentStep, setCurrentStep] = useState('camera'); // camera, preview, processing, results, consultation, decisions
|
|
32
|
+
const [capturedImage, setCapturedImage] = useState(null);
|
|
33
|
+
const [cameraStream, setCameraStream] = useState(null);
|
|
34
|
+
const [cameraFacing, setCameraFacing] = useState('environment'); // environment (back), user (front)
|
|
35
|
+
const [availableCameras, setAvailableCameras] = useState([]);
|
|
36
|
+
const [currentCameraIndex, setCurrentCameraIndex] = useState(0);
|
|
37
|
+
const [isProcessing, setIsProcessing] = useState(false);
|
|
38
|
+
const [extractedData, setExtractedData] = useState({});
|
|
39
|
+
const [ocrProgress, setOcrProgress] = useState(0);
|
|
40
|
+
const [showAssignmentDropdown, setShowAssignmentDropdown] = useState(false);
|
|
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);
|
|
47
|
+
|
|
48
|
+
const videoRef = useRef(null);
|
|
49
|
+
const canvasRef = useRef(null);
|
|
50
|
+
const fileInputRef = useRef(null);
|
|
51
|
+
|
|
52
|
+
// Detect available cameras when component opens
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (isOpen) {
|
|
55
|
+
detectCameras();
|
|
56
|
+
}
|
|
57
|
+
}, [isOpen]);
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
// Initialize camera when component opens
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (isOpen && currentStep === 'camera') {
|
|
63
|
+
initializeCamera();
|
|
64
|
+
}
|
|
65
|
+
return () => {
|
|
66
|
+
stopCamera();
|
|
67
|
+
};
|
|
68
|
+
}, [
|
|
69
|
+
isOpen,
|
|
70
|
+
currentStep,
|
|
71
|
+
cameraFacing,
|
|
72
|
+
currentCameraIndex,
|
|
73
|
+
availableCameras,
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
const detectCameras = async () => {
|
|
77
|
+
try {
|
|
78
|
+
// Request permission first to get proper device labels
|
|
79
|
+
await navigator.mediaDevices.getUserMedia({ video: true });
|
|
80
|
+
|
|
81
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
82
|
+
const videoDevices = devices.filter(
|
|
83
|
+
(device) => device.kind === 'videoinput' && device.deviceId
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Filter out devices that might not work
|
|
87
|
+
const workingCameras = [];
|
|
88
|
+
for (const device of videoDevices) {
|
|
89
|
+
try {
|
|
90
|
+
const testStream =
|
|
91
|
+
await navigator.mediaDevices.getUserMedia({
|
|
92
|
+
video: { deviceId: device.deviceId },
|
|
93
|
+
});
|
|
94
|
+
testStream.getTracks().forEach((track) => track.stop());
|
|
95
|
+
workingCameras.push(device);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.warn(
|
|
98
|
+
`Camera ${
|
|
99
|
+
device.label || device.deviceId
|
|
100
|
+
} not accessible:`,
|
|
101
|
+
err
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
setAvailableCameras(workingCameras);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
console.error('Error detecting cameras:', error);
|
|
109
|
+
setAvailableCameras([]);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const initializeCamera = async () => {
|
|
114
|
+
try {
|
|
115
|
+
let constraints;
|
|
116
|
+
|
|
117
|
+
// Use specific device if available cameras are detected
|
|
118
|
+
if (
|
|
119
|
+
availableCameras.length > 0 &&
|
|
120
|
+
availableCameras[currentCameraIndex]
|
|
121
|
+
) {
|
|
122
|
+
constraints = {
|
|
123
|
+
video: {
|
|
124
|
+
deviceId: {
|
|
125
|
+
exact: availableCameras[currentCameraIndex]
|
|
126
|
+
.deviceId,
|
|
127
|
+
},
|
|
128
|
+
width: { ideal: 1280 },
|
|
129
|
+
height: { ideal: 720 },
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
} else {
|
|
133
|
+
// Fallback to facing mode
|
|
134
|
+
constraints = {
|
|
135
|
+
video: {
|
|
136
|
+
facingMode: cameraFacing,
|
|
137
|
+
width: { ideal: 1280 },
|
|
138
|
+
height: { ideal: 720 },
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const stream = await navigator.mediaDevices.getUserMedia(
|
|
144
|
+
constraints
|
|
145
|
+
);
|
|
146
|
+
setCameraStream(stream);
|
|
147
|
+
|
|
148
|
+
if (videoRef.current) {
|
|
149
|
+
videoRef.current.srcObject = stream;
|
|
150
|
+
}
|
|
151
|
+
} catch (error) {
|
|
152
|
+
console.error('Error accessing camera:', error);
|
|
153
|
+
toast.error('Unable to access camera. Please check permissions.');
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const stopCamera = () => {
|
|
158
|
+
if (cameraStream) {
|
|
159
|
+
cameraStream.getTracks().forEach((track) => track.stop());
|
|
160
|
+
setCameraStream(null);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const capturePhoto = useCallback(() => {
|
|
165
|
+
if (!videoRef.current || !canvasRef.current) return;
|
|
166
|
+
|
|
167
|
+
const video = videoRef.current;
|
|
168
|
+
const canvas = canvasRef.current;
|
|
169
|
+
const context = canvas.getContext('2d');
|
|
170
|
+
|
|
171
|
+
canvas.width = video.videoWidth;
|
|
172
|
+
canvas.height = video.videoHeight;
|
|
173
|
+
context.drawImage(video, 0, 0);
|
|
174
|
+
|
|
175
|
+
canvas.toBlob(
|
|
176
|
+
(blob) => {
|
|
177
|
+
setCapturedImage(blob);
|
|
178
|
+
setCurrentStep('preview');
|
|
179
|
+
stopCamera();
|
|
180
|
+
},
|
|
181
|
+
'image/jpeg',
|
|
182
|
+
0.9
|
|
183
|
+
);
|
|
184
|
+
}, []);
|
|
185
|
+
|
|
186
|
+
const switchCamera = () => {
|
|
187
|
+
if (availableCameras.length > 1) {
|
|
188
|
+
setCurrentCameraIndex(
|
|
189
|
+
(prev) => (prev + 1) % availableCameras.length
|
|
190
|
+
);
|
|
191
|
+
} else {
|
|
192
|
+
// Fallback to facing mode switch
|
|
193
|
+
setCameraFacing((prev) =>
|
|
194
|
+
prev === 'environment' ? 'user' : 'environment'
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const retakePhoto = () => {
|
|
200
|
+
setCapturedImage(null);
|
|
201
|
+
setCurrentStep('camera');
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const handleFileUpload = (event) => {
|
|
205
|
+
const file = event.target.files[0];
|
|
206
|
+
|
|
207
|
+
if (!file) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Validate file type
|
|
212
|
+
if (!file.type.startsWith('image/')) {
|
|
213
|
+
toast.error('Please select a valid image file (JPG, PNG, etc.)');
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Validate file size (max 10MB)
|
|
218
|
+
const maxSize = 10 * 1024 * 1024; // 10MB
|
|
219
|
+
if (file.size > maxSize) {
|
|
220
|
+
toast.error(
|
|
221
|
+
'Image file is too large. Please select a file smaller than 10MB.'
|
|
222
|
+
);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Check if it's a reasonable image size
|
|
227
|
+
const img = new Image();
|
|
228
|
+
img.onload = function () {
|
|
229
|
+
if (this.width < 100 || this.height < 100) {
|
|
230
|
+
toast.error(
|
|
231
|
+
'Image is too small. Please select a larger image.'
|
|
232
|
+
);
|
|
233
|
+
URL.revokeObjectURL(this.src); // Clean up memory
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
URL.revokeObjectURL(this.src); // Clean up memory
|
|
238
|
+
setCapturedImage(file);
|
|
239
|
+
setCurrentStep('preview');
|
|
240
|
+
stopCamera();
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
img.onerror = function () {
|
|
244
|
+
toast.error(
|
|
245
|
+
'Unable to load the selected image. Please try another file.'
|
|
246
|
+
);
|
|
247
|
+
URL.revokeObjectURL(this.src); // Clean up memory
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const objectUrl = URL.createObjectURL(file);
|
|
251
|
+
img.src = objectUrl;
|
|
252
|
+
|
|
253
|
+
// Clear the input to allow re-selecting the same file
|
|
254
|
+
event.target.value = '';
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const processBusinessCard = async () => {
|
|
258
|
+
if (!capturedImage) return;
|
|
259
|
+
|
|
260
|
+
setIsProcessing(true);
|
|
261
|
+
setCurrentStep('processing');
|
|
262
|
+
setOcrProgress(0);
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
const result = await Tesseract.recognize(capturedImage, 'eng', {
|
|
266
|
+
logger: (m) => {
|
|
267
|
+
if (m.status === 'recognizing text') {
|
|
268
|
+
const progress = Math.round(m.progress * 100);
|
|
269
|
+
setOcrProgress(progress);
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const text = result.data.text;
|
|
275
|
+
const parsedData = parseExtractedText(text);
|
|
276
|
+
setExtractedData({ ...parsedData, rawText: text });
|
|
277
|
+
setCurrentStep('results');
|
|
278
|
+
toast.success('Business card processed successfully!');
|
|
279
|
+
} catch (error) {
|
|
280
|
+
console.error('OCR Error:', error);
|
|
281
|
+
toast.error('Failed to process business card. Please try again.');
|
|
282
|
+
setCurrentStep('preview');
|
|
283
|
+
} finally {
|
|
284
|
+
setIsProcessing(false);
|
|
285
|
+
setOcrProgress(0);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// Helper functions for data quality validation
|
|
290
|
+
const isValidName = (name) => {
|
|
291
|
+
if (!name || typeof name !== 'string') return false;
|
|
292
|
+
|
|
293
|
+
const trimmed = name.trim();
|
|
294
|
+
|
|
295
|
+
// Must be at least 2 characters for first name, 1 for last name
|
|
296
|
+
if (trimmed.length < 1) return false;
|
|
297
|
+
|
|
298
|
+
// No single character first names except common ones
|
|
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
|
+
) {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Must contain mostly letters
|
|
319
|
+
const letterCount = (trimmed.match(/[a-zA-Z]/g) || []).length;
|
|
320
|
+
const letterRatio = letterCount / trimmed.length;
|
|
321
|
+
if (letterRatio < 0.6) return false;
|
|
322
|
+
|
|
323
|
+
// No operators or excessive special characters
|
|
324
|
+
const invalidChars = /[=+\-*/<>|\\#$%^&@!~`{}[\]]/;
|
|
325
|
+
if (invalidChars.test(trimmed)) return false;
|
|
326
|
+
|
|
327
|
+
// Allow common name characters
|
|
328
|
+
const validNamePattern = /^[a-zA-Z\s'.,-]+$/;
|
|
329
|
+
return validNamePattern.test(trimmed);
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const isLikelyOCRArtifact = (line) => {
|
|
333
|
+
if (!line || typeof line !== 'string') return true;
|
|
334
|
+
|
|
335
|
+
const trimmed = line.trim();
|
|
336
|
+
|
|
337
|
+
// Too short or too long
|
|
338
|
+
if (trimmed.length < 2 || trimmed.length > 50) return true;
|
|
339
|
+
|
|
340
|
+
// Calculate character type ratios
|
|
341
|
+
const letters = (trimmed.match(/[a-zA-Z]/g) || []).length;
|
|
342
|
+
const numbers = (trimmed.match(/[0-9]/g) || []).length;
|
|
343
|
+
const symbols = (trimmed.match(/[^a-zA-Z0-9\s.,;:!?'-]/g) || []).length;
|
|
344
|
+
const spaces = (trimmed.match(/\s/g) || []).length;
|
|
345
|
+
const total = trimmed.length;
|
|
346
|
+
const letterRatio = letters / total;
|
|
347
|
+
const symbolRatio = symbols / total;
|
|
348
|
+
const spaceRatio = spaces / total;
|
|
349
|
+
|
|
350
|
+
// Skip if mostly symbols
|
|
351
|
+
if (symbolRatio > 0.3) return true;
|
|
352
|
+
|
|
353
|
+
// Skip if too many consecutive spaces or weird spacing
|
|
354
|
+
if (spaceRatio > 0.4) return true;
|
|
355
|
+
|
|
356
|
+
// Skip if it's all numbers or mostly numbers (unless it looks like a phone)
|
|
357
|
+
if (
|
|
358
|
+
numbers > 0 &&
|
|
359
|
+
letterRatio < 0.2 &&
|
|
360
|
+
!trimmed.match(/^\+?[\d\s\-()]+$/)
|
|
361
|
+
)
|
|
362
|
+
return true;
|
|
363
|
+
|
|
364
|
+
// Skip lines that look like formatting artifacts
|
|
365
|
+
const formatArtifacts =
|
|
366
|
+
/^[.\-_|]+$|^[^a-zA-Z]*[|]+[^a-zA-Z]*$|^\s*[.]\s*[A-Z]?\s*$/;
|
|
367
|
+
if (formatArtifacts.test(trimmed)) return true;
|
|
368
|
+
|
|
369
|
+
// Skip if it has random character combinations
|
|
370
|
+
const randomPattern =
|
|
371
|
+
/^[a-zA-Z]{1,2}\s+[a-zA-Z]{1,2}(\s+[a-zA-Z]{1,2})*$/;
|
|
372
|
+
if (randomPattern.test(trimmed) && trimmed.length < 8) return true;
|
|
373
|
+
|
|
374
|
+
// Enhanced gibberish detection
|
|
375
|
+
if (isGibberish(trimmed)) return true;
|
|
376
|
+
|
|
377
|
+
return false;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// Advanced gibberish detection
|
|
381
|
+
const isGibberish = (text) => {
|
|
382
|
+
if (!text || text.length < 3) return false;
|
|
383
|
+
|
|
384
|
+
const words = text.split(/\s+/).filter((word) => word.length > 0);
|
|
385
|
+
|
|
386
|
+
// Check for excessive mixed case patterns like "Li FOR go > ips"
|
|
387
|
+
let mixedCaseWords = 0;
|
|
388
|
+
let gibberishWords = 0;
|
|
389
|
+
let totalWords = words.length;
|
|
390
|
+
|
|
391
|
+
for (const word of words) {
|
|
392
|
+
// Skip very short words
|
|
393
|
+
if (word.length < 2) continue;
|
|
394
|
+
|
|
395
|
+
// Check for excessive symbol contamination in words
|
|
396
|
+
const symbolsInWord = (word.match(/[^a-zA-Z0-9]/g) || []).length;
|
|
397
|
+
if (symbolsInWord / word.length > 0.3) {
|
|
398
|
+
gibberishWords++;
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Check for random mixed case patterns
|
|
403
|
+
const hasRandomCase = /[a-z][A-Z]|[A-Z][a-z][A-Z]/.test(word);
|
|
404
|
+
if (hasRandomCase && word.length > 2) {
|
|
405
|
+
mixedCaseWords++;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Check for consonant/vowel patterns that don't make sense
|
|
409
|
+
const consonantClusters = word.match(
|
|
410
|
+
/[bcdfghjklmnpqrstvwxzBCDFGHJKLMNPQRSTVWXZ]{4,}/g
|
|
411
|
+
);
|
|
412
|
+
if (consonantClusters && consonantClusters.length > 0) {
|
|
413
|
+
gibberishWords++;
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Check for excessive single character "words"
|
|
418
|
+
if (word.length === 1 && /[a-zA-Z]/.test(word)) {
|
|
419
|
+
gibberishWords++;
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Check for patterns like "FOR", "ips", "ED", "RE" in sequence (common OCR errors)
|
|
424
|
+
if (word.length <= 3 && word.match(/^[A-Z]{2,3}$|^[a-z]{2,3}$/)) {
|
|
425
|
+
// Count as potential gibberish if there are many of these short words
|
|
426
|
+
gibberishWords += 0.5;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// If more than 60% of words are gibberish or mixed case, consider it gibberish
|
|
431
|
+
const gibberishRatio = gibberishWords / Math.max(totalWords, 1);
|
|
432
|
+
const mixedCaseRatio = mixedCaseWords / Math.max(totalWords, 1);
|
|
433
|
+
|
|
434
|
+
if (gibberishRatio > 0.6) return true;
|
|
435
|
+
if (mixedCaseRatio > 0.5 && totalWords > 2) return true;
|
|
436
|
+
|
|
437
|
+
// Check for specific gibberish patterns
|
|
438
|
+
const gibberishPatterns = [
|
|
439
|
+
// Patterns like ": Re : Bei oc oC"
|
|
440
|
+
/^[:;]\s*[A-Za-z]{1,3}\s*[:;]\s*[A-Za-z]{1,4}\s+[a-z]{1,2}\s+[a-zA-Z]{1,2}$/,
|
|
441
|
+
// Patterns like "HE — SE TL Beant 2 Es Re Se"
|
|
442
|
+
/^[A-Z]{1,3}\s*[—\-]\s*[A-Z]{1,3}\s+[A-Z]{1,3}\s+[A-Za-z]{1,6}\s+\d+\s+[A-Za-z]{1,3}\s+[A-Za-z]{1,3}\s+[A-Za-z]{1,3}$/,
|
|
443
|
+
// Patterns with excessive brackets, symbols mixed with short words
|
|
444
|
+
/[>\[\]]{2,}|[A-Za-z]{1,3}\s+[>\[\]<]+\s+[A-Za-z]{1,3}/,
|
|
445
|
+
// Excessive punctuation mixed with short letter sequences
|
|
446
|
+
/^[^a-zA-Z]*[a-zA-Z]{1,2}[^a-zA-Z]+[a-zA-Z]{1,3}[^a-zA-Z]+.*$/,
|
|
447
|
+
];
|
|
448
|
+
|
|
449
|
+
for (const pattern of gibberishPatterns) {
|
|
450
|
+
if (pattern.test(text)) return true;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Check for excessive spacing patterns
|
|
454
|
+
if (/\s{3,}/.test(text)) return true;
|
|
455
|
+
|
|
456
|
+
// Check for lines with too many single characters
|
|
457
|
+
const singleChars = text.match(/\b[a-zA-Z]\b/g);
|
|
458
|
+
if (singleChars && singleChars.length > 3 && text.length < 30)
|
|
459
|
+
return true;
|
|
460
|
+
|
|
461
|
+
return false;
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
const hasValidNameStructure = (words) => {
|
|
465
|
+
if (!words || words.length === 0) return false;
|
|
466
|
+
|
|
467
|
+
// Check each word for name validity
|
|
468
|
+
for (const word of words) {
|
|
469
|
+
if (!isValidName(word)) return false;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// First word should be at least 2 characters (unless common single char)
|
|
473
|
+
const firstName = words[0];
|
|
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
|
+
) {
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return true;
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
// Helper function to clean and validate field values
|
|
497
|
+
const cleanFieldValue = (value, fieldType = 'general') => {
|
|
498
|
+
if (!value || typeof value !== 'string') return '';
|
|
499
|
+
|
|
500
|
+
let cleaned = value.trim();
|
|
501
|
+
|
|
502
|
+
// Remove common OCR artifacts and invalid characters
|
|
503
|
+
switch (fieldType) {
|
|
504
|
+
case 'name':
|
|
505
|
+
// For names: only letters, spaces, apostrophes, periods, hyphens
|
|
506
|
+
cleaned = cleaned.replace(/[^a-zA-Z\s'.-]/g, '');
|
|
507
|
+
// Remove excessive spaces
|
|
508
|
+
cleaned = cleaned.replace(/\s+/g, ' ');
|
|
509
|
+
// Remove leading/trailing punctuation
|
|
510
|
+
cleaned = cleaned.replace(/^[^a-zA-Z]+|[^a-zA-Z]+$/g, '');
|
|
511
|
+
break;
|
|
512
|
+
|
|
513
|
+
case 'company':
|
|
514
|
+
// For companies: letters, numbers, spaces, common punctuation
|
|
515
|
+
cleaned = cleaned.replace(/[^a-zA-Z0-9\s&.,'-]/g, '');
|
|
516
|
+
// Remove pipe characters and equals signs that OCR often mistakes
|
|
517
|
+
cleaned = cleaned.replace(/[|="]/g, '');
|
|
518
|
+
// Remove excessive spaces
|
|
519
|
+
cleaned = cleaned.replace(/\s+/g, ' ');
|
|
520
|
+
// Remove leading/trailing punctuation except periods
|
|
521
|
+
cleaned = cleaned.replace(
|
|
522
|
+
/^[^a-zA-Z0-9]+|[^a-zA-Z0-9.]+$/g,
|
|
523
|
+
''
|
|
524
|
+
);
|
|
525
|
+
break;
|
|
526
|
+
|
|
527
|
+
case 'position':
|
|
528
|
+
// For positions: letters, spaces, common punctuation
|
|
529
|
+
cleaned = cleaned.replace(/[^a-zA-Z\s&.,'-]/g, '');
|
|
530
|
+
// Remove pipe characters and equals signs
|
|
531
|
+
cleaned = cleaned.replace(/[|="]/g, '');
|
|
532
|
+
// Remove excessive spaces
|
|
533
|
+
cleaned = cleaned.replace(/\s+/g, ' ');
|
|
534
|
+
// Remove leading/trailing punctuation
|
|
535
|
+
cleaned = cleaned.replace(/^[^a-zA-Z]+|[^a-zA-Z]+$/g, '');
|
|
536
|
+
break;
|
|
537
|
+
|
|
538
|
+
case 'email':
|
|
539
|
+
// For emails: standard email characters only
|
|
540
|
+
cleaned = cleaned.replace(/[^a-zA-Z0-9@._+-]/g, '');
|
|
541
|
+
break;
|
|
542
|
+
|
|
543
|
+
case 'phone':
|
|
544
|
+
// For phones: numbers, spaces, parentheses, hyphens, plus
|
|
545
|
+
cleaned = cleaned.replace(/[^0-9\s()+-]/g, '');
|
|
546
|
+
break;
|
|
547
|
+
|
|
548
|
+
case 'website':
|
|
549
|
+
// For websites: standard URL characters
|
|
550
|
+
cleaned = cleaned.replace(
|
|
551
|
+
/[^a-zA-Z0-9./:?#@!$&'()*+,;=-]/g,
|
|
552
|
+
''
|
|
553
|
+
);
|
|
554
|
+
break;
|
|
555
|
+
|
|
556
|
+
default:
|
|
557
|
+
// General cleaning: remove obvious artifacts
|
|
558
|
+
cleaned = cleaned.replace(/[|="]/g, '');
|
|
559
|
+
cleaned = cleaned.replace(/\s+/g, ' ');
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Final cleanup
|
|
563
|
+
cleaned = cleaned.trim();
|
|
564
|
+
|
|
565
|
+
// Return empty string if result is too short or invalid
|
|
566
|
+
if (cleaned.length < 1) return '';
|
|
567
|
+
|
|
568
|
+
return cleaned;
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
const parseExtractedText = (text) => {
|
|
572
|
+
const lines = text.split('\n').filter((line) => line.trim());
|
|
573
|
+
const parsed = {};
|
|
574
|
+
const unusedLines = [];
|
|
575
|
+
const usedLineIndices = new Set();
|
|
576
|
+
|
|
577
|
+
// Enhanced regex patterns
|
|
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
|
+
|
|
585
|
+
// Job title keywords for better position detection
|
|
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
|
+
|
|
613
|
+
// Company indicators
|
|
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
|
+
];
|
|
633
|
+
|
|
634
|
+
// Smart field extraction with confidence scoring
|
|
635
|
+
lines.forEach((line, index) => {
|
|
636
|
+
const trimmedLine = line.trim();
|
|
637
|
+
const lowerLine = trimmedLine.toLowerCase();
|
|
638
|
+
|
|
639
|
+
// Skip obvious OCR artifacts early
|
|
640
|
+
if (isLikelyOCRArtifact(trimmedLine)) {
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Extract email (high confidence)
|
|
645
|
+
const emailMatch = trimmedLine.match(emailRegex);
|
|
646
|
+
if (emailMatch && !parsed.email) {
|
|
647
|
+
const cleanedEmail = cleanFieldValue(emailMatch[0], 'email');
|
|
648
|
+
if (cleanedEmail) {
|
|
649
|
+
parsed.email = cleanedEmail;
|
|
650
|
+
usedLineIndices.add(index);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Extract phone/mobile (high confidence)
|
|
656
|
+
const phoneMatch = trimmedLine.match(phoneRegex);
|
|
657
|
+
if (phoneMatch && !parsed.mobile && !parsed.phone) {
|
|
658
|
+
const cleanedPhone = cleanFieldValue(phoneMatch[0], 'phone');
|
|
659
|
+
if (cleanedPhone) {
|
|
660
|
+
parsed.mobile = cleanedPhone;
|
|
661
|
+
parsed.phone = cleanedPhone; // For backward compatibility
|
|
662
|
+
usedLineIndices.add(index);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Extract website (high confidence)
|
|
668
|
+
const websiteMatch = trimmedLine.match(websiteRegex);
|
|
669
|
+
if (websiteMatch && !parsed.website && !emailMatch) {
|
|
670
|
+
const cleanedWebsite = cleanFieldValue(
|
|
671
|
+
websiteMatch[0],
|
|
672
|
+
'website'
|
|
673
|
+
);
|
|
674
|
+
if (cleanedWebsite) {
|
|
675
|
+
parsed.website = cleanedWebsite;
|
|
676
|
+
usedLineIndices.add(index);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Smart name detection with enhanced validation
|
|
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
|
+
|
|
698
|
+
// Enhanced name validation
|
|
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
|
+
) {
|
|
710
|
+
const firstName = words[0];
|
|
711
|
+
const lastName = words.slice(1).join(' ');
|
|
712
|
+
|
|
713
|
+
// Final validation for names
|
|
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
|
+
|
|
727
|
+
if (cleanedFirstName) {
|
|
728
|
+
parsed.firstname = cleanedFirstName;
|
|
729
|
+
parsed.lastname = cleanedLastName;
|
|
730
|
+
// For backward compatibility
|
|
731
|
+
parsed.first_name = parsed.firstname;
|
|
732
|
+
parsed.last_name = parsed.lastname;
|
|
733
|
+
usedLineIndices.add(index);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Smart position/job title detection with validation
|
|
741
|
+
if (!parsed.position) {
|
|
742
|
+
const hasJobTitle = jobTitleKeywords.some((keyword) =>
|
|
743
|
+
lowerLine.includes(keyword)
|
|
744
|
+
);
|
|
745
|
+
const words = trimmedLine.split(/\s+/);
|
|
746
|
+
const letterRatio =
|
|
747
|
+
(trimmedLine.match(/[a-zA-Z]/g) || []).length /
|
|
748
|
+
trimmedLine.length;
|
|
749
|
+
|
|
750
|
+
// Must have good letter ratio and reasonable length
|
|
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
|
+
);
|
|
766
|
+
if (cleanedPosition) {
|
|
767
|
+
parsed.position = cleanedPosition;
|
|
768
|
+
usedLineIndices.add(index);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Smart company detection with validation
|
|
775
|
+
if (!parsed.company) {
|
|
776
|
+
const hasCompanyIndicator = companyIndicators.some(
|
|
777
|
+
(indicator) => lowerLine.includes(indicator)
|
|
778
|
+
);
|
|
779
|
+
const isAllCaps =
|
|
780
|
+
trimmedLine === trimmedLine.toUpperCase() &&
|
|
781
|
+
trimmedLine.length > 2;
|
|
782
|
+
const words = trimmedLine.split(/\s+/);
|
|
783
|
+
const letterRatio =
|
|
784
|
+
(trimmedLine.match(/[a-zA-Z]/g) || []).length /
|
|
785
|
+
trimmedLine.length;
|
|
786
|
+
|
|
787
|
+
// Must have good letter ratio and reasonable structure
|
|
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
|
+
);
|
|
805
|
+
if (cleanedCompany) {
|
|
806
|
+
parsed.company = cleanedCompany;
|
|
807
|
+
usedLineIndices.add(index);
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
// Collect unused lines for manual assignment (excluding artifacts)
|
|
815
|
+
lines.forEach((line, index) => {
|
|
816
|
+
if (
|
|
817
|
+
!usedLineIndices.has(index) &&
|
|
818
|
+
!isLikelyOCRArtifact(line.trim())
|
|
819
|
+
) {
|
|
820
|
+
const cleanedLine = cleanFieldValue(line.trim(), 'general');
|
|
821
|
+
if (cleanedLine && cleanedLine.length > 1) {
|
|
822
|
+
unusedLines.push({
|
|
823
|
+
text: cleanedLine,
|
|
824
|
+
index: index,
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
parsed._unusedData = unusedLines;
|
|
831
|
+
return parsed;
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
// Helper function to get field input type
|
|
835
|
+
const getFieldInputType = (fieldId) => {
|
|
836
|
+
const fieldTypeMap = {
|
|
837
|
+
email: 'email',
|
|
838
|
+
mobile: 'tel',
|
|
839
|
+
phone: 'tel',
|
|
840
|
+
website: 'url',
|
|
841
|
+
position: 'text',
|
|
842
|
+
company: 'text',
|
|
843
|
+
firstname: 'text',
|
|
844
|
+
lastname: 'text',
|
|
845
|
+
};
|
|
846
|
+
return fieldTypeMap[fieldId] || 'text';
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
// Helper function to get field icon
|
|
850
|
+
const getFieldIcon = (fieldId) => {
|
|
851
|
+
const iconMap = {
|
|
852
|
+
email: <Envelope size={16} />,
|
|
853
|
+
mobile: null,
|
|
854
|
+
phone: null,
|
|
855
|
+
website: <Network size={16} />,
|
|
856
|
+
position: null,
|
|
857
|
+
company: <Network size={16} />,
|
|
858
|
+
firstname: <Edit size={16} />,
|
|
859
|
+
lastname: <Edit size={16} />,
|
|
860
|
+
};
|
|
861
|
+
return iconMap[fieldId];
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
// Helper function to get field placeholder
|
|
865
|
+
const getFieldPlaceholder = (fieldId, label) => {
|
|
866
|
+
const placeholderMap = {
|
|
867
|
+
email: 'email@company.com',
|
|
868
|
+
mobile: '(555) 123-4567',
|
|
869
|
+
phone: '(555) 123-4567',
|
|
870
|
+
website: 'https://company.com',
|
|
871
|
+
position: 'Job Title',
|
|
872
|
+
company: 'Company Name',
|
|
873
|
+
firstname: 'First Name',
|
|
874
|
+
lastname: 'Last Name',
|
|
875
|
+
};
|
|
876
|
+
return placeholderMap[fieldId] || label;
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
// Helper function to check if form has minimum required data
|
|
880
|
+
const hasMinimumRequiredData = () => {
|
|
881
|
+
if (fields.length > 0) {
|
|
882
|
+
// Check if at least one field has data
|
|
883
|
+
return fields.some((field) => extractedData[field.id]?.trim());
|
|
884
|
+
} else {
|
|
885
|
+
// Fallback for hardcoded fields
|
|
886
|
+
return (
|
|
887
|
+
extractedData.first_name?.trim() || extractedData.email?.trim()
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
};
|
|
891
|
+
|
|
892
|
+
const handleInputChange = (field, value) => {
|
|
893
|
+
setExtractedData((prev) => ({ ...prev, [field]: value }));
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
const handleUnusedDataClick = (text) => {
|
|
897
|
+
setSelectedUnusedText(text);
|
|
898
|
+
setShowAssignmentDropdown(true);
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
const assignToField = (fieldId, text) => {
|
|
902
|
+
// Update the field with the assigned text
|
|
903
|
+
setExtractedData((prev) => ({
|
|
904
|
+
...prev,
|
|
905
|
+
[fieldId]: text,
|
|
906
|
+
_unusedData:
|
|
907
|
+
prev._unusedData?.filter((item) => item.text !== text) || [],
|
|
908
|
+
}));
|
|
909
|
+
setShowAssignmentDropdown(false);
|
|
910
|
+
setSelectedUnusedText('');
|
|
911
|
+
};
|
|
912
|
+
|
|
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 () => {
|
|
959
|
+
try {
|
|
960
|
+
toast.success('Contact information extracted successfully!');
|
|
961
|
+
|
|
962
|
+
if (onContactSaved) {
|
|
963
|
+
onContactSaved(extractedData);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
handleClose();
|
|
967
|
+
} catch (error) {
|
|
968
|
+
console.error('Save error:', error);
|
|
969
|
+
toast.error('Failed to save contact. Please try again.');
|
|
970
|
+
}
|
|
971
|
+
};
|
|
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
|
+
|
|
1041
|
+
const handleClose = () => {
|
|
1042
|
+
stopCamera();
|
|
1043
|
+
setCapturedImage(null);
|
|
1044
|
+
setExtractedData({});
|
|
1045
|
+
setCurrentStep('camera');
|
|
1046
|
+
setOcrProgress(0);
|
|
1047
|
+
setShowAssignmentDropdown(false);
|
|
1048
|
+
setSelectedUnusedText('');
|
|
1049
|
+
setCurrentCameraIndex(0);
|
|
1050
|
+
setBackendResponse(null);
|
|
1051
|
+
setSelectedClient(null);
|
|
1052
|
+
setContactDecision(null);
|
|
1053
|
+
setIsConsulting(false);
|
|
1054
|
+
setIsSaving(false);
|
|
1055
|
+
|
|
1056
|
+
if (onClose) onClose();
|
|
1057
|
+
if (onCancel) onCancel();
|
|
1058
|
+
};
|
|
1059
|
+
|
|
1060
|
+
if (!isOpen) return null;
|
|
1061
|
+
|
|
1062
|
+
return (
|
|
1063
|
+
<motion.div
|
|
1064
|
+
className={ocrStyles.modalOverlay}
|
|
1065
|
+
initial={{ opacity: 0 }}
|
|
1066
|
+
animate={{ opacity: 1 }}
|
|
1067
|
+
exit={{ opacity: 0 }}
|
|
1068
|
+
>
|
|
1069
|
+
<motion.div
|
|
1070
|
+
className={ocrStyles.modalContent}
|
|
1071
|
+
initial={{ scale: 0.9, opacity: 0 }}
|
|
1072
|
+
animate={{ scale: 1, opacity: 1 }}
|
|
1073
|
+
exit={{ scale: 0.9, opacity: 0 }}
|
|
1074
|
+
>
|
|
1075
|
+
<div className={ocrStyles.modalHeader}>
|
|
1076
|
+
<h2>
|
|
1077
|
+
{currentStep === 'camera' && 'Capture Business Card'}
|
|
1078
|
+
{currentStep === 'preview' && 'Review Capture'}
|
|
1079
|
+
{currentStep === 'processing' && 'Processing...'}
|
|
1080
|
+
{currentStep === 'results' && 'Contact Information'}
|
|
1081
|
+
{currentStep === 'consultation' &&
|
|
1082
|
+
'Checking for Duplicates...'}
|
|
1083
|
+
{currentStep === 'decisions' && 'Resolve Conflicts'}
|
|
1084
|
+
</h2>
|
|
1085
|
+
<button
|
|
1086
|
+
onClick={handleClose}
|
|
1087
|
+
className={ocrStyles.closeButton}
|
|
1088
|
+
>
|
|
1089
|
+
<CircleX size={24} />
|
|
1090
|
+
</button>
|
|
1091
|
+
</div>
|
|
1092
|
+
|
|
1093
|
+
<div className={ocrStyles.modalBody}>
|
|
1094
|
+
{currentStep === 'camera' && (
|
|
1095
|
+
<div className={ocrStyles.cameraContainer}>
|
|
1096
|
+
<div className={ocrStyles.captureGuide}>
|
|
1097
|
+
<p>Position business card within the frame</p>
|
|
1098
|
+
</div>
|
|
1099
|
+
<div className={ocrStyles.videoWrapper}>
|
|
1100
|
+
<video
|
|
1101
|
+
ref={videoRef}
|
|
1102
|
+
autoPlay
|
|
1103
|
+
playsInline
|
|
1104
|
+
muted
|
|
1105
|
+
className={ocrStyles.cameraVideo}
|
|
1106
|
+
/>
|
|
1107
|
+
<div className={ocrStyles.cameraOverlay}></div>
|
|
1108
|
+
</div>
|
|
1109
|
+
|
|
1110
|
+
<div className={ocrStyles.cameraControls}>
|
|
1111
|
+
{availableCameras.length > 1 && (
|
|
1112
|
+
<button
|
|
1113
|
+
onClick={switchCamera}
|
|
1114
|
+
className={ocrStyles.secondaryButton}
|
|
1115
|
+
>
|
|
1116
|
+
<ArrowCycle size={20} />
|
|
1117
|
+
Switch Camera
|
|
1118
|
+
</button>
|
|
1119
|
+
)}
|
|
1120
|
+
|
|
1121
|
+
<button
|
|
1122
|
+
onClick={capturePhoto}
|
|
1123
|
+
className={ocrStyles.captureButton}
|
|
1124
|
+
>
|
|
1125
|
+
<Camera size={24} />
|
|
1126
|
+
Capture
|
|
1127
|
+
</button>
|
|
1128
|
+
|
|
1129
|
+
<input
|
|
1130
|
+
type="file"
|
|
1131
|
+
ref={fileInputRef}
|
|
1132
|
+
onChange={handleFileUpload}
|
|
1133
|
+
accept="image/jpeg,image/png,image/jpg,image/gif,image/webp"
|
|
1134
|
+
style={{ display: 'none' }}
|
|
1135
|
+
/>
|
|
1136
|
+
<button
|
|
1137
|
+
onClick={() =>
|
|
1138
|
+
fileInputRef.current?.click()
|
|
1139
|
+
}
|
|
1140
|
+
className={ocrStyles.secondaryButton}
|
|
1141
|
+
>
|
|
1142
|
+
<ImageIcon size={24} />
|
|
1143
|
+
Upload Image
|
|
1144
|
+
</button>
|
|
1145
|
+
</div>
|
|
1146
|
+
</div>
|
|
1147
|
+
)}
|
|
1148
|
+
|
|
1149
|
+
{currentStep === 'preview' && capturedImage && (
|
|
1150
|
+
<div className={ocrStyles.previewContainer}>
|
|
1151
|
+
<div className={ocrStyles.imagePreview}>
|
|
1152
|
+
<img
|
|
1153
|
+
src={URL.createObjectURL(capturedImage)}
|
|
1154
|
+
alt="Captured business card"
|
|
1155
|
+
className={ocrStyles.previewImage}
|
|
1156
|
+
/>
|
|
1157
|
+
</div>
|
|
1158
|
+
|
|
1159
|
+
<div className={ocrStyles.previewControls}>
|
|
1160
|
+
<button
|
|
1161
|
+
onClick={retakePhoto}
|
|
1162
|
+
className={ocrStyles.secondaryButton}
|
|
1163
|
+
>
|
|
1164
|
+
<ArrowCounterClockwise size={20} />
|
|
1165
|
+
Retake
|
|
1166
|
+
</button>
|
|
1167
|
+
|
|
1168
|
+
<button
|
|
1169
|
+
onClick={processBusinessCard}
|
|
1170
|
+
disabled={isProcessing}
|
|
1171
|
+
className={ocrStyles.primaryButton}
|
|
1172
|
+
>
|
|
1173
|
+
<CircleCheck size={20} />
|
|
1174
|
+
Process Card
|
|
1175
|
+
</button>
|
|
1176
|
+
</div>
|
|
1177
|
+
</div>
|
|
1178
|
+
)}
|
|
1179
|
+
|
|
1180
|
+
{currentStep === 'processing' && (
|
|
1181
|
+
<div className={ocrStyles.processingContainer}>
|
|
1182
|
+
<div className={ocrStyles.loadingSpinner}></div>
|
|
1183
|
+
<p>Extracting contact information...</p>
|
|
1184
|
+
<div className={ocrStyles.progressBar}>
|
|
1185
|
+
<div
|
|
1186
|
+
className={ocrStyles.progressFill}
|
|
1187
|
+
style={{ width: `${ocrProgress}%` }}
|
|
1188
|
+
></div>
|
|
1189
|
+
</div>
|
|
1190
|
+
<small>{ocrProgress}% complete</small>
|
|
1191
|
+
</div>
|
|
1192
|
+
)}
|
|
1193
|
+
|
|
1194
|
+
{currentStep === 'results' && (
|
|
1195
|
+
<div className={ocrStyles.contactForm}>
|
|
1196
|
+
<div className={ocrStyles.formGrid}>
|
|
1197
|
+
{fields.length > 0 ? (
|
|
1198
|
+
fields.map((field) => (
|
|
1199
|
+
<div
|
|
1200
|
+
key={field.id}
|
|
1201
|
+
className={ocrStyles.fieldGroup}
|
|
1202
|
+
>
|
|
1203
|
+
<label>
|
|
1204
|
+
{getFieldIcon(field.id)}
|
|
1205
|
+
{field.label}
|
|
1206
|
+
</label>
|
|
1207
|
+
<input
|
|
1208
|
+
type={getFieldInputType(
|
|
1209
|
+
field.id
|
|
1210
|
+
)}
|
|
1211
|
+
value={
|
|
1212
|
+
extractedData[field.id] ||
|
|
1213
|
+
''
|
|
1214
|
+
}
|
|
1215
|
+
onChange={(e) =>
|
|
1216
|
+
handleInputChange(
|
|
1217
|
+
field.id,
|
|
1218
|
+
e.target.value
|
|
1219
|
+
)
|
|
1220
|
+
}
|
|
1221
|
+
placeholder={getFieldPlaceholder(
|
|
1222
|
+
field.id,
|
|
1223
|
+
field.label
|
|
1224
|
+
)}
|
|
1225
|
+
className={ocrStyles.inputField}
|
|
1226
|
+
/>
|
|
1227
|
+
</div>
|
|
1228
|
+
))
|
|
1229
|
+
) : (
|
|
1230
|
+
// Fallback to original hardcoded fields if no fields config provided
|
|
1231
|
+
<>
|
|
1232
|
+
<div className={ocrStyles.fieldGroup}>
|
|
1233
|
+
<label>
|
|
1234
|
+
<Edit size={16} />
|
|
1235
|
+
First Name
|
|
1236
|
+
</label>
|
|
1237
|
+
<input
|
|
1238
|
+
type="text"
|
|
1239
|
+
value={
|
|
1240
|
+
extractedData.first_name ||
|
|
1241
|
+
''
|
|
1242
|
+
}
|
|
1243
|
+
onChange={(e) =>
|
|
1244
|
+
handleInputChange(
|
|
1245
|
+
'first_name',
|
|
1246
|
+
e.target.value
|
|
1247
|
+
)
|
|
1248
|
+
}
|
|
1249
|
+
placeholder="First Name"
|
|
1250
|
+
className={ocrStyles.inputField}
|
|
1251
|
+
/>
|
|
1252
|
+
</div>
|
|
1253
|
+
<div className={ocrStyles.fieldGroup}>
|
|
1254
|
+
<label>
|
|
1255
|
+
<Edit size={16} />
|
|
1256
|
+
Last Name
|
|
1257
|
+
</label>
|
|
1258
|
+
<input
|
|
1259
|
+
type="text"
|
|
1260
|
+
value={
|
|
1261
|
+
extractedData.last_name ||
|
|
1262
|
+
''
|
|
1263
|
+
}
|
|
1264
|
+
onChange={(e) =>
|
|
1265
|
+
handleInputChange(
|
|
1266
|
+
'last_name',
|
|
1267
|
+
e.target.value
|
|
1268
|
+
)
|
|
1269
|
+
}
|
|
1270
|
+
placeholder="Last Name"
|
|
1271
|
+
className={ocrStyles.inputField}
|
|
1272
|
+
/>
|
|
1273
|
+
</div>
|
|
1274
|
+
<div className={ocrStyles.fieldGroup}>
|
|
1275
|
+
<label>
|
|
1276
|
+
<Envelope size={16} />
|
|
1277
|
+
Email
|
|
1278
|
+
</label>
|
|
1279
|
+
<input
|
|
1280
|
+
type="email"
|
|
1281
|
+
value={
|
|
1282
|
+
extractedData.email || ''
|
|
1283
|
+
}
|
|
1284
|
+
onChange={(e) =>
|
|
1285
|
+
handleInputChange(
|
|
1286
|
+
'email',
|
|
1287
|
+
e.target.value
|
|
1288
|
+
)
|
|
1289
|
+
}
|
|
1290
|
+
placeholder="email@company.com"
|
|
1291
|
+
className={ocrStyles.inputField}
|
|
1292
|
+
/>
|
|
1293
|
+
</div>
|
|
1294
|
+
<div className={ocrStyles.fieldGroup}>
|
|
1295
|
+
<label>Phone</label>
|
|
1296
|
+
<input
|
|
1297
|
+
type="tel"
|
|
1298
|
+
value={
|
|
1299
|
+
extractedData.phone || ''
|
|
1300
|
+
}
|
|
1301
|
+
onChange={(e) =>
|
|
1302
|
+
handleInputChange(
|
|
1303
|
+
'phone',
|
|
1304
|
+
e.target.value
|
|
1305
|
+
)
|
|
1306
|
+
}
|
|
1307
|
+
placeholder="(555) 123-4567"
|
|
1308
|
+
className={ocrStyles.inputField}
|
|
1309
|
+
/>
|
|
1310
|
+
</div>
|
|
1311
|
+
<div className={ocrStyles.fieldGroup}>
|
|
1312
|
+
<label>
|
|
1313
|
+
<Network size={16} />
|
|
1314
|
+
Company
|
|
1315
|
+
</label>
|
|
1316
|
+
<input
|
|
1317
|
+
type="text"
|
|
1318
|
+
value={
|
|
1319
|
+
extractedData.company || ''
|
|
1320
|
+
}
|
|
1321
|
+
onChange={(e) =>
|
|
1322
|
+
handleInputChange(
|
|
1323
|
+
'company',
|
|
1324
|
+
e.target.value
|
|
1325
|
+
)
|
|
1326
|
+
}
|
|
1327
|
+
placeholder="Company Name"
|
|
1328
|
+
className={ocrStyles.inputField}
|
|
1329
|
+
/>
|
|
1330
|
+
</div>
|
|
1331
|
+
</>
|
|
1332
|
+
)}
|
|
1333
|
+
</div>
|
|
1334
|
+
|
|
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
|
+
}
|
|
1372
|
+
>
|
|
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>
|
|
1411
|
+
</div>
|
|
1412
|
+
)}
|
|
1413
|
+
</div>
|
|
1414
|
+
)}
|
|
1415
|
+
|
|
1416
|
+
{extractedData.rawText && (
|
|
1417
|
+
<details className={ocrStyles.rawTextSection}>
|
|
1418
|
+
<summary
|
|
1419
|
+
className={ocrStyles.rawTextSummary}
|
|
1420
|
+
>
|
|
1421
|
+
<span className={ocrStyles.summaryText}>
|
|
1422
|
+
📄 Raw Extracted Text
|
|
1423
|
+
</span>
|
|
1424
|
+
<span className={ocrStyles.summaryHint}>
|
|
1425
|
+
(Click to expand)
|
|
1426
|
+
</span>
|
|
1427
|
+
<span className={ocrStyles.expandIcon}>
|
|
1428
|
+
▶
|
|
1429
|
+
</span>
|
|
1430
|
+
</summary>
|
|
1431
|
+
<div className={ocrStyles.rawTextContent}>
|
|
1432
|
+
{extractedData.rawText}
|
|
1433
|
+
</div>
|
|
1434
|
+
</details>
|
|
1435
|
+
)}
|
|
1436
|
+
|
|
1437
|
+
<div className={ocrStyles.formActions}>
|
|
1438
|
+
<button
|
|
1439
|
+
onClick={retakePhoto}
|
|
1440
|
+
className={ocrStyles.secondaryButton}
|
|
1441
|
+
>
|
|
1442
|
+
<ImageIcon size={20} />
|
|
1443
|
+
Scan Another
|
|
1444
|
+
</button>
|
|
1445
|
+
|
|
1446
|
+
<button
|
|
1447
|
+
onClick={handleSave}
|
|
1448
|
+
className={ocrStyles.primaryButton}
|
|
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
|
+
}
|
|
2362
|
+
>
|
|
2363
|
+
{isSaving ? 'Saving...' : 'Confirm & Save'}
|
|
2364
|
+
</button>
|
|
2365
|
+
</div>
|
|
2366
|
+
</div>
|
|
2367
|
+
)}
|
|
2368
|
+
</div>
|
|
2369
|
+
|
|
2370
|
+
<canvas ref={canvasRef} style={{ display: 'none' }} />
|
|
2371
|
+
</motion.div>
|
|
2372
|
+
</motion.div>
|
|
2373
|
+
);
|
|
2374
|
+
};
|
|
2375
|
+
|
|
2376
|
+
export default BusinessCardOcr;
|