Files
cim_summary/frontend/src/App.tsx

727 lines
27 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import LoginForm from './components/LoginForm';
import ProtectedRoute from './components/ProtectedRoute';
import DocumentUpload from './components/DocumentUpload';
import DocumentList from './components/DocumentList';
import DocumentViewer from './components/DocumentViewer';
import Analytics from './components/Analytics';
import LogoutButton from './components/LogoutButton';
import { documentService } from './services/documentService';
import {
Home,
Upload,
FileText,
BarChart3,
Plus,
Search,
TrendingUp
} from 'lucide-react';
import { cn } from './utils/cn';
// import { parseCIMReviewData } from './utils/parseCIMData';
// Mock data for demonstration
// const mockDocuments = [
// {
// id: '1',
// name: 'Sample CIM Document 1',
// originalName: 'sample_cim_1.pdf',
// status: 'completed' as const,
// uploadedAt: '2024-01-15T10:30:00Z',
// processedAt: '2024-01-15T10:35:00Z',
// uploadedBy: 'John Doe',
// fileSize: 2048576,
// pageCount: 25,
// summary: 'This is a sample CIM document for demonstration purposes.',
// },
// {
// id: '2',
// name: 'Sample CIM Document 2',
// originalName: 'sample_cim_2.pdf',
// status: 'processing' as const,
// uploadedAt: '2024-01-15T11:00:00Z',
// uploadedBy: 'Jane Smith',
// fileSize: 1536000,
// pageCount: 18,
// },
// ];
// const mockExtractedData = {
// companyName: 'Sample Company Inc.',
// industry: 'Technology',
// revenue: '$50M',
// ebitda: '$8M',
// employees: '150',
// founded: '2010',
// location: 'San Francisco, CA',
// summary: 'A technology company focused on innovative solutions.',
// keyMetrics: {
// 'Revenue Growth': '25%',
// 'EBITDA Margin': '16%',
// 'Employee Count': '150',
// },
// financials: {
// revenue: ['$40M', '$45M', '$50M'],
// ebitda: ['$6M', '$7M', '$8M'],
// margins: ['15%', '15.6%', '16%'],
// },
// risks: [
// 'Market competition',
// 'Technology disruption',
// 'Talent retention',
// ],
// opportunities: [
// 'Market expansion',
// 'Product diversification',
// 'Strategic partnerships',
// ],
// };
// Dashboard component
const Dashboard: React.FC = () => {
const { user } = useAuth();
const [documents, setDocuments] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [viewingDocument, setViewingDocument] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [activeTab, setActiveTab] = useState<'overview' | 'documents' | 'upload' | 'analytics'>('overview');
// Map backend status to frontend status
const mapBackendStatus = (backendStatus: string): string => {
switch (backendStatus) {
case 'uploaded':
return 'uploaded';
case 'extracting_text':
case 'processing_llm':
case 'generating_pdf':
return 'processing';
case 'completed':
return 'completed';
case 'failed':
return 'error';
default:
return 'pending';
}
};
// Fetch documents from API
const fetchDocuments = useCallback(async () => {
try {
setLoading(true);
const response = await fetch('/api/documents', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
'Content-Type': 'application/json',
},
});
if (response.ok) {
const result = await response.json();
// The API returns an array directly, not wrapped in success/data
if (Array.isArray(result)) {
// Transform backend data to frontend format
const transformedDocs = result.map((doc: any) => ({
id: doc.id,
name: doc.name || doc.originalName,
originalName: doc.originalName,
status: mapBackendStatus(doc.status),
uploadedAt: doc.uploadedAt,
processedAt: doc.processedAt,
uploadedBy: user?.name || user?.email || 'Unknown',
fileSize: parseInt(doc.fileSize) || 0,
summary: doc.summary,
error: doc.error,
analysisData: doc.extractedData, // Include the enhanced BPCP CIM Review Template data
}));
setDocuments(transformedDocs);
}
}
} catch (error) {
console.error('Failed to fetch documents:', error);
} finally {
setLoading(false);
}
}, [user?.name, user?.email]);
// Poll for status updates on documents that are being processed
const pollDocumentStatus = useCallback(async (documentId: string) => {
// Guard against undefined or null document IDs
if (!documentId || documentId === 'undefined' || documentId === 'null') {
console.warn('Attempted to poll for document with invalid ID:', documentId);
return false; // Stop polling
}
try {
const response = await fetch(`/api/documents/${documentId}/progress`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
'Content-Type': 'application/json',
},
});
if (response.ok) {
const result = await response.json();
if (result.success) {
const progress = result.data;
// Update the document status based on progress
setDocuments(prev => prev.map(doc => {
if (doc.id === documentId) {
let newStatus = doc.status;
if (progress.status === 'processing') {
newStatus = 'processing';
} else if (progress.status === 'completed') {
newStatus = 'completed';
} else if (progress.status === 'error') {
newStatus = 'error';
}
return {
...doc,
status: newStatus,
progress: progress.progress || 0,
message: progress.message || doc.message,
};
}
return doc;
}));
// Stop polling if completed or error
if (progress.status === 'completed' || progress.status === 'error') {
// Refresh the documents list to get the latest data including summary
fetchDocuments();
return false; // Stop polling
}
}
}
} catch (error) {
console.error('Failed to fetch document progress:', error);
}
return true; // Continue polling
}, []);
// Set up polling for documents that are being processed or uploaded (might be processing)
useEffect(() => {
const processingDocuments = documents.filter(doc =>
(doc.status === 'processing' || doc.status === 'uploaded' || doc.status === 'extracting_text') && doc.id
);
if (processingDocuments.length === 0) {
return;
}
const pollIntervals: NodeJS.Timeout[] = [];
processingDocuments.forEach(doc => {
// Skip if document ID is undefined or null
if (!doc.id) {
console.warn('Skipping polling for document with undefined ID:', doc);
return;
}
const interval = setInterval(async () => {
const shouldContinue = await pollDocumentStatus(doc.id);
if (!shouldContinue) {
clearInterval(interval);
}
}, 3000); // Poll every 3 seconds
pollIntervals.push(interval);
});
// Cleanup intervals on unmount or when documents change
return () => {
pollIntervals.forEach(interval => clearInterval(interval));
};
}, [documents, pollDocumentStatus]);
// Load documents on component mount and refresh periodically
React.useEffect(() => {
fetchDocuments();
// Refresh documents every 30 seconds to catch any updates
const refreshInterval = setInterval(() => {
fetchDocuments();
}, 30000);
return () => clearInterval(refreshInterval);
}, [fetchDocuments]);
const handleUploadComplete = (fileId: string) => {
console.log('Upload completed:', fileId);
// Refresh documents list after upload
fetchDocuments();
};
const handleUploadError = (error: string) => {
console.error('Upload error:', error);
// In a real app, this would show an error notification
};
const handleViewDocument = (documentId: string) => {
setViewingDocument(documentId);
};
const handleDownloadDocument = async (documentId: string) => {
try {
console.log('Downloading document:', documentId);
const blob = await documentService.downloadDocument(documentId);
// Create download link
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `document-${documentId}.pdf`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
console.log('Download completed');
} catch (error) {
console.error('Download failed:', error);
alert('Failed to download document. Please try again.');
}
};
const handleDeleteDocument = async (documentId: string) => {
// Show confirmation dialog
const confirmed = window.confirm('Are you sure you want to delete this document? This action cannot be undone.');
if (!confirmed) {
return;
}
try {
// Call the backend API to delete the document
await documentService.deleteDocument(documentId);
// Remove from local state
setDocuments(prev => prev.filter(doc => doc.id !== documentId));
// Show success message
alert('Document deleted successfully');
} catch (error) {
console.error('Failed to delete document:', error);
alert('Failed to delete document. Please try again.');
}
};
const handleRetryProcessing = (documentId: string) => {
console.log('Retrying processing for document:', documentId);
// In a real app, this would retry the processing
};
const handleBackFromViewer = () => {
setViewingDocument(null);
};
const filteredDocuments = documents.filter(doc =>
doc.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
doc.originalName.toLowerCase().includes(searchTerm.toLowerCase())
);
const stats = {
totalDocuments: documents.length,
completedDocuments: documents.filter(d => d.status === 'completed').length,
processingDocuments: documents.filter(d => d.status === 'processing').length,
errorDocuments: documents.filter(d => d.status === 'error').length,
};
if (viewingDocument) {
const document = documents.find(d => d.id === viewingDocument);
if (!document) return null;
// The new analysisData is already in the BPCP template format
const cimReviewData = document.analysisData;
const extractedData = cimReviewData ? {
companyName: cimReviewData?.dealOverview?.targetCompanyName || 'Not specified',
industry: cimReviewData?.dealOverview?.industrySector || 'Not specified',
// For revenue and ebitda, we'll take the most recent value from the financial summary.
revenue: cimReviewData?.financialSummary?.financials?.ltm?.revenue || 'N/A',
ebitda: cimReviewData?.financialSummary?.financials?.ltm?.ebitda || 'N/A',
employees: cimReviewData?.dealOverview?.employeeCount || 'Not specified',
founded: 'Not specified', // This field is not in the new schema
location: cimReviewData?.dealOverview?.geography || 'Not specified',
summary: cimReviewData?.preliminaryInvestmentThesis?.keyAttractions || 'No summary available',
keyMetrics: {
'Transaction Type': cimReviewData?.dealOverview?.transactionType || 'Not specified',
'Deal Source': cimReviewData?.dealOverview?.dealSource || 'Not specified',
},
financials: {
revenue: [
cimReviewData?.financialSummary?.financials?.fy3?.revenue || 'N/A',
cimReviewData?.financialSummary?.financials?.fy2?.revenue || 'N/A',
cimReviewData?.financialSummary?.financials?.fy1?.revenue || 'N/A',
cimReviewData?.financialSummary?.financials?.ltm?.revenue || 'N/A',
],
ebitda: [
cimReviewData?.financialSummary?.financials?.fy3?.ebitda || 'N/A',
cimReviewData?.financialSummary?.financials?.fy2?.ebitda || 'N/A',
cimReviewData?.financialSummary?.financials?.fy1?.ebitda || 'N/A',
cimReviewData?.financialSummary?.financials?.ltm?.ebitda || 'N/A',
],
margins: [
cimReviewData?.financialSummary?.financials?.fy3?.ebitdaMargin || 'N/A',
cimReviewData?.financialSummary?.financials?.fy2?.ebitdaMargin || 'N/A',
cimReviewData?.financialSummary?.financials?.fy1?.ebitdaMargin || 'N/A',
cimReviewData?.financialSummary?.financials?.ltm?.ebitdaMargin || 'N/A',
],
},
risks: [cimReviewData?.preliminaryInvestmentThesis?.potentialRisks || 'Not specified'],
opportunities: [cimReviewData?.preliminaryInvestmentThesis?.valueCreationLevers || 'Not specified'],
} : undefined;
return (
<DocumentViewer
documentId={document.id}
documentName={document.name}
extractedData={extractedData}
cimReviewData={cimReviewData}
onBack={handleBackFromViewer}
onDownload={() => handleDownloadDocument(document.id)}
onShare={() => console.log('Share document:', document.id)}
/>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* Navigation */}
<nav className="bg-primary-600 shadow-soft border-b border-primary-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
<h1 className="text-xl font-semibold text-white">
CIM Document Processor
</h1>
</div>
<div className="flex items-center space-x-4">
<span className="text-sm text-white">
Welcome, {user?.name || user?.email}
</span>
<LogoutButton variant="link" />
</div>
</div>
</div>
</nav>
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
{/* Tab Navigation */}
<div className="bg-white shadow-soft border-b border-gray-200 mb-6">
<div className="px-4 sm:px-6 lg:px-8">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('overview')}
className={cn(
'flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200',
activeTab === 'overview'
? 'border-primary-600 text-primary-700'
: 'border-transparent text-gray-500 hover:text-primary-600 hover:border-primary-300'
)}
>
<Home className="h-4 w-4 mr-2" />
Overview
</button>
<button
onClick={() => setActiveTab('documents')}
className={cn(
'flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200',
activeTab === 'documents'
? 'border-primary-600 text-primary-700'
: 'border-transparent text-gray-500 hover:text-primary-600 hover:border-primary-300'
)}
>
<FileText className="h-4 w-4 mr-2" />
Documents
</button>
<button
onClick={() => setActiveTab('upload')}
className={cn(
'flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200',
activeTab === 'upload'
? 'border-primary-600 text-primary-700'
: 'border-transparent text-gray-500 hover:text-primary-600 hover:border-primary-300'
)}
>
<Upload className="h-4 w-4 mr-2" />
Upload
</button>
<button
onClick={() => setActiveTab('analytics')}
className={cn(
'flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200',
activeTab === 'analytics'
? 'border-primary-600 text-primary-700'
: 'border-transparent text-gray-500 hover:text-primary-600 hover:border-primary-300'
)}
>
<TrendingUp className="h-4 w-4 mr-2" />
Analytics
</button>
</nav>
</div>
</div>
{/* Content */}
<div className="px-4 sm:px-0">
{activeTab === 'overview' && (
<div className="space-y-6">
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="bg-white overflow-hidden shadow-soft rounded-lg border border-gray-100">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<FileText className="h-6 w-6 text-primary-500" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-600 truncate">
Total Documents
</dt>
<dd className="text-lg font-semibold text-primary-800">
{stats.totalDocuments}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow-soft rounded-lg border border-gray-100">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<BarChart3 className="h-6 w-6 text-success-500" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-600 truncate">
Completed
</dt>
<dd className="text-lg font-semibold text-primary-800">
{stats.completedDocuments}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow-soft rounded-lg border border-gray-100">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-accent-500" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-600 truncate">
Processing
</dt>
<dd className="text-lg font-semibold text-primary-800">
{stats.processingDocuments}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow-soft rounded-lg border border-gray-100">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="h-6 w-6 text-error-500"></div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-600 truncate">
Errors
</dt>
<dd className="text-lg font-semibold text-primary-800">
{stats.errorDocuments}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/* Recent Documents */}
<div className="bg-white shadow-soft rounded-lg border border-gray-100">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-primary-800 mb-4">
Recent Documents
</h3>
<DocumentList
documents={documents.slice(0, 3)}
onViewDocument={handleViewDocument}
onDownloadDocument={handleDownloadDocument}
onDeleteDocument={handleDeleteDocument}
onRetryProcessing={handleRetryProcessing}
onRefresh={fetchDocuments}
/>
</div>
</div>
</div>
)}
{activeTab === 'documents' && (
<div className="space-y-6">
{/* Search and Actions */}
<div className="bg-white shadow-soft rounded-lg border border-gray-100 p-6">
<div className="flex items-center justify-between">
<div className="flex-1 max-w-lg">
<label htmlFor="search" className="sr-only">
Search documents
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
id="search"
name="search"
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary-500 focus:border-primary-500 sm:text-sm transition-colors duration-200"
placeholder="Search documents..."
type="search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
<div className="flex space-x-3">
<button
onClick={fetchDocuments}
disabled={loading}
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-soft text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 transition-colors duration-200"
>
<div className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`}>🔄</div>
Refresh
</button>
<button
onClick={() => setActiveTab('upload')}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-soft text-white bg-accent-500 hover:bg-accent-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-500 transition-colors duration-200"
>
<Plus className="h-4 w-4 mr-2" />
Upload New
</button>
</div>
</div>
</div>
{/* Documents List */}
{loading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-accent-500 mx-auto mb-4"></div>
<p className="text-gray-600">Loading documents...</p>
</div>
) : (
<DocumentList
documents={filteredDocuments}
onViewDocument={handleViewDocument}
onDownloadDocument={handleDownloadDocument}
onDeleteDocument={handleDeleteDocument}
onRetryProcessing={handleRetryProcessing}
onRefresh={fetchDocuments}
/>
)}
</div>
)}
{activeTab === 'upload' && (
<div className="bg-white shadow-soft rounded-lg border border-gray-100 p-6">
<h3 className="text-lg leading-6 font-medium text-primary-800 mb-6">
Upload CIM Documents
</h3>
<DocumentUpload
onUploadComplete={handleUploadComplete}
onUploadError={handleUploadError}
/>
</div>
)}
{activeTab === 'analytics' && (
<Analytics />
)}
</div>
</div>
</div>
);
};
// Login page component
const LoginPage: React.FC = () => {
const { user } = useAuth();
// Redirect to dashboard if already authenticated
if (user) {
return <Navigate to="/dashboard" replace />;
}
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
CIM Document Processor
</h2>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<LoginForm />
</div>
</div>
);
};
// Unauthorized page component
const UnauthorizedPage: React.FC = () => {
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<div className="text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Access Denied
</h2>
<p className="text-gray-600 mb-6">
You don't have permission to access this resource.
</p>
<LogoutButton />
</div>
</div>
</div>
</div>
);
};
const App: React.FC = () => {
return (
<AuthProvider>
<Router>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/unauthorized" element={<UnauthorizedPage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Router>
</AuthProvider>
);
};
export default App;