github-actions[bot] commited on
Commit
e74ba5a
·
1 Parent(s): 7b7afd3

Sync from GitHub: c230872b9b1bdc86965c2debf97a243408f4bf65

Browse files
frontend/src/App.jsx CHANGED
@@ -3,8 +3,9 @@ import { FileText, AlertCircle } from 'lucide-react';
3
  import FileUpload from './components/FileUpload';
4
  import ProgressIndicator from './components/ProgressIndicator';
5
  import ResultCard from './components/ResultCard';
 
6
  import { convertFileToImages, dataUrlToBlob } from './utils/fileConverter';
7
- import { processBatchInvoices } from './utils/api';
8
 
9
  function App() {
10
  const [processing, setProcessing] = useState(false);
@@ -12,17 +13,23 @@ function App() {
12
  const [progress, setProgress] = useState({ total: 0, completed: 0, current: '' });
13
  const [error, setError] = useState(null);
14
  const [imageDataMap, setImageDataMap] = useState({});
 
 
 
15
 
16
  const handleFilesSelected = async (files) => {
17
- setProcessing(true);
18
  setResults([]);
19
  setError(null);
20
  setImageDataMap({});
 
 
21
 
22
  try {
23
- // Step 1: Convert all files to images
24
  const allImages = [];
25
  const imageData = {};
 
26
 
27
  for (const file of files) {
28
  try {
@@ -30,13 +37,18 @@ function App() {
30
  images.forEach((img, idx) => {
31
  const key = `${file.name}_${idx}`;
32
  allImages.push({
33
- blob: dataUrlToBlob(img.dataUrl),
 
34
  filename: img.filename,
35
  originalFile: img.originalFile,
36
  pageNumber: img.pageNumber,
37
- key: key
38
  });
39
  imageData[key] = img.dataUrl;
 
 
 
 
 
40
  });
41
  } catch (err) {
42
  console.error(`Error converting ${file.name}:`, err);
@@ -45,30 +57,117 @@ function App() {
45
  }
46
 
47
  setImageDataMap(imageData);
48
- setProgress({ total: allImages.length, completed: 0, current: '' });
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
- // Step 2: Process all images through the API
51
- await processBatchInvoices(allImages, (index, result) => {
52
- // Update progress
 
 
 
 
 
 
 
53
  setProgress(prev => ({
54
  ...prev,
55
- completed: index + 1,
56
- current: index < allImages.length - 1 ? allImages[index + 1].filename : ''
57
  }));
58
 
59
- // Add result
60
- setResults(prev => [...prev, result]);
61
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
 
 
63
  } catch (err) {
64
  console.error('Processing error:', err);
65
  setError(`Processing failed: ${err.message}`);
66
  } finally {
67
  setProcessing(false);
 
68
  setProgress(prev => ({ ...prev, current: '' }));
69
  }
70
  };
71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  return (
73
  <div className="min-h-screen py-8 px-4 sm:px-6 lg:px-8">
74
  <div className="max-w-7xl mx-auto">
@@ -94,6 +193,43 @@ function App() {
94
  <FileUpload onFilesSelected={handleFilesSelected} disabled={processing} />
95
  </div>
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  {/* Error Display */}
98
  {error && (
99
  <div className="bg-red-50 border-2 border-red-200 rounded-xl p-4 flex items-start">
@@ -129,12 +265,14 @@ function App() {
129
  </div>
130
 
131
  {results.map((result, index) => {
132
- const imageKey = `${result.originalFile}_${index}`;
133
  return (
134
  <ResultCard
135
  key={index}
136
  result={result}
137
  imageData={imageDataMap[imageKey] || imageDataMap[Object.keys(imageDataMap)[index]]}
 
 
138
  />
139
  );
140
  })}
 
3
  import FileUpload from './components/FileUpload';
4
  import ProgressIndicator from './components/ProgressIndicator';
5
  import ResultCard from './components/ResultCard';
6
+ import ImagePreview from './components/ImagePreview';
7
  import { convertFileToImages, dataUrlToBlob } from './utils/fileConverter';
8
+ import { processSingleInvoice } from './utils/api';
9
 
10
  function App() {
11
  const [processing, setProcessing] = useState(false);
 
13
  const [progress, setProgress] = useState({ total: 0, completed: 0, current: '' });
14
  const [error, setError] = useState(null);
15
  const [imageDataMap, setImageDataMap] = useState({});
16
+ const [previewImages, setPreviewImages] = useState([]);
17
+ const [processingIndex, setProcessingIndex] = useState(null);
18
+ const [resolutionMap, setResolutionMap] = useState({});
19
 
20
  const handleFilesSelected = async (files) => {
21
+ setProcessing(false);
22
  setResults([]);
23
  setError(null);
24
  setImageDataMap({});
25
+ setPreviewImages([]);
26
+ setResolutionMap({});
27
 
28
  try {
29
+ // Step 1: Convert all files to images and show previews
30
  const allImages = [];
31
  const imageData = {};
32
+ const previews = [];
33
 
34
  for (const file of files) {
35
  try {
 
37
  images.forEach((img, idx) => {
38
  const key = `${file.name}_${idx}`;
39
  allImages.push({
40
+ key: key,
41
+ dataUrl: img.dataUrl,
42
  filename: img.filename,
43
  originalFile: img.originalFile,
44
  pageNumber: img.pageNumber,
 
45
  });
46
  imageData[key] = img.dataUrl;
47
+ previews.push({
48
+ key: key,
49
+ dataUrl: img.dataUrl,
50
+ filename: img.filename,
51
+ });
52
  });
53
  } catch (err) {
54
  console.error(`Error converting ${file.name}:`, err);
 
57
  }
58
 
59
  setImageDataMap(imageData);
60
+ setPreviewImages(previews);
61
+
62
+ // Initialize resolution map with 100% for all images
63
+ const initialResolutions = {};
64
+ previews.forEach(p => {
65
+ initialResolutions[p.key] = { dataUrl: p.dataUrl, resolution: 100 };
66
+ });
67
+ setResolutionMap(initialResolutions);
68
+
69
+ } catch (err) {
70
+ console.error('Processing error:', err);
71
+ setError(`Processing failed: ${err.message}`);
72
+ }
73
+ };
74
 
75
+ const handleProcessImages = async () => {
76
+ setProcessing(true);
77
+ setResults([]);
78
+ setError(null);
79
+ setProgress({ total: previewImages.length, completed: 0, current: '' });
80
+
81
+ try {
82
+ for (let i = 0; i < previewImages.length; i++) {
83
+ const preview = previewImages[i];
84
+ setProcessingIndex(i);
85
  setProgress(prev => ({
86
  ...prev,
87
+ current: preview.filename
 
88
  }));
89
 
90
+ try {
91
+ // Use resolution-adjusted image if available
92
+ const processData = resolutionMap[preview.key] || { dataUrl: preview.dataUrl };
93
+ const blob = dataUrlToBlob(processData.dataUrl);
94
+
95
+ const result = await processSingleInvoice(blob, preview.filename);
96
+
97
+ const resultWithMetadata = {
98
+ ...result,
99
+ filename: preview.filename,
100
+ originalFile: preview.filename,
101
+ index: i,
102
+ success: true,
103
+ key: preview.key
104
+ };
105
+
106
+ setResults(prev => [...prev, resultWithMetadata]);
107
+ } catch (error) {
108
+ const errorResult = {
109
+ filename: preview.filename,
110
+ index: i,
111
+ success: false,
112
+ error: error.response?.data?.detail || error.message,
113
+ key: preview.key
114
+ };
115
+ setResults(prev => [...prev, errorResult]);
116
+ }
117
 
118
+ setProgress(prev => ({ ...prev, completed: i + 1 }));
119
+ }
120
  } catch (err) {
121
  console.error('Processing error:', err);
122
  setError(`Processing failed: ${err.message}`);
123
  } finally {
124
  setProcessing(false);
125
+ setProcessingIndex(null);
126
  setProgress(prev => ({ ...prev, current: '' }));
127
  }
128
  };
129
 
130
+ const handleReprocess = async (result) => {
131
+ const index = results.findIndex(r => r.key === result.key);
132
+ if (index === -1) return;
133
+
134
+ setProcessingIndex(index);
135
+
136
+ try {
137
+ // Use current resolution-adjusted image
138
+ const processData = resolutionMap[result.key] || { dataUrl: imageDataMap[result.key] };
139
+ const blob = dataUrlToBlob(processData.dataUrl);
140
+
141
+ const newResult = await processSingleInvoice(blob, result.filename);
142
+
143
+ const resultWithMetadata = {
144
+ ...newResult,
145
+ filename: result.filename,
146
+ originalFile: result.originalFile,
147
+ index: index,
148
+ success: true,
149
+ key: result.key
150
+ };
151
+
152
+ setResults(prev => {
153
+ const newResults = [...prev];
154
+ newResults[index] = resultWithMetadata;
155
+ return newResults;
156
+ });
157
+ } catch (error) {
158
+ setError(`Reprocessing failed: ${error.message}`);
159
+ } finally {
160
+ setProcessingIndex(null);
161
+ }
162
+ };
163
+
164
+ const handleResolutionChange = (key, dataUrl, resolution) => {
165
+ setResolutionMap(prev => ({
166
+ ...prev,
167
+ [key]: { dataUrl, resolution }
168
+ }));
169
+ };
170
+
171
  return (
172
  <div className="min-h-screen py-8 px-4 sm:px-6 lg:px-8">
173
  <div className="max-w-7xl mx-auto">
 
193
  <FileUpload onFilesSelected={handleFilesSelected} disabled={processing} />
194
  </div>
195
 
196
+ {/* Image Previews with Resolution Sliders */}
197
+ {previewImages.length > 0 && !processing && results.length === 0 && (
198
+ <div className="space-y-4">
199
+ <div className="flex items-center justify-between bg-white rounded-xl p-4 shadow-md">
200
+ <h2 className="text-xl font-bold text-gray-800">
201
+ Preview & Adjust Resolution
202
+ </h2>
203
+ <span className="text-sm text-gray-600">
204
+ {previewImages.length} {previewImages.length === 1 ? 'Image' : 'Images'}
205
+ </span>
206
+ </div>
207
+
208
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
209
+ {previewImages.map((preview, idx) => (
210
+ <ImagePreview
211
+ key={preview.key}
212
+ imageData={preview.dataUrl}
213
+ fileName={preview.filename}
214
+ onResolutionChange={(dataUrl, resolution) =>
215
+ handleResolutionChange(preview.key, dataUrl, resolution)
216
+ }
217
+ />
218
+ ))}
219
+ </div>
220
+
221
+ <button
222
+ onClick={handleProcessImages}
223
+ className="w-full bg-gradient-to-r from-green-500 to-green-600 text-white font-semibold py-4 px-6 rounded-lg hover:from-green-600 hover:to-green-700 transition-all shadow-lg hover:shadow-xl flex items-center justify-center space-x-2 text-lg"
224
+ >
225
+ <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
226
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
227
+ </svg>
228
+ <span>Process {previewImages.length} Image{previewImages.length > 1 ? 's' : ''}</span>
229
+ </button>
230
+ </div>
231
+ )}
232
+
233
  {/* Error Display */}
234
  {error && (
235
  <div className="bg-red-50 border-2 border-red-200 rounded-xl p-4 flex items-start">
 
265
  </div>
266
 
267
  {results.map((result, index) => {
268
+ const imageKey = result.key || `${result.originalFile}_${index}`;
269
  return (
270
  <ResultCard
271
  key={index}
272
  result={result}
273
  imageData={imageDataMap[imageKey] || imageDataMap[Object.keys(imageDataMap)[index]]}
274
+ onReprocess={handleReprocess}
275
+ isProcessing={processingIndex === index}
276
  />
277
  );
278
  })}
frontend/src/components/ImagePreview.jsx ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { Slider } from 'lucide-react';
3
+
4
+ const ImagePreview = ({ imageData, fileName, onResolutionChange }) => {
5
+ const [resolution, setResolution] = useState(100);
6
+ const canvasRef = useRef(null);
7
+ const [originalDimensions, setOriginalDimensions] = useState({ width: 0, height: 0 });
8
+ const [currentDimensions, setCurrentDimensions] = useState({ width: 0, height: 0 });
9
+
10
+ useEffect(() => {
11
+ if (!imageData || !canvasRef.current) return;
12
+
13
+ const canvas = canvasRef.current;
14
+ const ctx = canvas.getContext('2d');
15
+ const img = new Image();
16
+
17
+ img.onload = () => {
18
+ if (!originalDimensions.width) {
19
+ setOriginalDimensions({ width: img.width, height: img.height });
20
+ }
21
+
22
+ // Calculate new dimensions based on resolution
23
+ const newWidth = Math.floor(img.width * (resolution / 100));
24
+ const newHeight = Math.floor(img.height * (resolution / 100));
25
+
26
+ setCurrentDimensions({ width: newWidth, height: newHeight });
27
+
28
+ // Set display size (max 400px width for preview)
29
+ const displayWidth = Math.min(400, newWidth);
30
+ const displayHeight = Math.floor(newHeight * (displayWidth / newWidth));
31
+
32
+ canvas.width = displayWidth;
33
+ canvas.height = displayHeight;
34
+
35
+ // Draw scaled image
36
+ ctx.drawImage(img, 0, 0, displayWidth, displayHeight);
37
+
38
+ // Notify parent of resolution change
39
+ if (onResolutionChange) {
40
+ const resizedCanvas = document.createElement('canvas');
41
+ resizedCanvas.width = newWidth;
42
+ resizedCanvas.height = newHeight;
43
+ const resizedCtx = resizedCanvas.getContext('2d');
44
+ resizedCtx.drawImage(img, 0, 0, newWidth, newHeight);
45
+ const resizedDataUrl = resizedCanvas.toDataURL('image/jpeg', 0.95);
46
+ onResolutionChange(resizedDataUrl, resolution);
47
+ }
48
+ };
49
+
50
+ img.src = imageData;
51
+ }, [imageData, resolution]);
52
+
53
+ const handleResolutionChange = (e) => {
54
+ const newResolution = parseInt(e.target.value);
55
+ setResolution(newResolution);
56
+ };
57
+
58
+ return (
59
+ <div className="bg-white rounded-lg shadow-md p-4 space-y-3">
60
+ <div className="flex items-center justify-between">
61
+ <h4 className="text-sm font-semibold text-gray-700">Preview: {fileName}</h4>
62
+ <span className="text-xs text-gray-500">
63
+ {currentDimensions.width} × {currentDimensions.height}px
64
+ </span>
65
+ </div>
66
+
67
+ <div className="bg-gray-50 rounded-lg p-3 flex justify-center">
68
+ <canvas ref={canvasRef} className="rounded shadow-sm" />
69
+ </div>
70
+
71
+ <div className="space-y-2">
72
+ <div className="flex items-center justify-between">
73
+ <label className="text-sm font-medium text-gray-700 flex items-center gap-2">
74
+ <Slider className="w-4 h-4" />
75
+ Resolution
76
+ </label>
77
+ <span className="text-sm font-bold text-primary-600">{resolution}%</span>
78
+ </div>
79
+
80
+ <input
81
+ type="range"
82
+ min="10"
83
+ max="100"
84
+ value={resolution}
85
+ onChange={handleResolutionChange}
86
+ className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider"
87
+ />
88
+
89
+ <div className="flex justify-between text-xs text-gray-500">
90
+ <span>Low Quality</span>
91
+ <span>Original</span>
92
+ </div>
93
+
94
+ {resolution < 100 && (
95
+ <div className="bg-blue-50 border border-blue-200 rounded p-2 text-xs text-blue-700">
96
+ 💡 Lower resolution = faster processing & lower cost
97
+ </div>
98
+ )}
99
+ </div>
100
+
101
+ <div className="text-xs text-gray-500 space-y-1">
102
+ <div className="flex justify-between">
103
+ <span>Original:</span>
104
+ <span className="font-medium">{originalDimensions.width} × {originalDimensions.height}px</span>
105
+ </div>
106
+ <div className="flex justify-between">
107
+ <span>Processing:</span>
108
+ <span className="font-medium text-primary-600">{currentDimensions.width} × {currentDimensions.height}px</span>
109
+ </div>
110
+ </div>
111
+ </div>
112
+ );
113
+ };
114
+
115
+ export default ImagePreview;
frontend/src/components/ResultCard.jsx CHANGED
@@ -1,8 +1,34 @@
1
  import React, { useRef, useEffect, useState } from 'react';
2
 
3
- const ResultCard = ({ result, imageData }) => {
4
  const canvasRef = useRef(null);
5
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  useEffect(() => {
8
  if (!imageData || !canvasRef.current) return;
@@ -38,6 +64,16 @@ const ResultCard = ({ result, imageData }) => {
38
  const scaleX = width / img.width;
39
  const scaleY = height / img.height;
40
 
 
 
 
 
 
 
 
 
 
 
41
  // Draw bounding boxes for signature
42
  if (result.signature_coords && result.signature_coords.length > 0) {
43
  ctx.strokeStyle = '#ef4444';
@@ -111,15 +147,31 @@ const ResultCard = ({ result, imageData }) => {
111
  <div className="bg-white rounded-xl shadow-lg overflow-hidden mb-6 border border-gray-200">
112
  {/* Header */}
113
  <div className="bg-gradient-to-r from-primary-500 to-primary-600 px-6 py-4">
114
- <h3 className="text-lg font-semibold text-white flex items-center">
115
- <svg className="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
116
- <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" />
117
- </svg>
118
- {result.filename}
119
- </h3>
120
- {result.pageNumber && (
121
- <p className="text-primary-100 text-sm mt-1">Page {result.pageNumber}</p>
122
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  </div>
124
 
125
  <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 p-6">
@@ -133,6 +185,11 @@ const ResultCard = ({ result, imageData }) => {
133
  </h4>
134
  <div className="relative bg-gray-50 rounded-lg p-4 flex justify-center items-center">
135
  <canvas ref={canvasRef} className="max-w-full h-auto rounded shadow-md" />
 
 
 
 
 
136
  </div>
137
  <div className="flex gap-4 text-sm">
138
  {result.signature_coords && result.signature_coords.length > 0 && (
@@ -160,6 +217,28 @@ const ResultCard = ({ result, imageData }) => {
160
  </h4>
161
 
162
  <div className="bg-gray-50 rounded-lg p-4 space-y-3">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  <div className="bg-white rounded-lg p-4 shadow-sm">
164
  <h5 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-2">Extracted Text</h5>
165
  <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">
@@ -206,6 +285,31 @@ const ResultCard = ({ result, imageData }) => {
206
  </div>
207
  </div>
208
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  {/* Coordinates Info */}
210
  {(result.signature_coords?.length > 0 || result.stamp_coords?.length > 0) && (
211
  <div className="bg-white rounded-lg p-4 shadow-sm">
 
1
  import React, { useRef, useEffect, useState } from 'react';
2
 
3
+ const ResultCard = ({ result, imageData, onReprocess, isProcessing }) => {
4
  const canvasRef = useRef(null);
5
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
6
+ const [signatureCrop, setSignatureCrop] = useState(null);
7
+ const [stampCrop, setStampCrop] = useState(null);
8
+ const [resolution, setResolution] = useState(100);
9
+
10
+ // Function to crop image regions
11
+ const cropRegion = (img, coords, scaleX, scaleY) => {
12
+ if (!coords || coords.length === 0) return null;
13
+
14
+ const [x1, y1, x2, y2] = coords[0];
15
+ const width = (x2 - x1) * scaleX;
16
+ const height = (y2 - y1) * scaleY;
17
+
18
+ const cropCanvas = document.createElement('canvas');
19
+ cropCanvas.width = width;
20
+ cropCanvas.height = height;
21
+ const cropCtx = cropCanvas.getContext('2d');
22
+
23
+ // Draw the cropped region
24
+ cropCtx.drawImage(
25
+ img,
26
+ x1, y1, x2 - x1, y2 - y1,
27
+ 0, 0, width, height
28
+ );
29
+
30
+ return cropCanvas.toDataURL();
31
+ };
32
 
33
  useEffect(() => {
34
  if (!imageData || !canvasRef.current) return;
 
64
  const scaleX = width / img.width;
65
  const scaleY = height / img.height;
66
 
67
+ // Create cropped images for signature and stamp
68
+ if (result.signature_coords && result.signature_coords.length > 0) {
69
+ const sigCrop = cropRegion(img, result.signature_coords, 1, 1);
70
+ setSignatureCrop(sigCrop);
71
+ }
72
+ if (result.stamp_coords && result.stamp_coords.length > 0) {
73
+ const stCrop = cropRegion(img, result.stamp_coords, 1, 1);
74
+ setStampCrop(stCrop);
75
+ }
76
+
77
  // Draw bounding boxes for signature
78
  if (result.signature_coords && result.signature_coords.length > 0) {
79
  ctx.strokeStyle = '#ef4444';
 
147
  <div className="bg-white rounded-xl shadow-lg overflow-hidden mb-6 border border-gray-200">
148
  {/* Header */}
149
  <div className="bg-gradient-to-r from-primary-500 to-primary-600 px-6 py-4">
150
+ <div className="flex items-center justify-between">
151
+ <div>
152
+ <h3 className="text-lg font-semibold text-white flex items-center">
153
+ <svg className="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
154
+ <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" />
155
+ </svg>
156
+ {result.filename}
157
+ </h3>
158
+ {result.pageNumber && (
159
+ <p className="text-primary-100 text-sm mt-1">Page {result.pageNumber}</p>
160
+ )}
161
+ </div>
162
+ {onReprocess && (
163
+ <button
164
+ onClick={() => onReprocess(result)}
165
+ disabled={isProcessing}
166
+ 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"
167
+ >
168
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
169
+ <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" />
170
+ </svg>
171
+ Reprocess
172
+ </button>
173
+ )}
174
+ </div>
175
  </div>
176
 
177
  <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 p-6">
 
185
  </h4>
186
  <div className="relative bg-gray-50 rounded-lg p-4 flex justify-center items-center">
187
  <canvas ref={canvasRef} className="max-w-full h-auto rounded shadow-md" />
188
+ {isProcessing && (
189
+ <div className="absolute inset-0 flex items-center justify-center bg-black/10 rounded-lg">
190
+ <div className="scanning-line"></div>
191
+ </div>
192
+ )}
193
  </div>
194
  <div className="flex gap-4 text-sm">
195
  {result.signature_coords && result.signature_coords.length > 0 && (
 
217
  </h4>
218
 
219
  <div className="bg-gray-50 rounded-lg p-4 space-y-3">
220
+ {/* Performance Metrics */}
221
+ <div className="grid grid-cols-3 gap-3">
222
+ {result.processing_time !== undefined && (
223
+ <div className="bg-white rounded-lg p-3 shadow-sm text-center">
224
+ <div className="text-xs text-gray-500 mb-1">Processing Time</div>
225
+ <div className="text-lg font-bold text-blue-600">{result.processing_time.toFixed(2)}s</div>
226
+ </div>
227
+ )}
228
+ {result.confidence !== undefined && (
229
+ <div className="bg-white rounded-lg p-3 shadow-sm text-center">
230
+ <div className="text-xs text-gray-500 mb-1">Confidence</div>
231
+ <div className="text-lg font-bold text-green-600">{(result.confidence * 100).toFixed(1)}%</div>
232
+ </div>
233
+ )}
234
+ {result.fields?.cost_estimate_usd !== undefined && (
235
+ <div className="bg-white rounded-lg p-3 shadow-sm text-center">
236
+ <div className="text-xs text-gray-500 mb-1">Cost</div>
237
+ <div className="text-lg font-bold text-purple-600">${result.fields.cost_estimate_usd.toFixed(4)}</div>
238
+ </div>
239
+ )}
240
+ </div>
241
+
242
  <div className="bg-white rounded-lg p-4 shadow-sm">
243
  <h5 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-2">Extracted Text</h5>
244
  <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">
 
285
  </div>
286
  </div>
287
 
288
+ {/* Cropped Signature and Stamp */}
289
+ {(signatureCrop || stampCrop) && (
290
+ <div className="bg-white rounded-lg p-4 shadow-sm">
291
+ <h5 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Extracted Elements</h5>
292
+ <div className="grid grid-cols-2 gap-4">
293
+ {signatureCrop && (
294
+ <div className="space-y-2">
295
+ <div className="text-xs font-medium text-red-600 uppercase">Signature</div>
296
+ <div className="border-2 border-red-200 rounded-lg p-2 bg-gray-50">
297
+ <img src={signatureCrop} alt="Signature" className="w-full h-auto" />
298
+ </div>
299
+ </div>
300
+ )}
301
+ {stampCrop && (
302
+ <div className="space-y-2">
303
+ <div className="text-xs font-medium text-blue-600 uppercase">Stamp</div>
304
+ <div className="border-2 border-blue-200 rounded-lg p-2 bg-gray-50">
305
+ <img src={stampCrop} alt="Stamp" className="w-full h-auto" />
306
+ </div>
307
+ </div>
308
+ )}
309
+ </div>
310
+ </div>
311
+ )}
312
+
313
  {/* Coordinates Info */}
314
  {(result.signature_coords?.length > 0 || result.stamp_coords?.length > 0) && (
315
  <div className="bg-white rounded-lg p-4 shadow-sm">
frontend/src/index.css CHANGED
@@ -36,3 +36,83 @@ body {
36
  background-color: #e0f2fe;
37
  transform: scale(1.02);
38
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  background-color: #e0f2fe;
37
  transform: scale(1.02);
38
  }
39
+
40
+ /* Scanning animation */
41
+ .scanning-line {
42
+ position: absolute;
43
+ top: 0;
44
+ left: 0;
45
+ right: 0;
46
+ height: 3px;
47
+ background: linear-gradient(to right, transparent, #0ea5e9, transparent);
48
+ box-shadow: 0 0 10px #0ea5e9;
49
+ animation: scan 2s linear infinite;
50
+ }
51
+
52
+ @keyframes scan {
53
+ 0% {
54
+ top: 0;
55
+ opacity: 0;
56
+ }
57
+ 10% {
58
+ opacity: 1;
59
+ }
60
+ 90% {
61
+ opacity: 1;
62
+ }
63
+ 100% {
64
+ top: 100%;
65
+ opacity: 0;
66
+ }
67
+ }
68
+
69
+ /* Custom slider styles */
70
+ input[type="range"].slider {
71
+ -webkit-appearance: none;
72
+ appearance: none;
73
+ background: transparent;
74
+ }
75
+
76
+ input[type="range"].slider::-webkit-slider-track {
77
+ background: #e5e7eb;
78
+ height: 8px;
79
+ border-radius: 4px;
80
+ }
81
+
82
+ input[type="range"].slider::-webkit-slider-thumb {
83
+ -webkit-appearance: none;
84
+ appearance: none;
85
+ width: 20px;
86
+ height: 20px;
87
+ border-radius: 50%;
88
+ background: linear-gradient(135deg, #0ea5e9, #0284c7);
89
+ cursor: pointer;
90
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
91
+ margin-top: -6px;
92
+ }
93
+
94
+ input[type="range"].slider::-webkit-slider-thumb:hover {
95
+ transform: scale(1.1);
96
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
97
+ }
98
+
99
+ input[type="range"].slider::-moz-range-track {
100
+ background: #e5e7eb;
101
+ height: 8px;
102
+ border-radius: 4px;
103
+ }
104
+
105
+ input[type="range"].slider::-moz-range-thumb {
106
+ width: 20px;
107
+ height: 20px;
108
+ border-radius: 50%;
109
+ background: linear-gradient(135deg, #0ea5e9, #0284c7);
110
+ cursor: pointer;
111
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
112
+ border: none;
113
+ }
114
+
115
+ input[type="range"].slider::-moz-range-thumb:hover {
116
+ transform: scale(1.1);
117
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
118
+ }