- Analytics endpoints now query document_processing_events table instead of returning hardcoded zeros (/documents/analytics, /processing-stats, /health/agentic-rag) - Add react-markdown for rich text rendering in CIM review template (readOnly textarea fields now render markdown formatting) - Fix upload page references from "Firebase Storage" to "Cloud Storage" - Fix financial year labels from "FY3" to "FY-3" in DocumentViewer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
476 lines
18 KiB
TypeScript
476 lines
18 KiB
TypeScript
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
|
import { useDropzone } from 'react-dropzone';
|
|
import { Upload, FileText, X, CheckCircle, AlertCircle, Cloud } from 'lucide-react';
|
|
import { cn } from '../utils/cn';
|
|
import { documentService, GCSErrorHandler, GCSError } from '../services/documentService';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
|
|
interface UploadedFile {
|
|
id: string;
|
|
name: string;
|
|
size: number;
|
|
type: string;
|
|
status: 'uploading' | 'uploaded' | 'processing' | 'completed' | 'error';
|
|
progress: number;
|
|
error?: string;
|
|
documentId?: string; // Real document ID from backend
|
|
// Firebase Storage specific fields
|
|
storageError?: boolean;
|
|
storageType?: 'firebase' | 'local';
|
|
storageUrl?: string;
|
|
}
|
|
|
|
interface DocumentUploadProps {
|
|
onUploadComplete?: (documentId: string) => void;
|
|
onUploadError?: (error: string) => void;
|
|
}
|
|
|
|
const DocumentUpload: React.FC<DocumentUploadProps> = ({
|
|
onUploadComplete,
|
|
onUploadError,
|
|
}) => {
|
|
const { token } = useAuth();
|
|
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const abortControllers = useRef<Map<string, AbortController>>(new Map());
|
|
|
|
// Cleanup function to cancel ongoing uploads when component unmounts
|
|
useEffect(() => {
|
|
return () => {
|
|
// Cancel all ongoing uploads when component unmounts
|
|
abortControllers.current.forEach((controller, fileId) => {
|
|
controller.abort();
|
|
console.log(`Cancelled upload for file: ${fileId}`);
|
|
});
|
|
abortControllers.current.clear();
|
|
};
|
|
}, []);
|
|
|
|
// Handle page visibility changes (tab switching, minimizing)
|
|
useEffect(() => {
|
|
const handleVisibilityChange = () => {
|
|
if (document.hidden && isUploading && abortControllers.current.size > 0) {
|
|
console.warn('Page hidden during upload - uploads may be cancelled');
|
|
// Optionally show a notification to the user
|
|
if ('Notification' in window && Notification.permission === 'granted') {
|
|
new Notification('Upload in Progress', {
|
|
body: 'Please return to the tab to continue uploads',
|
|
icon: '/favicon.ico',
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
return () => {
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
};
|
|
}, [isUploading]);
|
|
|
|
const onDrop = useCallback(async (acceptedFiles: File[]) => {
|
|
setIsUploading(true);
|
|
|
|
const newFiles: UploadedFile[] = acceptedFiles.map(file => ({
|
|
id: Math.random().toString(36).substr(2, 9),
|
|
name: file.name,
|
|
size: file.size,
|
|
type: file.type,
|
|
status: 'uploading',
|
|
progress: 0,
|
|
}));
|
|
|
|
setUploadedFiles(prev => [...prev, ...newFiles]);
|
|
|
|
// Upload files using the document service
|
|
for (let i = 0; i < acceptedFiles.length; i++) {
|
|
const file = acceptedFiles[i];
|
|
const uploadedFile = newFiles[i];
|
|
|
|
// Create AbortController for this upload
|
|
const abortController = new AbortController();
|
|
abortControllers.current.set(uploadedFile.id, abortController);
|
|
|
|
try {
|
|
// Upload the document with optimized agentic RAG processing (no strategy selection needed)
|
|
const result = await documentService.uploadDocument(
|
|
file,
|
|
(progress) => {
|
|
setUploadedFiles(prev =>
|
|
prev.map(f =>
|
|
f.id === uploadedFile.id ? { ...f, progress } : f
|
|
)
|
|
);
|
|
},
|
|
abortController.signal
|
|
);
|
|
|
|
// Upload completed - update status to "processing" immediately
|
|
setUploadedFiles(prev =>
|
|
prev.map(f =>
|
|
f.id === uploadedFile.id
|
|
? {
|
|
...f,
|
|
id: result.id,
|
|
documentId: result.id,
|
|
status: 'processing', // Changed from 'uploaded' to 'processing'
|
|
progress: 100
|
|
}
|
|
: f
|
|
)
|
|
);
|
|
|
|
// Call the completion callback with the document ID
|
|
onUploadComplete?.(result.id);
|
|
|
|
// Start monitoring processing progress immediately
|
|
monitorProcessingProgress(result.id, uploadedFile.id);
|
|
|
|
} catch (error) {
|
|
// Check if this was an abort error
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
console.log(`Upload cancelled for file: ${uploadedFile.name}`);
|
|
setUploadedFiles(prev =>
|
|
prev.map(f =>
|
|
f.id === uploadedFile.id
|
|
? { ...f, status: 'error', error: 'Upload cancelled' }
|
|
: f
|
|
)
|
|
);
|
|
} else {
|
|
console.error('Upload failed:', error);
|
|
|
|
// Handle storage-specific errors
|
|
let errorMessage = 'Upload failed';
|
|
let isStorageError = false;
|
|
|
|
if (GCSErrorHandler.isGCSError(error)) {
|
|
errorMessage = GCSErrorHandler.getErrorMessage(error as GCSError);
|
|
isStorageError = true;
|
|
} else if (error instanceof Error) {
|
|
errorMessage = error.message;
|
|
}
|
|
|
|
setUploadedFiles(prev =>
|
|
prev.map(f =>
|
|
f.id === uploadedFile.id
|
|
? {
|
|
...f,
|
|
status: 'error',
|
|
error: errorMessage,
|
|
// Add storage error indicator
|
|
...(isStorageError && { storageError: true })
|
|
}
|
|
: f
|
|
)
|
|
);
|
|
|
|
onUploadError?.(errorMessage);
|
|
}
|
|
} finally {
|
|
// Clean up the abort controller
|
|
abortControllers.current.delete(uploadedFile.id);
|
|
}
|
|
}
|
|
|
|
setIsUploading(false);
|
|
}, [onUploadComplete, onUploadError]);
|
|
|
|
// Monitor processing progress for uploaded documents
|
|
const monitorProcessingProgress = useCallback((documentId: string, fileId: string) => {
|
|
// Guard against undefined or null document IDs
|
|
if (!documentId || documentId === 'undefined' || documentId === 'null') {
|
|
console.warn('Attempted to monitor progress for document with invalid ID:', documentId);
|
|
return;
|
|
}
|
|
|
|
// Validate UUID format
|
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
if (!uuidRegex.test(documentId)) {
|
|
console.warn('Attempted to monitor progress for document with invalid UUID format:', documentId);
|
|
return;
|
|
}
|
|
|
|
// Add timeout to prevent infinite polling (30 minutes max)
|
|
const startTime = Date.now();
|
|
const maxPollingTime = 30 * 60 * 1000; // 30 minutes
|
|
|
|
const checkProgress = async () => {
|
|
// Check if we've exceeded the maximum polling time
|
|
if (Date.now() - startTime > maxPollingTime) {
|
|
console.warn(`Polling timeout for document ${documentId} after ${maxPollingTime / 1000 / 60} minutes`);
|
|
setUploadedFiles(prev =>
|
|
prev.map(f =>
|
|
f.id === fileId
|
|
? {
|
|
...f,
|
|
status: 'error',
|
|
error: 'Processing timeout - please check document status manually'
|
|
}
|
|
: f
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/documents/${documentId}/progress`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
if (response.ok) {
|
|
const progress = await response.json();
|
|
|
|
// Update status based on progress
|
|
let newStatus: UploadedFile['status'] = 'processing'; // Default to processing
|
|
if (progress.status === 'uploading' || progress.status === 'uploaded') {
|
|
newStatus = 'processing'; // Still processing
|
|
} else if (progress.status === 'processing' || progress.status === 'extracting_text' || progress.status === 'processing_llm' || progress.status === 'generating_pdf') {
|
|
newStatus = 'processing';
|
|
} else if (progress.status === 'completed') {
|
|
newStatus = 'completed';
|
|
} else if (progress.status === 'error' || progress.status === 'failed') {
|
|
newStatus = 'error';
|
|
}
|
|
|
|
setUploadedFiles(prev =>
|
|
prev.map(f =>
|
|
f.id === fileId
|
|
? {
|
|
...f,
|
|
status: newStatus,
|
|
progress: progress.progress || f.progress
|
|
}
|
|
: f
|
|
)
|
|
);
|
|
|
|
// Stop monitoring if completed or error
|
|
if (newStatus === 'completed' || newStatus === 'error') {
|
|
return;
|
|
}
|
|
} else if (response.status === 404) {
|
|
// Document not found, stop monitoring
|
|
console.warn(`Document ${documentId} not found, stopping progress monitoring`);
|
|
return;
|
|
} else if (response.status === 401) {
|
|
// Unauthorized, stop monitoring
|
|
console.warn('Unauthorized access to document progress, stopping monitoring');
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch processing progress:', error);
|
|
// Don't stop monitoring on network errors, just log and continue
|
|
}
|
|
|
|
// Continue monitoring with shorter intervals for better responsiveness
|
|
setTimeout(checkProgress, 3000); // Check every 3 seconds
|
|
};
|
|
|
|
// Start monitoring immediately
|
|
setTimeout(checkProgress, 500); // Start checking after 500ms
|
|
}, [token]);
|
|
|
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
onDrop,
|
|
accept: {
|
|
'application/pdf': ['.pdf'],
|
|
},
|
|
multiple: true,
|
|
maxSize: 50 * 1024 * 1024, // 50MB
|
|
});
|
|
|
|
const removeFile = (fileId: string) => {
|
|
// Cancel the upload if it's still in progress
|
|
const controller = abortControllers.current.get(fileId);
|
|
if (controller) {
|
|
controller.abort();
|
|
abortControllers.current.delete(fileId);
|
|
}
|
|
|
|
setUploadedFiles(prev => prev.filter(f => f.id !== fileId));
|
|
};
|
|
|
|
const formatFileSize = (bytes: number) => {
|
|
if (bytes === 0) return '0 Bytes';
|
|
const k = 1024;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
};
|
|
|
|
const getStatusIcon = (status: UploadedFile['status']) => {
|
|
switch (status) {
|
|
case 'uploading':
|
|
return <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-600" />;
|
|
case 'uploaded':
|
|
return <CheckCircle className="h-4 w-4 text-success-500" />;
|
|
case 'processing':
|
|
return <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-accent-500" />;
|
|
case 'completed':
|
|
return <CheckCircle className="h-4 w-4 text-success-500" />;
|
|
case 'error':
|
|
return <AlertCircle className="h-4 w-4 text-error-500" />;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const getStatusText = (status: UploadedFile['status'], error?: string, storageError?: boolean) => {
|
|
switch (status) {
|
|
case 'uploading':
|
|
return 'Uploading to Cloud Storage...';
|
|
case 'uploaded':
|
|
return 'Uploaded to Cloud Storage ✓';
|
|
case 'processing':
|
|
return 'Processing with Document AI + Optimized Agentic RAG...';
|
|
case 'completed':
|
|
return 'Completed ✓ (PDF automatically deleted)';
|
|
case 'error':
|
|
if (error === 'Upload cancelled') return 'Cancelled';
|
|
if (storageError) return 'Cloud Storage Error';
|
|
return 'Error';
|
|
default:
|
|
return '';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Processing Information */}
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
<div className="flex items-center">
|
|
<CheckCircle className="h-5 w-5 text-blue-600 mr-2" />
|
|
<div>
|
|
<h3 className="text-sm font-medium text-blue-800">Document AI + Optimized Agentic RAG Processing</h3>
|
|
<p className="text-sm text-blue-700 mt-1">
|
|
All documents are automatically processed using Google Document AI for extraction and our advanced optimized agentic RAG system for analysis,
|
|
including intelligent chunking, vectorization, and multi-agent CIM review. PDFs are automatically deleted after processing.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Upload Area */}
|
|
<div
|
|
{...getRootProps()}
|
|
className={cn(
|
|
'border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors duration-200',
|
|
isDragActive
|
|
? 'border-primary-500 bg-primary-50'
|
|
: 'border-gray-300 hover:border-primary-400'
|
|
)}
|
|
>
|
|
<input {...getInputProps()} />
|
|
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
|
<h3 className="text-lg font-medium text-primary-800 mb-2">
|
|
{isDragActive ? 'Drop files here' : 'Upload Documents'}
|
|
</h3>
|
|
<p className="text-sm text-gray-600 mb-4">
|
|
Drag and drop PDF files here, or click to browse
|
|
</p>
|
|
<p className="text-xs text-gray-500">
|
|
Maximum file size: 50MB • Supported format: PDF • Stored securely in Cloud Storage • Automatic Document AI + Optimized Agentic RAG Processing • PDFs deleted after processing
|
|
</p>
|
|
</div>
|
|
|
|
{/* Upload Cancellation Warning */}
|
|
{isUploading && (
|
|
<div className="bg-warning-50 border border-warning-200 rounded-lg p-4">
|
|
<div className="flex items-center">
|
|
<AlertCircle className="h-5 w-5 text-warning-600 mr-2" />
|
|
<div>
|
|
<h4 className="text-sm font-medium text-warning-800">Upload in Progress</h4>
|
|
<p className="text-sm text-warning-700 mt-1">
|
|
Please don't navigate away from this page while files are uploading.
|
|
Once files show "Uploaded ✓", you can safely navigate away - processing will continue in the background.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Upload Complete Success Message */}
|
|
{!isUploading && uploadedFiles.some(f => f.status === 'uploaded') && (
|
|
<div className="bg-success-50 border border-success-200 rounded-lg p-4">
|
|
<div className="flex items-center">
|
|
<CheckCircle className="h-5 w-5 text-success-600 mr-2" />
|
|
<div>
|
|
<h4 className="text-sm font-medium text-success-800">Upload Complete</h4>
|
|
<p className="text-sm text-success-700 mt-1">
|
|
Files have been uploaded successfully! You can now navigate away from this page.
|
|
Processing will continue in the background using Document AI + Optimized Agentic RAG. This can take several minutes. PDFs will be automatically deleted after processing to save costs.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Uploaded Files List */}
|
|
{uploadedFiles.length > 0 && (
|
|
<div className="space-y-3">
|
|
<h4 className="text-sm font-medium text-primary-800">Uploaded Files</h4>
|
|
<div className="space-y-2">
|
|
{uploadedFiles.map((file) => (
|
|
<div
|
|
key={file.id}
|
|
className="flex items-center justify-between p-3 bg-white border border-gray-200 rounded-lg"
|
|
>
|
|
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
|
<FileText className="h-5 w-5 text-gray-400 flex-shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-gray-900 truncate">
|
|
{file.name}
|
|
</p>
|
|
<p className="text-xs text-gray-500">
|
|
{formatFileSize(file.size)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-3">
|
|
{/* Progress Bar */}
|
|
{(file.status === 'uploading' || file.status === 'processing') && (
|
|
<div className="w-24 bg-gray-200 rounded-full h-2">
|
|
<div
|
|
className={`h-2 rounded-full transition-all duration-300 ${
|
|
file.status === 'uploading' ? 'bg-blue-600' : 'bg-orange-600'
|
|
}`}
|
|
style={{ width: `${file.progress}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Status */}
|
|
<div className="flex items-center space-x-1">
|
|
{getStatusIcon(file.status)}
|
|
<span className="text-xs text-gray-600">
|
|
{getStatusText(file.status, file.error, file.storageError)}
|
|
</span>
|
|
{/* Firebase Storage indicator */}
|
|
{file.storageType === 'firebase' && (
|
|
<Cloud className="h-3 w-3 text-blue-500" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Remove Button */}
|
|
<button
|
|
onClick={() => removeFile(file.id)}
|
|
className="text-gray-400 hover:text-gray-600 transition-colors"
|
|
disabled={file.status === 'uploading' || file.status === 'processing'}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default DocumentUpload; |