|
|
import React, { useRef, useEffect, useState } from 'react'; |
|
|
import { SlidersHorizontal, ChevronDown, ChevronUp, Brain } from 'lucide-react'; |
|
|
|
|
|
const ResultCard = ({ result, imageData, processedImageData, onReprocess, isProcessing }) => { |
|
|
const canvasRef = useRef(null); |
|
|
const previewCanvasRef = useRef(null); |
|
|
const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); |
|
|
const [signatureCrop, setSignatureCrop] = useState(null); |
|
|
const [stampCrop, setStampCrop] = useState(null); |
|
|
const [resolution, setResolution] = useState(result.processedResolution || 100); |
|
|
const [adjustedDataUrl, setAdjustedDataUrl] = useState(null); |
|
|
const [previewDimensions, setPreviewDimensions] = useState({ width: 0, height: 0 }); |
|
|
const [currentImageData, setCurrentImageData] = useState(processedImageData || imageData); |
|
|
const [showReasoning, setShowReasoning] = useState(false); |
|
|
|
|
|
|
|
|
const cropRegion = (img, coords, scaleX, scaleY) => { |
|
|
if (!coords || coords.length === 0) return null; |
|
|
|
|
|
const [x1, y1, x2, y2] = coords[0]; |
|
|
const width = (x2 - x1) * scaleX; |
|
|
const height = (y2 - y1) * scaleY; |
|
|
|
|
|
const cropCanvas = document.createElement('canvas'); |
|
|
cropCanvas.width = width; |
|
|
cropCanvas.height = height; |
|
|
const cropCtx = cropCanvas.getContext('2d'); |
|
|
|
|
|
|
|
|
cropCtx.drawImage( |
|
|
img, |
|
|
x1, y1, x2 - x1, y2 - y1, |
|
|
0, 0, width, height |
|
|
); |
|
|
|
|
|
return cropCanvas.toDataURL(); |
|
|
}; |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (processedImageData) { |
|
|
setCurrentImageData(processedImageData); |
|
|
} |
|
|
}, [processedImageData]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (!currentImageData || !canvasRef.current) return; |
|
|
|
|
|
const canvas = canvasRef.current; |
|
|
const ctx = canvas.getContext('2d'); |
|
|
const img = new Image(); |
|
|
|
|
|
img.onload = () => { |
|
|
|
|
|
const maxWidth = 800; |
|
|
const maxHeight = 600; |
|
|
let width = img.width; |
|
|
let height = img.height; |
|
|
|
|
|
if (width > maxWidth) { |
|
|
height = (height * maxWidth) / width; |
|
|
width = maxWidth; |
|
|
} |
|
|
if (height > maxHeight) { |
|
|
width = (width * maxHeight) / height; |
|
|
height = maxHeight; |
|
|
} |
|
|
|
|
|
canvas.width = width; |
|
|
canvas.height = height; |
|
|
setDimensions({ width, height }); |
|
|
|
|
|
|
|
|
ctx.drawImage(img, 0, 0, width, height); |
|
|
|
|
|
|
|
|
const scaleX = width / img.width; |
|
|
const scaleY = height / img.height; |
|
|
|
|
|
|
|
|
|
|
|
if (result.signature_coords && result.signature_coords.length > 0) { |
|
|
const sigCrop = cropRegion(img, result.signature_coords, 1, 1); |
|
|
setSignatureCrop(sigCrop); |
|
|
} |
|
|
if (result.stamp_coords && result.stamp_coords.length > 0) { |
|
|
const stCrop = cropRegion(img, result.stamp_coords, 1, 1); |
|
|
setStampCrop(stCrop); |
|
|
} |
|
|
|
|
|
|
|
|
if (result.signature_coords && result.signature_coords.length > 0) { |
|
|
ctx.strokeStyle = '#ef4444'; |
|
|
ctx.lineWidth = 3; |
|
|
ctx.setLineDash([5, 5]); |
|
|
|
|
|
result.signature_coords.forEach(coords => { |
|
|
const [x1, y1, x2, y2] = coords; |
|
|
ctx.strokeRect( |
|
|
x1 * scaleX, |
|
|
y1 * scaleY, |
|
|
(x2 - x1) * scaleX, |
|
|
(y2 - y1) * scaleY |
|
|
); |
|
|
}); |
|
|
|
|
|
|
|
|
ctx.fillStyle = '#ef4444'; |
|
|
ctx.font = 'bold 14px Arial'; |
|
|
ctx.fillText('Signature', result.signature_coords[0][0] * scaleX, result.signature_coords[0][1] * scaleY - 5); |
|
|
} |
|
|
|
|
|
|
|
|
if (result.stamp_coords && result.stamp_coords.length > 0) { |
|
|
ctx.strokeStyle = '#3b82f6'; |
|
|
ctx.lineWidth = 3; |
|
|
ctx.setLineDash([5, 5]); |
|
|
|
|
|
result.stamp_coords.forEach(coords => { |
|
|
const [x1, y1, x2, y2] = coords; |
|
|
ctx.strokeRect( |
|
|
x1 * scaleX, |
|
|
y1 * scaleY, |
|
|
(x2 - x1) * scaleX, |
|
|
(y2 - y1) * scaleY |
|
|
); |
|
|
}); |
|
|
|
|
|
|
|
|
ctx.fillStyle = '#3b82f6'; |
|
|
ctx.font = 'bold 14px Arial'; |
|
|
ctx.fillText('Stamp', result.stamp_coords[0][0] * scaleX, result.stamp_coords[0][1] * scaleY - 5); |
|
|
} |
|
|
}; |
|
|
|
|
|
img.src = currentImageData; |
|
|
}, [currentImageData, imageData, result]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (!imageData || !previewCanvasRef.current) return; |
|
|
|
|
|
const canvas = previewCanvasRef.current; |
|
|
const ctx = canvas.getContext('2d'); |
|
|
const img = new Image(); |
|
|
|
|
|
img.onload = () => { |
|
|
const scale = resolution / 100; |
|
|
const newWidth = Math.floor(img.width * scale); |
|
|
const newHeight = Math.floor(img.height * scale); |
|
|
|
|
|
canvas.width = newWidth; |
|
|
canvas.height = newHeight; |
|
|
setPreviewDimensions({ width: newWidth, height: newHeight }); |
|
|
|
|
|
ctx.drawImage(img, 0, 0, newWidth, newHeight); |
|
|
|
|
|
|
|
|
const adjustedUrl = canvas.toDataURL('image/jpeg', 0.95); |
|
|
setAdjustedDataUrl(adjustedUrl); |
|
|
|
|
|
|
|
|
setCurrentImageData(adjustedUrl); |
|
|
}; |
|
|
|
|
|
img.src = imageData; |
|
|
}, [imageData, resolution]); |
|
|
|
|
|
if (!result.success) { |
|
|
return ( |
|
|
<div className="bg-red-50 border-2 border-red-200 rounded-xl p-6 mb-4"> |
|
|
<div className="flex items-start"> |
|
|
<div className="flex-shrink-0"> |
|
|
<svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> |
|
|
</svg> |
|
|
</div> |
|
|
<div className="ml-3"> |
|
|
<h3 className="text-sm font-medium text-red-800">Processing Error</h3> |
|
|
<div className="mt-2 text-sm text-red-700"> |
|
|
<p><strong>File:</strong> {result.filename}</p> |
|
|
<p><strong>Error:</strong> {result.error}</p> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
); |
|
|
} |
|
|
|
|
|
return ( |
|
|
<div className="bg-white rounded-xl shadow-lg overflow-hidden mb-6 border border-gray-200"> |
|
|
{/* Header */} |
|
|
<div className="bg-gradient-to-r from-primary-500 to-primary-600 px-6 py-4"> |
|
|
<div className="flex items-center justify-between"> |
|
|
<div> |
|
|
<h3 className="text-lg font-semibold text-white flex items-center"> |
|
|
<svg className="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> |
|
|
</svg> |
|
|
{result.filename} |
|
|
</h3> |
|
|
{result.pageNumber && ( |
|
|
<p className="text-primary-100 text-sm mt-1">Page {result.pageNumber}</p> |
|
|
)} |
|
|
</div> |
|
|
{onReprocess && ( |
|
|
<button |
|
|
onClick={() => onReprocess(result, resolution, adjustedDataUrl)} |
|
|
disabled={isProcessing} |
|
|
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2" |
|
|
> |
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> |
|
|
</svg> |
|
|
Reprocess |
|
|
</button> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 p-6"> |
|
|
{/* Image with bounding boxes */} |
|
|
<div className="space-y-4"> |
|
|
<h4 className="text-md font-semibold text-gray-700 flex items-center"> |
|
|
<svg className="w-5 h-5 mr-2 text-primary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /> |
|
|
</svg> |
|
|
Document Preview |
|
|
</h4> |
|
|
|
|
|
{/* Resolution Slider */} |
|
|
<div className="bg-white rounded-lg p-4 shadow-sm border border-gray-200"> |
|
|
<div className="flex items-center justify-between mb-3"> |
|
|
<label className="text-sm font-medium text-gray-700 flex items-center gap-2"> |
|
|
<SlidersHorizontal className="w-4 h-4 text-primary-500" /> |
|
|
Adjust Resolution for Reprocessing |
|
|
</label> |
|
|
<span className="text-sm font-semibold text-primary-600">{resolution}%</span> |
|
|
</div> |
|
|
<input |
|
|
type="range" |
|
|
min="10" |
|
|
max="100" |
|
|
step="5" |
|
|
value={resolution} |
|
|
onChange={(e) => setResolution(parseInt(e.target.value))} |
|
|
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider-thumb" |
|
|
disabled={isProcessing} |
|
|
/> |
|
|
<div className="flex justify-between text-xs text-gray-500 mt-2"> |
|
|
<span>10% (Fast)</span> |
|
|
<span className="text-gray-400">Dimensions: {previewDimensions.width} × {previewDimensions.height}px</span> |
|
|
<span>100% (Best)</span> |
|
|
</div> |
|
|
</div> |
|
|
<div className="relative bg-gray-50 rounded-lg p-4 flex justify-center items-center"> |
|
|
<canvas ref={canvasRef} className="max-w-full h-auto rounded shadow-md" /> |
|
|
{isProcessing && ( |
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/10 rounded-lg"> |
|
|
<div className="scanning-line"></div> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
<div className="flex gap-4 text-sm"> |
|
|
{result.signature_coords && result.signature_coords.length > 0 && ( |
|
|
<div className="flex items-center"> |
|
|
<div className="w-4 h-4 border-2 border-red-500 mr-2"></div> |
|
|
<span className="text-gray-600">Signature Detected</span> |
|
|
</div> |
|
|
)} |
|
|
{result.stamp_coords && result.stamp_coords.length > 0 && ( |
|
|
<div className="flex items-center"> |
|
|
<div className="w-4 h-4 border-2 border-blue-500 mr-2"></div> |
|
|
<span className="text-gray-600">Stamp Detected</span> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* Extracted Information */} |
|
|
<div className="space-y-4"> |
|
|
<h4 className="text-md font-semibold text-gray-700 flex items-center"> |
|
|
<svg className="w-5 h-5 mr-2 text-primary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> |
|
|
</svg> |
|
|
Extracted Information |
|
|
</h4> |
|
|
|
|
|
<div className="bg-gray-50 rounded-lg p-4 space-y-3"> |
|
|
{/* Performance Metrics */} |
|
|
<div className="grid grid-cols-3 gap-3"> |
|
|
{result.processing_time !== undefined && ( |
|
|
<div className="bg-white rounded-lg p-3 shadow-sm text-center"> |
|
|
<div className="text-xs text-gray-500 mb-1">Processing Time</div> |
|
|
<div className="text-lg font-bold text-blue-600">{result.processing_time.toFixed(2)}s</div> |
|
|
</div> |
|
|
)} |
|
|
{result.confidence !== undefined && ( |
|
|
<div className="bg-white rounded-lg p-3 shadow-sm text-center"> |
|
|
<div className="text-xs text-gray-500 mb-1">Confidence</div> |
|
|
<div className="text-lg font-bold text-green-600">{(result.confidence * 100).toFixed(1)}%</div> |
|
|
</div> |
|
|
)} |
|
|
{result.cost_estimate_usd !== undefined && ( |
|
|
<div className="bg-white rounded-lg p-3 shadow-sm text-center"> |
|
|
<div className="text-xs text-gray-500 mb-1">Cost</div> |
|
|
<div className="text-lg font-bold text-purple-600">${result.cost_estimate_usd.toFixed(4)}</div> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
|
|
|
<div className="bg-white rounded-lg p-4 shadow-sm"> |
|
|
<h5 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-2">Extracted Text</h5> |
|
|
<div className="text-sm text-gray-800 whitespace-pre-wrap max-h-96 overflow-y-auto font-mono bg-gray-50 p-3 rounded border border-gray-200"> |
|
|
{result.extracted_text || 'No text extracted'} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* Reasoning Output (Chain of Thought) */} |
|
|
{result.timing_breakdown?.reasoning_output && ( |
|
|
<div className="bg-blue-50 rounded-lg border border-blue-200 overflow-hidden"> |
|
|
<button |
|
|
onClick={() => setShowReasoning(!showReasoning)} |
|
|
className="w-full px-4 py-3 flex items-center justify-between hover:bg-blue-100 transition-colors" |
|
|
> |
|
|
<div className="flex items-center gap-2"> |
|
|
<Brain className="w-5 h-5 text-blue-600" /> |
|
|
<h5 className="text-sm font-semibold text-blue-700 uppercase tracking-wide"> |
|
|
Reasoning Output |
|
|
</h5> |
|
|
</div> |
|
|
{showReasoning ? ( |
|
|
<ChevronUp className="w-5 h-5 text-blue-600" /> |
|
|
) : ( |
|
|
<ChevronDown className="w-5 h-5 text-blue-600" /> |
|
|
)} |
|
|
</button> |
|
|
{showReasoning && ( |
|
|
<div className="px-4 pb-4"> |
|
|
<div className="text-xs text-blue-600 mb-2"> |
|
|
This is the model's reasoning before extracting structured fields |
|
|
</div> |
|
|
<div className="text-sm text-gray-800 whitespace-pre-wrap max-h-96 overflow-y-auto font-mono bg-white p-3 rounded border border-blue-300"> |
|
|
{result.timing_breakdown.reasoning_output} |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{/* Detection Status */} |
|
|
<div className="grid grid-cols-2 gap-3"> |
|
|
<div className="bg-white rounded-lg p-4 shadow-sm"> |
|
|
<div className="flex items-center justify-between"> |
|
|
<span className="text-sm font-medium text-gray-600">Signature</span> |
|
|
{result.signature_coords && result.signature_coords.length > 0 ? ( |
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"> |
|
|
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20"> |
|
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" /> |
|
|
</svg> |
|
|
Detected |
|
|
</span> |
|
|
) : ( |
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"> |
|
|
Not Found |
|
|
</span> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div className="bg-white rounded-lg p-4 shadow-sm"> |
|
|
<div className="flex items-center justify-between"> |
|
|
<span className="text-sm font-medium text-gray-600">Stamp</span> |
|
|
{result.stamp_coords && result.stamp_coords.length > 0 ? ( |
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"> |
|
|
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20"> |
|
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" /> |
|
|
</svg> |
|
|
Detected |
|
|
</span> |
|
|
) : ( |
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"> |
|
|
Not Found |
|
|
</span> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* Cropped Signature and Stamp */} |
|
|
{(signatureCrop || stampCrop) && ( |
|
|
<div className="bg-white rounded-lg p-4 shadow-sm"> |
|
|
<h5 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Extracted Elements</h5> |
|
|
<div className="grid grid-cols-2 gap-4"> |
|
|
{signatureCrop && ( |
|
|
<div className="space-y-2"> |
|
|
<div className="text-xs font-medium text-red-600 uppercase">Signature</div> |
|
|
<div className="border-2 border-red-200 rounded-lg p-2 bg-gray-50"> |
|
|
<img src={signatureCrop} alt="Signature" className="w-full h-auto" /> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
{stampCrop && ( |
|
|
<div className="space-y-2"> |
|
|
<div className="text-xs font-medium text-blue-600 uppercase">Stamp</div> |
|
|
<div className="border-2 border-blue-200 rounded-lg p-2 bg-gray-50"> |
|
|
<img src={stampCrop} alt="Stamp" className="w-full h-auto" /> |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
|
|
|
{/* Coordinates Info */} |
|
|
{(result.signature_coords?.length > 0 || result.stamp_coords?.length > 0) && ( |
|
|
<div className="bg-white rounded-lg p-4 shadow-sm"> |
|
|
<h5 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-2">Coordinates</h5> |
|
|
<div className="text-xs space-y-2"> |
|
|
{result.signature_coords?.length > 0 && ( |
|
|
<div> |
|
|
<span className="font-medium text-red-600">Signature:</span> |
|
|
<code className="ml-2 text-gray-700"> |
|
|
{JSON.stringify(result.signature_coords)} |
|
|
</code> |
|
|
</div> |
|
|
)} |
|
|
{result.stamp_coords?.length > 0 && ( |
|
|
<div> |
|
|
<span className="font-medium text-blue-600">Stamp:</span> |
|
|
<code className="ml-2 text-gray-700"> |
|
|
{JSON.stringify(result.stamp_coords)} |
|
|
</code> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
)} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
{/* Hidden canvas for resolution preview generation */} |
|
|
<canvas ref={previewCanvasRef} style={{ display: 'none' }} /> |
|
|
</div> |
|
|
); |
|
|
}; |
|
|
|
|
|
export default ResultCard; |
|
|
|