Files
cim_summary/frontend/src/components/DocumentUpload.tsx
admin d4b1658929 feat: wire Analytics tab to real data, add markdown rendering, fix UI labels
- 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>
2026-02-25 11:27:46 -05:00

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;