Files
cim_summary/frontend/src/App.tsx
Jon a4f393d4ac Fix financial table rendering and enhance PDF generation
- Fix [object Object] issue in PDF financial table rendering
- Enhance Key Questions and Investment Thesis sections with detailed prompts
- Update year labeling in Overview tab (FY0 -> LTM)
- Improve PDF generation service with page pooling and caching
- Add better error handling for financial data structure
- Increase textarea rows for detailed content sections
- Update API configuration for Cloud Run deployment
- Add comprehensive styling improvements to PDF output
2025-08-01 20:33:16 -04:00

746 lines
28 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 UploadMonitoringDashboard from './components/UploadMonitoringDashboard';
import LogoutButton from './components/LogoutButton';
import { documentService, GCSErrorHandler, GCSError } from './services/documentService';
// import { debugAuth, testAPIAuth } from './utils/authDebug';
import {
Home,
Upload,
FileText,
BarChart3,
Plus,
Search,
TrendingUp,
Activity
} from 'lucide-react';
import { cn } from './utils/cn';
// Dashboard component
const Dashboard: React.FC = () => {
const { user, token } = useAuth();
const [documents, setDocuments] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [viewingDocument, setViewingDocument] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [activeTab, setActiveTab] = useState<'overview' | 'documents' | 'upload' | 'analytics' | 'monitoring'>('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);
console.log('Fetching documents with token:', token ? 'Token available' : 'No token');
console.log('User state:', user);
console.log('Token preview:', token ? `${token.substring(0, 20)}...` : 'No token');
if (!token) {
console.error('No authentication token available');
return;
}
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/documents`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
console.log('API response status:', response.status);
if (response.ok) {
const result = await response.json();
// The API returns documents wrapped in a documents property
const documentsArray = result.documents || result;
if (Array.isArray(documentsArray)) {
// Transform backend data to frontend format
const transformedDocs = documentsArray.map((doc: any) => {
// Extract company name from analysis data if available
let displayName = doc.name || doc.originalName || 'Unknown';
if (doc.analysis_data && doc.analysis_data.dealOverview && doc.analysis_data.dealOverview.targetCompanyName) {
displayName = doc.analysis_data.dealOverview.targetCompanyName;
}
return {
id: doc.id,
name: displayName,
originalName: doc.originalName || doc.name || 'Unknown',
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);
}
} else {
console.error('API request failed:', response.status, response.statusText);
const errorText = await response.text();
console.error('Error response body:', errorText);
}
} catch (error) {
console.error('Failed to fetch documents:', error);
} finally {
setLoading(false);
}
}, [user?.name, user?.email, token]);
// 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 {
if (!token) {
console.error('No authentication token available');
return false;
}
const response = await fetch(`https://us-central1-cim-summarizer.cloudfunctions.net/api/documents/${documentId}/progress`, {
headers: {
'Authorization': `Bearer ${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
}, [token]);
// 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 = (documentId: string) => {
console.log('Upload completed:', documentId);
// Add the new document to the list with a "processing" status
// Since we only have the ID, we'll create a minimal document object
const newDocument = {
id: documentId,
status: 'processing',
name: 'Processing...',
originalName: 'Processing...',
uploadedAt: new Date().toISOString(),
fileSize: 0,
user_id: user?.id || '',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
setDocuments(prev => [...prev, newDocument]);
};
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);
// Handle GCS-specific errors
if (GCSErrorHandler.isGCSError(error)) {
const gcsError = error as GCSError;
alert(`Download failed: ${GCSErrorHandler.getErrorMessage(gcsError)}`);
} else {
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);
};
// Debug functions (commented out for now)
// const handleDebugAuth = async () => {
// await debugAuth();
// };
// const handleTestAPIAuth = async () => {
// await testAPIAuth();
// };
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="button" className="bg-error-500 hover:bg-error-600 text-white" />
</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>
<button
onClick={() => setActiveTab('monitoring')}
className={cn(
'flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200',
activeTab === 'monitoring'
? 'border-primary-600 text-primary-700'
: 'border-transparent text-gray-500 hover:text-primary-600 hover:border-primary-300'
)}
>
<Activity className="h-4 w-4 mr-2" />
Monitoring
</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 />
)}
{activeTab === 'monitoring' && (
<UploadMonitoringDashboard />
)}
</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;