Add comprehensive CIM processing features and UI improvements
- Add new database migrations for analysis data and job tracking - Implement enhanced document processing service with LLM integration - Add processing progress and queue status components - Create testing guides and utility scripts for CIM processing - Update frontend components for better user experience - Add environment configuration and backup files - Implement job queue service and upload progress tracking
This commit is contained in:
@@ -5,6 +5,9 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CIM Document Processor</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useState } from 'react';
|
||||
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 LogoutButton from './components/LogoutButton';
|
||||
import DocumentUpload from './components/DocumentUpload';
|
||||
import DocumentList from './components/DocumentList';
|
||||
import DocumentViewer from './components/DocumentViewer';
|
||||
import LogoutButton from './components/LogoutButton';
|
||||
import { documentService } from './services/documentService';
|
||||
import {
|
||||
Home,
|
||||
Upload,
|
||||
@@ -16,85 +17,240 @@ import {
|
||||
Search
|
||||
} from 'lucide-react';
|
||||
import { cn } from './utils/cn';
|
||||
import { parseCIMReviewData } from './utils/parseCIMData';
|
||||
|
||||
// Mock data for demonstration
|
||||
const mockDocuments = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'TechCorp CIM Review',
|
||||
originalName: 'TechCorp_CIM_2024.pdf',
|
||||
status: 'completed' as const,
|
||||
uploadedAt: '2024-01-15T10:30:00Z',
|
||||
processedAt: '2024-01-15T10:35:00Z',
|
||||
uploadedBy: 'John Doe',
|
||||
fileSize: 2048576,
|
||||
pageCount: 45,
|
||||
summary: 'Technology company specializing in cloud infrastructure solutions with strong recurring revenue model.',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Manufacturing Solutions Inc.',
|
||||
originalName: 'Manufacturing_Solutions_CIM.pdf',
|
||||
status: 'processing' as const,
|
||||
uploadedAt: '2024-01-14T14:20:00Z',
|
||||
uploadedBy: 'Jane Smith',
|
||||
fileSize: 3145728,
|
||||
pageCount: 67,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Retail Chain Analysis',
|
||||
originalName: 'Retail_Chain_CIM.docx',
|
||||
status: 'error' as const,
|
||||
uploadedAt: '2024-01-13T09:15:00Z',
|
||||
uploadedBy: 'Mike Johnson',
|
||||
fileSize: 1048576,
|
||||
error: 'Document processing failed due to unsupported format',
|
||||
},
|
||||
];
|
||||
// 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: 'TechCorp Solutions',
|
||||
industry: 'Technology - Cloud Infrastructure',
|
||||
revenue: '$45.2M',
|
||||
ebitda: '$8.7M',
|
||||
employees: '125',
|
||||
founded: '2018',
|
||||
location: 'Austin, TX',
|
||||
summary: 'TechCorp is a leading provider of cloud infrastructure solutions for mid-market enterprises. The company has demonstrated strong growth with a 35% CAGR over the past three years, driven by increasing cloud adoption and their proprietary automation platform.',
|
||||
keyMetrics: {
|
||||
'Recurring Revenue %': '85%',
|
||||
'Customer Retention': '94%',
|
||||
'Gross Margin': '72%',
|
||||
},
|
||||
financials: {
|
||||
revenue: ['$25.1M', '$33.8M', '$45.2M'],
|
||||
ebitda: ['$3.2M', '$5.1M', '$8.7M'],
|
||||
margins: ['12.7%', '15.1%', '19.2%'],
|
||||
},
|
||||
risks: [
|
||||
'High customer concentration (Top 5 customers = 45% of revenue)',
|
||||
'Dependence on key technical personnel',
|
||||
'Rapidly evolving competitive landscape',
|
||||
],
|
||||
opportunities: [
|
||||
'Expansion into adjacent markets (security, compliance)',
|
||||
'International market penetration',
|
||||
'Product portfolio expansion through M&A',
|
||||
],
|
||||
};
|
||||
// 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(mockDocuments);
|
||||
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'>('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();
|
||||
if (result.success) {
|
||||
// Transform backend data to frontend format
|
||||
const transformedDocs = result.data.map((doc: any) => ({
|
||||
id: doc.id,
|
||||
name: doc.original_file_name,
|
||||
originalName: doc.original_file_name,
|
||||
status: mapBackendStatus(doc.status),
|
||||
uploadedAt: doc.uploaded_at,
|
||||
processedAt: doc.processing_completed_at,
|
||||
uploadedBy: user?.name || user?.email || 'Unknown',
|
||||
fileSize: parseInt(doc.file_size) || 0,
|
||||
summary: doc.generated_summary,
|
||||
error: doc.error_message,
|
||||
analysisData: doc.analysis_data, // 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 === 'pending') && 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);
|
||||
// In a real app, this would trigger document processing
|
||||
// Refresh documents list after upload
|
||||
fetchDocuments();
|
||||
};
|
||||
|
||||
const handleUploadError = (error: string) => {
|
||||
@@ -106,13 +262,48 @@ const Dashboard: React.FC = () => {
|
||||
setViewingDocument(documentId);
|
||||
};
|
||||
|
||||
const handleDownloadDocument = (documentId: string) => {
|
||||
console.log('Downloading document:', documentId);
|
||||
// In a real app, this would trigger a download
|
||||
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 = (documentId: string) => {
|
||||
setDocuments(prev => prev.filter(doc => doc.id !== documentId));
|
||||
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) => {
|
||||
@@ -140,11 +331,35 @@ const Dashboard: React.FC = () => {
|
||||
const document = documents.find(d => d.id === viewingDocument);
|
||||
if (!document) return null;
|
||||
|
||||
// Parse the generated summary into structured CIM review data
|
||||
const cimReviewData = document.generated_summary ? parseCIMReviewData(document.generated_summary) : {};
|
||||
|
||||
// Transform analysis_data to the format expected by DocumentViewer
|
||||
const extractedData = document.analysisData ? {
|
||||
companyName: document.analysisData.companyName || document.analysisData.targetCompanyName,
|
||||
industry: document.analysisData.industry || document.analysisData.industrySector,
|
||||
revenue: document.analysisData.revenue || 'N/A',
|
||||
ebitda: document.analysisData.ebitda || 'N/A',
|
||||
employees: document.analysisData.employees || 'N/A',
|
||||
founded: document.analysisData.founded || 'N/A',
|
||||
location: document.analysisData.location || document.analysisData.geography,
|
||||
summary: document.generated_summary || document.summary,
|
||||
keyMetrics: document.analysisData.keyMetrics || {},
|
||||
financials: document.analysisData.financials || {
|
||||
revenue: [],
|
||||
ebitda: [],
|
||||
margins: []
|
||||
},
|
||||
risks: document.analysisData.risks || [],
|
||||
opportunities: document.analysisData.opportunities || []
|
||||
} : undefined;
|
||||
|
||||
return (
|
||||
<DocumentViewer
|
||||
documentId={document.id}
|
||||
documentName={document.name}
|
||||
extractedData={mockExtractedData}
|
||||
extractedData={extractedData}
|
||||
cimReviewData={cimReviewData}
|
||||
onBack={handleBackFromViewer}
|
||||
onDownload={() => handleDownloadDocument(document.id)}
|
||||
onShare={() => console.log('Share document:', document.id)}
|
||||
@@ -155,16 +370,16 @@ const Dashboard: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Navigation */}
|
||||
<nav className="bg-white shadow-sm border-b">
|
||||
<nav className="bg-white shadow-soft border-b border-gray-200">
|
||||
<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-gray-900">
|
||||
<h1 className="text-xl font-semibold text-primary-800">
|
||||
CIM Document Processor
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-700">
|
||||
<span className="text-sm text-gray-600">
|
||||
Welcome, {user?.name || user?.email}
|
||||
</span>
|
||||
<LogoutButton variant="link" />
|
||||
@@ -172,19 +387,19 @@ const Dashboard: React.FC = () => {
|
||||
</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-sm border-b border-gray-200 mb-6">
|
||||
<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',
|
||||
'flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200',
|
||||
activeTab === 'overview'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
? '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" />
|
||||
@@ -193,10 +408,10 @@ const Dashboard: React.FC = () => {
|
||||
<button
|
||||
onClick={() => setActiveTab('documents')}
|
||||
className={cn(
|
||||
'flex items-center py-4 px-1 border-b-2 font-medium text-sm',
|
||||
'flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200',
|
||||
activeTab === 'documents'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
? '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" />
|
||||
@@ -205,10 +420,10 @@ const Dashboard: React.FC = () => {
|
||||
<button
|
||||
onClick={() => setActiveTab('upload')}
|
||||
className={cn(
|
||||
'flex items-center py-4 px-1 border-b-2 font-medium text-sm',
|
||||
'flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200',
|
||||
activeTab === 'upload'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
? '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" />
|
||||
@@ -224,18 +439,18 @@ const Dashboard: React.FC = () => {
|
||||
<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 rounded-lg">
|
||||
<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-gray-400" />
|
||||
<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-500 truncate">
|
||||
<dt className="text-sm font-medium text-gray-600 truncate">
|
||||
Total Documents
|
||||
</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
<dd className="text-lg font-semibold text-primary-800">
|
||||
{stats.totalDocuments}
|
||||
</dd>
|
||||
</dl>
|
||||
@@ -244,18 +459,18 @@ const Dashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<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-green-400" />
|
||||
<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-500 truncate">
|
||||
<dt className="text-sm font-medium text-gray-600 truncate">
|
||||
Completed
|
||||
</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
<dd className="text-lg font-semibold text-primary-800">
|
||||
{stats.completedDocuments}
|
||||
</dd>
|
||||
</dl>
|
||||
@@ -264,18 +479,18 @@ const Dashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<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-blue-600" />
|
||||
<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-500 truncate">
|
||||
<dt className="text-sm font-medium text-gray-600 truncate">
|
||||
Processing
|
||||
</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
<dd className="text-lg font-semibold text-primary-800">
|
||||
{stats.processingDocuments}
|
||||
</dd>
|
||||
</dl>
|
||||
@@ -284,18 +499,18 @@ const Dashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<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-red-400">⚠️</div>
|
||||
<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-500 truncate">
|
||||
<dt className="text-sm font-medium text-gray-600 truncate">
|
||||
Errors
|
||||
</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
<dd className="text-lg font-semibold text-primary-800">
|
||||
{stats.errorDocuments}
|
||||
</dd>
|
||||
</dl>
|
||||
@@ -306,9 +521,9 @@ const Dashboard: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Recent Documents */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<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-gray-900 mb-4">
|
||||
<h3 className="text-lg leading-6 font-medium text-primary-800 mb-4">
|
||||
Recent Documents
|
||||
</h3>
|
||||
<DocumentList
|
||||
@@ -317,6 +532,7 @@ const Dashboard: React.FC = () => {
|
||||
onDownloadDocument={handleDownloadDocument}
|
||||
onDeleteDocument={handleDeleteDocument}
|
||||
onRetryProcessing={handleRetryProcessing}
|
||||
onRefresh={fetchDocuments}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -326,7 +542,7 @@ const Dashboard: React.FC = () => {
|
||||
{activeTab === 'documents' && (
|
||||
<div className="space-y-6">
|
||||
{/* Search and Actions */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<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">
|
||||
@@ -339,7 +555,7 @@ const Dashboard: React.FC = () => {
|
||||
<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-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
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}
|
||||
@@ -347,37 +563,55 @@ const Dashboard: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setActiveTab('upload')}
|
||||
className="ml-3 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Upload New
|
||||
</button>
|
||||
<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-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors duration-200"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Upload New
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Documents List */}
|
||||
<DocumentList
|
||||
documents={filteredDocuments}
|
||||
onViewDocument={handleViewDocument}
|
||||
onDownloadDocument={handleDownloadDocument}
|
||||
onDeleteDocument={handleDeleteDocument}
|
||||
onRetryProcessing={handleRetryProcessing}
|
||||
/>
|
||||
{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 rounded-lg p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-6">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,74 +1,95 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Save, Download } from 'lucide-react';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
interface CIMReviewData {
|
||||
// Deal Overview
|
||||
targetCompanyName: string;
|
||||
industrySector: string;
|
||||
geography: string;
|
||||
dealSource: string;
|
||||
transactionType: string;
|
||||
dateCIMReceived: string;
|
||||
dateReviewed: string;
|
||||
reviewers: string;
|
||||
cimPageCount: string;
|
||||
statedReasonForSale: string;
|
||||
dealOverview: {
|
||||
targetCompanyName: string;
|
||||
industrySector: string;
|
||||
geography: string;
|
||||
dealSource: string;
|
||||
transactionType: string;
|
||||
dateCIMReceived: string;
|
||||
dateReviewed: string;
|
||||
reviewers: string;
|
||||
cimPageCount: string;
|
||||
statedReasonForSale: string;
|
||||
};
|
||||
|
||||
// Business Description
|
||||
coreOperationsSummary: string;
|
||||
keyProductsServices: string;
|
||||
uniqueValueProposition: string;
|
||||
keyCustomerSegments: string;
|
||||
customerConcentrationRisk: string;
|
||||
typicalContractLength: string;
|
||||
keySupplierOverview: string;
|
||||
businessDescription: {
|
||||
coreOperationsSummary: string;
|
||||
keyProductsServices: string;
|
||||
uniqueValueProposition: string;
|
||||
customerBaseOverview: {
|
||||
keyCustomerSegments: string;
|
||||
customerConcentrationRisk: string;
|
||||
typicalContractLength: string;
|
||||
};
|
||||
keySupplierOverview: {
|
||||
dependenceConcentrationRisk: string;
|
||||
};
|
||||
};
|
||||
|
||||
// Market & Industry Analysis
|
||||
estimatedMarketSize: string;
|
||||
estimatedMarketGrowthRate: string;
|
||||
keyIndustryTrends: string;
|
||||
keyCompetitors: string;
|
||||
targetMarketPosition: string;
|
||||
basisOfCompetition: string;
|
||||
barriersToEntry: string;
|
||||
marketIndustryAnalysis: {
|
||||
estimatedMarketSize: string;
|
||||
estimatedMarketGrowthRate: string;
|
||||
keyIndustryTrends: string;
|
||||
competitiveLandscape: {
|
||||
keyCompetitors: string;
|
||||
targetMarketPosition: string;
|
||||
basisOfCompetition: string;
|
||||
};
|
||||
barriersToEntry: string;
|
||||
};
|
||||
|
||||
// Financial Summary
|
||||
financials: {
|
||||
fy3: { revenue: string; revenueGrowth: string; grossProfit: string; grossMargin: string; ebitda: string; ebitdaMargin: string };
|
||||
fy2: { revenue: string; revenueGrowth: string; grossProfit: string; grossMargin: string; ebitda: string; ebitdaMargin: string };
|
||||
fy1: { revenue: string; revenueGrowth: string; grossProfit: string; grossMargin: string; ebitda: string; ebitdaMargin: string };
|
||||
ltm: { revenue: string; revenueGrowth: string; grossProfit: string; grossMargin: string; ebitda: string; ebitdaMargin: string };
|
||||
financialSummary: {
|
||||
financials: {
|
||||
fy3: { revenue: string; revenueGrowth: string; grossProfit: string; grossMargin: string; ebitda: string; ebitdaMargin: string };
|
||||
fy2: { revenue: string; revenueGrowth: string; grossProfit: string; grossMargin: string; ebitda: string; ebitdaMargin: string };
|
||||
fy1: { revenue: string; revenueGrowth: string; grossProfit: string; grossMargin: string; ebitda: string; ebitdaMargin: string };
|
||||
ltm: { revenue: string; revenueGrowth: string; grossProfit: string; grossMargin: string; ebitda: string; ebitdaMargin: string };
|
||||
};
|
||||
qualityOfEarnings: string;
|
||||
revenueGrowthDrivers: string;
|
||||
marginStabilityAnalysis: string;
|
||||
capitalExpenditures: string;
|
||||
workingCapitalIntensity: string;
|
||||
freeCashFlowQuality: string;
|
||||
};
|
||||
qualityOfEarnings: string;
|
||||
revenueGrowthDrivers: string;
|
||||
marginStabilityAnalysis: string;
|
||||
capitalExpenditures: string;
|
||||
workingCapitalIntensity: string;
|
||||
freeCashFlowQuality: string;
|
||||
|
||||
// Management Team Overview
|
||||
keyLeaders: string;
|
||||
managementQualityAssessment: string;
|
||||
postTransactionIntentions: string;
|
||||
organizationalStructure: string;
|
||||
managementTeamOverview: {
|
||||
keyLeaders: string;
|
||||
managementQualityAssessment: string;
|
||||
postTransactionIntentions: string;
|
||||
organizationalStructure: string;
|
||||
};
|
||||
|
||||
// Preliminary Investment Thesis
|
||||
keyAttractions: string;
|
||||
potentialRisks: string;
|
||||
valueCreationLevers: string;
|
||||
alignmentWithFundStrategy: string;
|
||||
preliminaryInvestmentThesis: {
|
||||
keyAttractions: string;
|
||||
potentialRisks: string;
|
||||
valueCreationLevers: string;
|
||||
alignmentWithFundStrategy: string;
|
||||
};
|
||||
|
||||
// Key Questions & Next Steps
|
||||
criticalQuestions: string;
|
||||
missingInformation: string;
|
||||
preliminaryRecommendation: string;
|
||||
rationaleForRecommendation: string;
|
||||
proposedNextSteps: string;
|
||||
keyQuestionsNextSteps: {
|
||||
criticalQuestions: string;
|
||||
missingInformation: string;
|
||||
preliminaryRecommendation: string;
|
||||
rationaleForRecommendation: string;
|
||||
proposedNextSteps: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CIMReviewTemplateProps {
|
||||
initialData?: Partial<CIMReviewData>;
|
||||
cimReviewData?: any;
|
||||
onSave?: (data: CIMReviewData) => void;
|
||||
onExport?: (data: CIMReviewData) => void;
|
||||
readOnly?: boolean;
|
||||
@@ -76,89 +97,123 @@ interface CIMReviewTemplateProps {
|
||||
|
||||
const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
|
||||
initialData = {},
|
||||
cimReviewData,
|
||||
onSave,
|
||||
onExport,
|
||||
readOnly = false,
|
||||
}) => {
|
||||
const [data, setData] = useState<CIMReviewData>({
|
||||
// Deal Overview
|
||||
targetCompanyName: initialData.targetCompanyName || '',
|
||||
industrySector: initialData.industrySector || '',
|
||||
geography: initialData.geography || '',
|
||||
dealSource: initialData.dealSource || '',
|
||||
transactionType: initialData.transactionType || '',
|
||||
dateCIMReceived: initialData.dateCIMReceived || '',
|
||||
dateReviewed: initialData.dateReviewed || '',
|
||||
reviewers: initialData.reviewers || '',
|
||||
cimPageCount: initialData.cimPageCount || '',
|
||||
statedReasonForSale: initialData.statedReasonForSale || '',
|
||||
dealOverview: initialData.dealOverview || {
|
||||
targetCompanyName: '',
|
||||
industrySector: '',
|
||||
geography: '',
|
||||
dealSource: '',
|
||||
transactionType: '',
|
||||
dateCIMReceived: '',
|
||||
dateReviewed: '',
|
||||
reviewers: '',
|
||||
cimPageCount: '',
|
||||
statedReasonForSale: '',
|
||||
},
|
||||
|
||||
// Business Description
|
||||
coreOperationsSummary: initialData.coreOperationsSummary || '',
|
||||
keyProductsServices: initialData.keyProductsServices || '',
|
||||
uniqueValueProposition: initialData.uniqueValueProposition || '',
|
||||
keyCustomerSegments: initialData.keyCustomerSegments || '',
|
||||
customerConcentrationRisk: initialData.customerConcentrationRisk || '',
|
||||
typicalContractLength: initialData.typicalContractLength || '',
|
||||
keySupplierOverview: initialData.keySupplierOverview || '',
|
||||
businessDescription: initialData.businessDescription || {
|
||||
coreOperationsSummary: '',
|
||||
keyProductsServices: '',
|
||||
uniqueValueProposition: '',
|
||||
customerBaseOverview: {
|
||||
keyCustomerSegments: '',
|
||||
customerConcentrationRisk: '',
|
||||
typicalContractLength: '',
|
||||
},
|
||||
keySupplierOverview: {
|
||||
dependenceConcentrationRisk: '',
|
||||
},
|
||||
},
|
||||
|
||||
// Market & Industry Analysis
|
||||
estimatedMarketSize: initialData.estimatedMarketSize || '',
|
||||
estimatedMarketGrowthRate: initialData.estimatedMarketGrowthRate || '',
|
||||
keyIndustryTrends: initialData.keyIndustryTrends || '',
|
||||
keyCompetitors: initialData.keyCompetitors || '',
|
||||
targetMarketPosition: initialData.targetMarketPosition || '',
|
||||
basisOfCompetition: initialData.basisOfCompetition || '',
|
||||
barriersToEntry: initialData.barriersToEntry || '',
|
||||
marketIndustryAnalysis: initialData.marketIndustryAnalysis || {
|
||||
estimatedMarketSize: '',
|
||||
estimatedMarketGrowthRate: '',
|
||||
keyIndustryTrends: '',
|
||||
competitiveLandscape: {
|
||||
keyCompetitors: '',
|
||||
targetMarketPosition: '',
|
||||
basisOfCompetition: '',
|
||||
},
|
||||
barriersToEntry: '',
|
||||
},
|
||||
|
||||
// Financial Summary
|
||||
financials: initialData.financials || {
|
||||
fy3: { revenue: '', revenueGrowth: '', grossProfit: '', grossMargin: '', ebitda: '', ebitdaMargin: '' },
|
||||
fy2: { revenue: '', revenueGrowth: '', grossProfit: '', grossMargin: '', ebitda: '', ebitdaMargin: '' },
|
||||
fy1: { revenue: '', revenueGrowth: '', grossProfit: '', grossMargin: '', ebitda: '', ebitdaMargin: '' },
|
||||
ltm: { revenue: '', revenueGrowth: '', grossProfit: '', grossMargin: '', ebitda: '', ebitdaMargin: '' },
|
||||
financialSummary: initialData.financialSummary || {
|
||||
financials: {
|
||||
fy3: { revenue: '', revenueGrowth: '', grossProfit: '', grossMargin: '', ebitda: '', ebitdaMargin: '' },
|
||||
fy2: { revenue: '', revenueGrowth: '', grossProfit: '', grossMargin: '', ebitda: '', ebitdaMargin: '' },
|
||||
fy1: { revenue: '', revenueGrowth: '', grossProfit: '', grossMargin: '', ebitda: '', ebitdaMargin: '' },
|
||||
ltm: { revenue: '', revenueGrowth: '', grossProfit: '', grossMargin: '', ebitda: '', ebitdaMargin: '' },
|
||||
},
|
||||
qualityOfEarnings: '',
|
||||
revenueGrowthDrivers: '',
|
||||
marginStabilityAnalysis: '',
|
||||
capitalExpenditures: '',
|
||||
workingCapitalIntensity: '',
|
||||
freeCashFlowQuality: '',
|
||||
},
|
||||
qualityOfEarnings: initialData.qualityOfEarnings || '',
|
||||
revenueGrowthDrivers: initialData.revenueGrowthDrivers || '',
|
||||
marginStabilityAnalysis: initialData.marginStabilityAnalysis || '',
|
||||
capitalExpenditures: initialData.capitalExpenditures || '',
|
||||
workingCapitalIntensity: initialData.workingCapitalIntensity || '',
|
||||
freeCashFlowQuality: initialData.freeCashFlowQuality || '',
|
||||
|
||||
// Management Team Overview
|
||||
keyLeaders: initialData.keyLeaders || '',
|
||||
managementQualityAssessment: initialData.managementQualityAssessment || '',
|
||||
postTransactionIntentions: initialData.postTransactionIntentions || '',
|
||||
organizationalStructure: initialData.organizationalStructure || '',
|
||||
managementTeamOverview: initialData.managementTeamOverview || {
|
||||
keyLeaders: '',
|
||||
managementQualityAssessment: '',
|
||||
postTransactionIntentions: '',
|
||||
organizationalStructure: '',
|
||||
},
|
||||
|
||||
// Preliminary Investment Thesis
|
||||
keyAttractions: initialData.keyAttractions || '',
|
||||
potentialRisks: initialData.potentialRisks || '',
|
||||
valueCreationLevers: initialData.valueCreationLevers || '',
|
||||
alignmentWithFundStrategy: initialData.alignmentWithFundStrategy || '',
|
||||
preliminaryInvestmentThesis: initialData.preliminaryInvestmentThesis || {
|
||||
keyAttractions: '',
|
||||
potentialRisks: '',
|
||||
valueCreationLevers: '',
|
||||
alignmentWithFundStrategy: '',
|
||||
},
|
||||
|
||||
// Key Questions & Next Steps
|
||||
criticalQuestions: initialData.criticalQuestions || '',
|
||||
missingInformation: initialData.missingInformation || '',
|
||||
preliminaryRecommendation: initialData.preliminaryRecommendation || '',
|
||||
rationaleForRecommendation: initialData.rationaleForRecommendation || '',
|
||||
proposedNextSteps: initialData.proposedNextSteps || '',
|
||||
keyQuestionsNextSteps: initialData.keyQuestionsNextSteps || {
|
||||
criticalQuestions: '',
|
||||
missingInformation: '',
|
||||
preliminaryRecommendation: '',
|
||||
rationaleForRecommendation: '',
|
||||
proposedNextSteps: '',
|
||||
},
|
||||
});
|
||||
|
||||
const [activeSection, setActiveSection] = useState<string>('deal-overview');
|
||||
|
||||
// Merge cimReviewData with existing data when it changes
|
||||
useEffect(() => {
|
||||
if (cimReviewData && Object.keys(cimReviewData).length > 0) {
|
||||
setData(prev => ({
|
||||
...prev,
|
||||
...cimReviewData
|
||||
}));
|
||||
}
|
||||
}, [cimReviewData]);
|
||||
|
||||
const updateData = (field: keyof CIMReviewData, value: any) => {
|
||||
setData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const updateFinancials = (period: keyof CIMReviewData['financials'], field: string, value: string) => {
|
||||
const updateFinancials = (period: keyof CIMReviewData['financialSummary']['financials'], field: string, value: string) => {
|
||||
setData(prev => ({
|
||||
...prev,
|
||||
financials: {
|
||||
...prev.financials,
|
||||
[period]: {
|
||||
...prev.financials[period],
|
||||
[field]: value,
|
||||
financialSummary: {
|
||||
...prev.financialSummary,
|
||||
financials: {
|
||||
...prev.financialSummary.financials,
|
||||
[period]: {
|
||||
...prev.financialSummary.financials[period],
|
||||
[field]: value,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -189,13 +244,13 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
|
||||
placeholder?: string,
|
||||
rows?: number
|
||||
) => (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
{type === 'textarea' ? (
|
||||
<textarea
|
||||
value={data[field] as string}
|
||||
value={getFieldValue(data, field) || ''}
|
||||
onChange={(e) => updateData(field, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={rows || 3}
|
||||
@@ -205,7 +260,7 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
|
||||
) : type === 'date' ? (
|
||||
<input
|
||||
type="date"
|
||||
value={data[field] as string}
|
||||
value={getFieldValue(data, field) || ''}
|
||||
onChange={(e) => updateData(field, e.target.value)}
|
||||
disabled={readOnly}
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm disabled:bg-gray-50 disabled:text-gray-500"
|
||||
@@ -213,7 +268,7 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={data[field] as string}
|
||||
value={getFieldValue(data, field) || ''}
|
||||
onChange={(e) => updateData(field, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={readOnly}
|
||||
@@ -223,6 +278,23 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
// Helper function to safely get field values
|
||||
const getFieldValue = (obj: any, field: keyof CIMReviewData): string => {
|
||||
const value = obj[field];
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
// For nested objects, try to find a string value
|
||||
for (const key in value) {
|
||||
if (typeof value[key] === 'string') {
|
||||
return value[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const renderFinancialTable = () => (
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-lg font-medium text-gray-900">Key Historical Financials</h4>
|
||||
@@ -256,7 +328,7 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
|
||||
<td key={period} className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="text"
|
||||
value={data.financials[period].revenue}
|
||||
value={data.financialSummary.financials[period].revenue}
|
||||
onChange={(e) => updateFinancials(period, 'revenue', e.target.value)}
|
||||
placeholder="$0"
|
||||
disabled={readOnly}
|
||||
@@ -273,7 +345,7 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
|
||||
<td key={period} className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="text"
|
||||
value={data.financials[period].revenueGrowth}
|
||||
value={data.financialSummary.financials[period].revenueGrowth}
|
||||
onChange={(e) => updateFinancials(period, 'revenueGrowth', e.target.value)}
|
||||
placeholder="0%"
|
||||
disabled={readOnly}
|
||||
@@ -290,7 +362,7 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
|
||||
<td key={period} className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="text"
|
||||
value={data.financials[period].ebitda}
|
||||
value={data.financialSummary.financials[period].ebitda}
|
||||
onChange={(e) => updateFinancials(period, 'ebitda', e.target.value)}
|
||||
placeholder="$0"
|
||||
disabled={readOnly}
|
||||
@@ -307,7 +379,7 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
|
||||
<td key={period} className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="text"
|
||||
value={data.financials[period].ebitdaMargin}
|
||||
value={data.financialSummary.financials[period].ebitdaMargin}
|
||||
onChange={(e) => updateFinancials(period, 'ebitdaMargin', e.target.value)}
|
||||
placeholder="0%"
|
||||
disabled={readOnly}
|
||||
@@ -328,39 +400,39 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{renderField('Target Company Name', 'targetCompanyName')}
|
||||
{renderField('Industry/Sector', 'industrySector')}
|
||||
{renderField('Geography (HQ & Key Operations)', 'geography')}
|
||||
{renderField('Deal Source', 'dealSource')}
|
||||
{renderField('Transaction Type', 'transactionType')}
|
||||
{renderField('Date CIM Received', 'dateCIMReceived', 'date')}
|
||||
{renderField('Date Reviewed', 'dateReviewed', 'date')}
|
||||
{renderField('Reviewer(s)', 'reviewers')}
|
||||
{renderField('CIM Page Count', 'cimPageCount')}
|
||||
{renderField('Target Company Name', 'dealOverview')}
|
||||
{renderField('Industry/Sector', 'dealOverview')}
|
||||
{renderField('Geography (HQ & Key Operations)', 'dealOverview')}
|
||||
{renderField('Deal Source', 'dealOverview')}
|
||||
{renderField('Transaction Type', 'dealOverview')}
|
||||
{renderField('Date CIM Received', 'dealOverview', 'date')}
|
||||
{renderField('Date Reviewed', 'dealOverview', 'date')}
|
||||
{renderField('Reviewer(s)', 'dealOverview')}
|
||||
{renderField('CIM Page Count', 'dealOverview')}
|
||||
</div>
|
||||
{renderField('Stated Reason for Sale (if provided)', 'statedReasonForSale', 'textarea', 'Enter the stated reason for sale...', 4)}
|
||||
{renderField('Stated Reason for Sale (if provided)', 'dealOverview', 'textarea', 'Enter the stated reason for sale...', 4)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'business-description':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{renderField('Core Operations Summary (3-5 sentences)', 'coreOperationsSummary', 'textarea', 'Describe the core operations...', 4)}
|
||||
{renderField('Key Products/Services & Revenue Mix (Est. % if available)', 'keyProductsServices', 'textarea', 'List key products/services and revenue mix...', 4)}
|
||||
{renderField('Unique Value Proposition (UVP) / Why Customers Buy', 'uniqueValueProposition', 'textarea', 'Describe the unique value proposition...', 4)}
|
||||
{renderField('Core Operations Summary (3-5 sentences)', 'businessDescription', 'textarea', 'Describe the core operations...', 4)}
|
||||
{renderField('Key Products/Services & Revenue Mix (Est. % if available)', 'businessDescription', 'textarea', 'List key products/services and revenue mix...', 4)}
|
||||
{renderField('Unique Value Proposition (UVP) / Why Customers Buy', 'businessDescription', 'textarea', 'Describe the unique value proposition...', 4)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-lg font-medium text-gray-900">Customer Base Overview</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{renderField('Key Customer Segments/Types', 'keyCustomerSegments')}
|
||||
{renderField('Customer Concentration Risk (Top 5 and/or Top 10 Customers as % Revenue)', 'customerConcentrationRisk')}
|
||||
{renderField('Typical Contract Length / Recurring Revenue %', 'typicalContractLength')}
|
||||
{renderField('Key Customer Segments/Types', 'businessDescription')}
|
||||
{renderField('Customer Concentration Risk (Top 5 and/or Top 10 Customers as % Revenue)', 'businessDescription')}
|
||||
{renderField('Typical Contract Length / Recurring Revenue %', 'businessDescription')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-lg font-medium text-gray-900">Key Supplier Overview (if critical & mentioned)</h4>
|
||||
{renderField('Dependence/Concentration Risk', 'keySupplierOverview', 'textarea', 'Describe supplier dependencies...', 3)}
|
||||
{renderField('Dependence/Concentration Risk', 'businessDescription', 'textarea', 'Describe supplier dependencies...', 3)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -369,21 +441,21 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{renderField('Estimated Market Size (TAM/SAM - if provided)', 'estimatedMarketSize')}
|
||||
{renderField('Estimated Market Growth Rate (% CAGR - Historical & Projected)', 'estimatedMarketGrowthRate')}
|
||||
{renderField('Estimated Market Size (TAM/SAM - if provided)', 'marketIndustryAnalysis')}
|
||||
{renderField('Estimated Market Growth Rate (% CAGR - Historical & Projected)', 'marketIndustryAnalysis')}
|
||||
</div>
|
||||
{renderField('Key Industry Trends & Drivers (Tailwinds/Headwinds)', 'keyIndustryTrends', 'textarea', 'Describe key industry trends...', 4)}
|
||||
{renderField('Key Industry Trends & Drivers (Tailwinds/Headwinds)', 'marketIndustryAnalysis', 'textarea', 'Describe key industry trends...', 4)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-lg font-medium text-gray-900">Competitive Landscape</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{renderField('Key Competitors Identified', 'keyCompetitors')}
|
||||
{renderField('Target\'s Stated Market Position/Rank', 'targetMarketPosition')}
|
||||
{renderField('Basis of Competition', 'basisOfCompetition')}
|
||||
{renderField('Key Competitors Identified', 'marketIndustryAnalysis')}
|
||||
{renderField('Target\'s Stated Market Position/Rank', 'marketIndustryAnalysis')}
|
||||
{renderField('Basis of Competition', 'marketIndustryAnalysis')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderField('Barriers to Entry / Competitive Moat (Stated/Inferred)', 'barriersToEntry', 'textarea', 'Describe barriers to entry...', 4)}
|
||||
{renderField('Barriers to Entry / Competitive Moat (Stated/Inferred)', 'marketIndustryAnalysis', 'textarea', 'Describe barriers to entry...', 4)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -395,12 +467,12 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-lg font-medium text-gray-900">Key Financial Notes & Observations</h4>
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{renderField('Quality of Earnings/Adjustments (Initial Impression)', 'qualityOfEarnings', 'textarea', 'Assess quality of earnings...', 3)}
|
||||
{renderField('Revenue Growth Drivers (Stated)', 'revenueGrowthDrivers', 'textarea', 'Identify revenue growth drivers...', 3)}
|
||||
{renderField('Margin Stability/Trend Analysis', 'marginStabilityAnalysis', 'textarea', 'Analyze margin trends...', 3)}
|
||||
{renderField('Capital Expenditures (Approx. LTM % of Revenue)', 'capitalExpenditures')}
|
||||
{renderField('Working Capital Intensity (Impression)', 'workingCapitalIntensity', 'textarea', 'Assess working capital intensity...', 3)}
|
||||
{renderField('Free Cash Flow (FCF) Proxy Quality (Impression)', 'freeCashFlowQuality', 'textarea', 'Assess FCF quality...', 3)}
|
||||
{renderField('Quality of Earnings/Adjustments (Initial Impression)', 'financialSummary', 'textarea', 'Assess quality of earnings...', 3)}
|
||||
{renderField('Revenue Growth Drivers (Stated)', 'financialSummary', 'textarea', 'Identify revenue growth drivers...', 3)}
|
||||
{renderField('Margin Stability/Trend Analysis', 'financialSummary', 'textarea', 'Analyze margin trends...', 3)}
|
||||
{renderField('Capital Expenditures (Approx. LTM % of Revenue)', 'financialSummary')}
|
||||
{renderField('Working Capital Intensity (Impression)', 'financialSummary', 'textarea', 'Assess working capital intensity...', 3)}
|
||||
{renderField('Free Cash Flow (FCF) Proxy Quality (Impression)', 'financialSummary', 'textarea', 'Assess FCF quality...', 3)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -409,31 +481,31 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
|
||||
case 'management-team':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{renderField('Key Leaders Identified (CEO, CFO, COO, Head of Sales, etc.)', 'keyLeaders', 'textarea', 'List key leaders...', 4)}
|
||||
{renderField('Initial Assessment of Quality/Experience (Based on Bios)', 'managementQualityAssessment', 'textarea', 'Assess management quality...', 4)}
|
||||
{renderField('Management\'s Stated Post-Transaction Role/Intentions (if mentioned)', 'postTransactionIntentions', 'textarea', 'Describe post-transaction intentions...', 4)}
|
||||
{renderField('Organizational Structure Overview (Impression)', 'organizationalStructure', 'textarea', 'Describe organizational structure...', 4)}
|
||||
{renderField('Key Leaders Identified (CEO, CFO, COO, Head of Sales, etc.)', 'managementTeamOverview', 'textarea', 'List key leaders...', 4)}
|
||||
{renderField('Initial Assessment of Quality/Experience (Based on Bios)', 'managementTeamOverview', 'textarea', 'Assess management quality...', 4)}
|
||||
{renderField('Management\'s Stated Post-Transaction Role/Intentions (if mentioned)', 'managementTeamOverview', 'textarea', 'Describe post-transaction intentions...', 4)}
|
||||
{renderField('Organizational Structure Overview (Impression)', 'managementTeamOverview', 'textarea', 'Describe organizational structure...', 4)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'investment-thesis':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{renderField('Key Attractions / Strengths (Why Invest?)', 'keyAttractions', 'textarea', 'List key attractions...', 4)}
|
||||
{renderField('Potential Risks / Concerns (Why Not Invest?)', 'potentialRisks', 'textarea', 'List potential risks...', 4)}
|
||||
{renderField('Initial Value Creation Levers (How PE Adds Value)', 'valueCreationLevers', 'textarea', 'Identify value creation levers...', 4)}
|
||||
{renderField('Alignment with Fund Strategy', 'alignmentWithFundStrategy', 'textarea', 'Assess alignment with BPCP strategy...', 4)}
|
||||
{renderField('Key Attractions / Strengths (Why Invest?)', 'preliminaryInvestmentThesis', 'textarea', 'List key attractions...', 4)}
|
||||
{renderField('Potential Risks / Concerns (Why Not Invest?)', 'preliminaryInvestmentThesis', 'textarea', 'List potential risks...', 4)}
|
||||
{renderField('Initial Value Creation Levers (How PE Adds Value)', 'preliminaryInvestmentThesis', 'textarea', 'Identify value creation levers...', 4)}
|
||||
{renderField('Alignment with Fund Strategy', 'preliminaryInvestmentThesis', 'textarea', 'Assess alignment with BPCP strategy...', 4)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'next-steps':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{renderField('Critical Questions Arising from CIM Review', 'criticalQuestions', 'textarea', 'List critical questions...', 4)}
|
||||
{renderField('Key Missing Information / Areas for Diligence Focus', 'missingInformation', 'textarea', 'Identify missing information...', 4)}
|
||||
{renderField('Preliminary Recommendation', 'preliminaryRecommendation')}
|
||||
{renderField('Rationale for Recommendation (Brief)', 'rationaleForRecommendation', 'textarea', 'Provide rationale...', 4)}
|
||||
{renderField('Proposed Next Steps', 'proposedNextSteps', 'textarea', 'Outline next steps...', 4)}
|
||||
{renderField('Critical Questions Arising from CIM Review', 'keyQuestionsNextSteps', 'textarea', 'List critical questions...', 4)}
|
||||
{renderField('Key Missing Information / Areas for Diligence Focus', 'keyQuestionsNextSteps', 'textarea', 'Identify missing information...', 4)}
|
||||
{renderField('Preliminary Recommendation', 'keyQuestionsNextSteps')}
|
||||
{renderField('Rationale for Recommendation (Brief)', 'keyQuestionsNextSteps', 'textarea', 'Provide rationale...', 4)}
|
||||
{renderField('Proposed Next Steps', 'keyQuestionsNextSteps', 'textarea', 'Outline next steps...', 4)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ interface Document {
|
||||
id: string;
|
||||
name: string;
|
||||
originalName: string;
|
||||
status: 'processing' | 'completed' | 'error' | 'pending';
|
||||
status: 'uploaded' | 'processing' | 'completed' | 'error' | 'pending';
|
||||
uploadedAt: string;
|
||||
processedAt?: string;
|
||||
uploadedBy: string;
|
||||
@@ -25,6 +25,8 @@ interface Document {
|
||||
pageCount?: number;
|
||||
summary?: string;
|
||||
error?: string;
|
||||
progress?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface DocumentListProps {
|
||||
@@ -33,6 +35,7 @@ interface DocumentListProps {
|
||||
onDownloadDocument?: (documentId: string) => void;
|
||||
onDeleteDocument?: (documentId: string) => void;
|
||||
onRetryProcessing?: (documentId: string) => void;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
const DocumentList: React.FC<DocumentListProps> = ({
|
||||
@@ -41,6 +44,7 @@ const DocumentList: React.FC<DocumentListProps> = ({
|
||||
onDownloadDocument,
|
||||
onDeleteDocument,
|
||||
onRetryProcessing,
|
||||
onRefresh,
|
||||
}) => {
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
@@ -63,25 +67,32 @@ const DocumentList: React.FC<DocumentListProps> = ({
|
||||
|
||||
const getStatusIcon = (status: Document['status']) => {
|
||||
switch (status) {
|
||||
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-blue-600" />;
|
||||
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-green-600" />;
|
||||
return <CheckCircle className="h-4 w-4 text-success-500" />;
|
||||
case 'error':
|
||||
return <AlertCircle className="h-4 w-4 text-red-600" />;
|
||||
return <AlertCircle className="h-4 w-4 text-error-500" />;
|
||||
case 'pending':
|
||||
return <Clock className="h-4 w-4 text-yellow-600" />;
|
||||
return <Clock className="h-4 w-4 text-warning-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: Document['status']) => {
|
||||
const getStatusText = (status: Document['status'], progress?: number, message?: string) => {
|
||||
switch (status) {
|
||||
case 'uploaded':
|
||||
return 'Uploaded ✓';
|
||||
case 'processing':
|
||||
return 'Processing';
|
||||
if (progress !== undefined) {
|
||||
return `Processing... ${progress}%`;
|
||||
}
|
||||
return message || 'Processing...';
|
||||
case 'completed':
|
||||
return 'Completed';
|
||||
return 'Completed ✓';
|
||||
case 'error':
|
||||
return 'Error';
|
||||
case 'pending':
|
||||
@@ -93,14 +104,16 @@ const DocumentList: React.FC<DocumentListProps> = ({
|
||||
|
||||
const getStatusColor = (status: Document['status']) => {
|
||||
switch (status) {
|
||||
case 'uploaded':
|
||||
return 'text-success-600 bg-success-50';
|
||||
case 'processing':
|
||||
return 'text-blue-600 bg-blue-50';
|
||||
return 'text-accent-600 bg-accent-50';
|
||||
case 'completed':
|
||||
return 'text-green-600 bg-green-50';
|
||||
return 'text-success-600 bg-success-50';
|
||||
case 'error':
|
||||
return 'text-red-600 bg-red-50';
|
||||
return 'text-error-600 bg-error-50';
|
||||
case 'pending':
|
||||
return 'text-yellow-600 bg-yellow-50';
|
||||
return 'text-warning-600 bg-warning-50';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-50';
|
||||
}
|
||||
@@ -110,7 +123,7 @@ const DocumentList: React.FC<DocumentListProps> = ({
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<FileText className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
<h3 className="text-lg font-medium text-primary-800 mb-2">
|
||||
No documents uploaded yet
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
@@ -123,12 +136,23 @@ const DocumentList: React.FC<DocumentListProps> = ({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
<h3 className="text-lg font-medium text-primary-800">
|
||||
Documents ({documents.length})
|
||||
</h3>
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="inline-flex items-center px-3 py-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<svg className="h-4 w-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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" />
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<div className="bg-white shadow-soft border border-gray-100 overflow-hidden sm:rounded-md">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{documents.map((document) => (
|
||||
<li key={document.id}>
|
||||
@@ -148,7 +172,7 @@ const DocumentList: React.FC<DocumentListProps> = ({
|
||||
)}
|
||||
>
|
||||
{getStatusIcon(document.status)}
|
||||
<span className="ml-1">{getStatusText(document.status)}</span>
|
||||
<span className="ml-1">{getStatusText(document.status, document.progress, document.message)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -167,9 +191,26 @@ const DocumentList: React.FC<DocumentListProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{document.summary && (
|
||||
<p className="mt-2 text-sm text-gray-600 line-clamp-2">
|
||||
{document.summary}
|
||||
{/* Progress bar for processing documents */}
|
||||
{document.status === 'processing' && document.progress !== undefined && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
|
||||
<span>Processing progress</span>
|
||||
<span>{document.progress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-accent-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${document.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show a brief status message instead of the full summary */}
|
||||
{document.status === 'completed' && (
|
||||
<p className="mt-2 text-sm text-success-600">
|
||||
✓ Analysis completed - Click "View" to see detailed CIM review
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useState, useRef, useEffect } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { Upload, FileText, X, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { cn } from '../utils/cn';
|
||||
import { documentService } from '../services/documentService';
|
||||
|
||||
interface UploadedFile {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
status: 'uploading' | 'processing' | 'completed' | 'error';
|
||||
status: 'uploading' | 'uploaded' | 'processing' | 'completed' | 'error';
|
||||
progress: number;
|
||||
error?: string;
|
||||
documentId?: string; // Real document ID from backend
|
||||
}
|
||||
|
||||
interface DocumentUploadProps {
|
||||
@@ -24,6 +26,40 @@ const DocumentUpload: React.FC<DocumentUploadProps> = ({
|
||||
}) => {
|
||||
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);
|
||||
@@ -39,60 +75,158 @@ const DocumentUpload: React.FC<DocumentUploadProps> = ({
|
||||
|
||||
setUploadedFiles(prev => [...prev, ...newFiles]);
|
||||
|
||||
// Simulate file upload and processing
|
||||
for (const file of 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 {
|
||||
// Simulate upload progress
|
||||
for (let i = 0; i <= 100; i += 10) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
// Upload the document with abort controller
|
||||
const document = await documentService.uploadDocument(file, (progress) => {
|
||||
setUploadedFiles(prev =>
|
||||
prev.map(f =>
|
||||
f.id === file.id
|
||||
? { ...f, progress: i, status: i === 100 ? 'processing' : 'uploading' }
|
||||
f.id === uploadedFile.id
|
||||
? { ...f, progress }
|
||||
: f
|
||||
)
|
||||
);
|
||||
}
|
||||
}, abortController.signal);
|
||||
|
||||
// Simulate processing
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Upload completed - update status to "uploaded"
|
||||
setUploadedFiles(prev =>
|
||||
prev.map(f =>
|
||||
f.id === file.id
|
||||
? { ...f, status: 'completed', progress: 100 }
|
||||
f.id === uploadedFile.id
|
||||
? {
|
||||
...f,
|
||||
id: document.id,
|
||||
documentId: document.id,
|
||||
status: 'uploaded',
|
||||
progress: 100
|
||||
}
|
||||
: f
|
||||
)
|
||||
);
|
||||
|
||||
onUploadComplete?.(file.id);
|
||||
// Call the completion callback with the document ID
|
||||
onUploadComplete?.(document.id);
|
||||
|
||||
// Start monitoring processing progress
|
||||
monitorProcessingProgress(document.id, uploadedFile.id);
|
||||
|
||||
} catch (error) {
|
||||
setUploadedFiles(prev =>
|
||||
prev.map(f =>
|
||||
f.id === file.id
|
||||
? { ...f, status: 'error', error: 'Upload failed' }
|
||||
: f
|
||||
)
|
||||
);
|
||||
onUploadError?.('Upload failed');
|
||||
// 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);
|
||||
setUploadedFiles(prev =>
|
||||
prev.map(f =>
|
||||
f.id === uploadedFile.id
|
||||
? { ...f, status: 'error', error: error instanceof Error ? error.message : 'Upload failed' }
|
||||
: f
|
||||
)
|
||||
);
|
||||
onUploadError?.(error instanceof Error ? error.message : 'Upload failed');
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
|
||||
const checkProgress = async () => {
|
||||
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 status based on progress
|
||||
let newStatus: UploadedFile['status'] = 'uploaded';
|
||||
if (progress.status === 'processing') {
|
||||
newStatus = 'processing';
|
||||
} else if (progress.status === 'completed') {
|
||||
newStatus = 'completed';
|
||||
} else if (progress.status === 'error') {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch processing progress:', error);
|
||||
}
|
||||
|
||||
// Continue monitoring
|
||||
setTimeout(() => checkProgress(), 2000);
|
||||
};
|
||||
|
||||
// Start monitoring
|
||||
setTimeout(checkProgress, 1000);
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
'application/msword': ['.doc'],
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
|
||||
},
|
||||
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));
|
||||
};
|
||||
|
||||
@@ -107,27 +241,32 @@ const DocumentUpload: React.FC<DocumentUploadProps> = ({
|
||||
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-blue-600" />;
|
||||
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-green-600" />;
|
||||
return <CheckCircle className="h-4 w-4 text-success-500" />;
|
||||
case 'error':
|
||||
return <AlertCircle className="h-4 w-4 text-red-600" />;
|
||||
return <AlertCircle className="h-4 w-4 text-error-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: UploadedFile['status']) => {
|
||||
const getStatusText = (status: UploadedFile['status'], error?: string) => {
|
||||
switch (status) {
|
||||
case 'uploading':
|
||||
return 'Uploading...';
|
||||
case 'uploaded':
|
||||
return 'Uploaded ✓';
|
||||
case 'processing':
|
||||
return 'Processing...';
|
||||
case 'completed':
|
||||
return 'Completed';
|
||||
return 'Completed ✓';
|
||||
case 'error':
|
||||
return 'Error';
|
||||
return error === 'Upload cancelled' ? 'Cancelled' : 'Error';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@@ -139,30 +278,61 @@ const DocumentUpload: React.FC<DocumentUploadProps> = ({
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
'border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors',
|
||||
'border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors duration-200',
|
||||
isDragActive
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-300 hover:border-gray-400',
|
||||
isUploading && 'pointer-events-none opacity-50'
|
||||
? '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-gray-900 mb-2">
|
||||
{isDragActive ? 'Drop files here' : 'Upload CIM Documents'}
|
||||
<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, DOC, or DOCX files here, or click to select files
|
||||
Drag and drop PDF files here, or click to browse
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Maximum file size: 50MB • Supported formats: PDF, DOC, DOCX
|
||||
Maximum file size: 50MB • Supported format: PDF
|
||||
</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 and you can check the status in the Documents tab.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uploaded Files List */}
|
||||
{uploadedFiles.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-gray-900">Uploaded Files</h4>
|
||||
<h4 className="text-sm font-medium text-primary-800">Uploaded Files</h4>
|
||||
<div className="space-y-2">
|
||||
{uploadedFiles.map((file) => (
|
||||
<div
|
||||
@@ -183,10 +353,12 @@ const DocumentUpload: React.FC<DocumentUploadProps> = ({
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Progress Bar */}
|
||||
{file.status === 'uploading' && (
|
||||
{(file.status === 'uploading' || file.status === 'processing') && (
|
||||
<div className="w-24 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
className={`h-2 rounded-full transition-all duration-300 ${
|
||||
file.status === 'uploading' ? 'bg-blue-600' : 'bg-orange-600'
|
||||
}`}
|
||||
style={{ width: `${file.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -196,7 +368,7 @@ const DocumentUpload: React.FC<DocumentUploadProps> = ({
|
||||
<div className="flex items-center space-x-1">
|
||||
{getStatusIcon(file.status)}
|
||||
<span className="text-xs text-gray-600">
|
||||
{getStatusText(file.status)}
|
||||
{getStatusText(file.status, file.error)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,6 +15,20 @@ import {
|
||||
import { cn } from '../utils/cn';
|
||||
import CIMReviewTemplate from './CIMReviewTemplate';
|
||||
|
||||
// Simple markdown to HTML converter
|
||||
const markdownToHtml = (markdown: string): string => {
|
||||
return markdown
|
||||
.replace(/^### (.*$)/gim, '<h3 class="text-lg font-semibold text-gray-900 mt-4 mb-2">$1</h3>')
|
||||
.replace(/^## (.*$)/gim, '<h2 class="text-xl font-bold text-gray-900 mt-6 mb-3">$1</h2>')
|
||||
.replace(/^# (.*$)/gim, '<h1 class="text-2xl font-bold text-gray-900 mt-8 mb-4">$1</h1>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||
.replace(/`(.*?)`/g, '<code class="bg-gray-100 px-1 py-0.5 rounded text-sm font-mono">$1</code>')
|
||||
.replace(/\n\n/g, '</p><p class="mb-3">')
|
||||
.replace(/^\n?/, '<p class="mb-3">')
|
||||
.replace(/\n?$/, '</p>');
|
||||
};
|
||||
|
||||
interface ExtractedData {
|
||||
companyName?: string;
|
||||
industry?: string;
|
||||
@@ -38,6 +52,7 @@ interface DocumentViewerProps {
|
||||
documentId: string;
|
||||
documentName: string;
|
||||
extractedData?: ExtractedData;
|
||||
cimReviewData?: any;
|
||||
onBack?: () => void;
|
||||
onDownload?: () => void;
|
||||
onShare?: () => void;
|
||||
@@ -47,6 +62,7 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
|
||||
documentId,
|
||||
documentName,
|
||||
extractedData,
|
||||
cimReviewData,
|
||||
onBack,
|
||||
onDownload,
|
||||
onShare,
|
||||
@@ -151,8 +167,21 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
|
||||
{/* Company Summary */}
|
||||
{extractedData?.summary && (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Company Summary</h3>
|
||||
<p className="text-gray-700 leading-relaxed">{extractedData.summary}</p>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Document Analysis</h3>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<FileText className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h4 className="text-sm font-medium text-blue-900">Structured CIM Review Available</h4>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
This document has been analyzed and structured into a comprehensive CIM review template.
|
||||
Switch to the "Template" tab to view the detailed analysis in a structured format.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -247,13 +276,32 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
|
||||
|
||||
const renderRawData = () => (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Raw Extracted Data</h3>
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Raw Extracted Data</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
This tab shows the raw JSON data extracted from the document during processing.
|
||||
It includes all the structured information that was parsed from the CIM document,
|
||||
including financial metrics, company details, and analysis results.
|
||||
</p>
|
||||
</div>
|
||||
<pre className="bg-gray-50 rounded-lg p-4 overflow-x-auto text-sm">
|
||||
<code>{JSON.stringify(extractedData, null, 2)}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderTemplateInfo = () => (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<h4 className="text-sm font-medium text-blue-900 mb-2">CIM Review Analysis</h4>
|
||||
<p className="text-sm text-blue-700">
|
||||
This tab displays the AI-generated analysis of your CIM document in a structured format.
|
||||
The analysis has been organized into sections like Deal Overview, Financial Summary,
|
||||
Management Team, and Investment Thesis. You can review, edit, and save this structured
|
||||
analysis for your investment review process.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
@@ -304,14 +352,14 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
|
||||
<div className="px-4 py-6 sm:px-6 lg:px-8">
|
||||
{activeTab === 'overview' && renderOverview()}
|
||||
{activeTab === 'template' && (
|
||||
<CIMReviewTemplate
|
||||
initialData={{
|
||||
targetCompanyName: extractedData?.companyName || '',
|
||||
industrySector: extractedData?.industry || '',
|
||||
// Add more mappings as needed
|
||||
}}
|
||||
readOnly={false}
|
||||
/>
|
||||
<>
|
||||
{renderTemplateInfo()}
|
||||
<CIMReviewTemplate
|
||||
initialData={cimReviewData}
|
||||
cimReviewData={cimReviewData}
|
||||
readOnly={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{activeTab === 'raw' && renderRawData()}
|
||||
</div>
|
||||
|
||||
@@ -57,9 +57,9 @@ export const LoginForm: React.FC<LoginFormProps> = ({ onSuccess }) => {
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto">
|
||||
<div className="bg-white shadow-lg rounded-lg p-8">
|
||||
<div className="bg-white shadow-soft rounded-lg border border-gray-100 p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Sign In</h1>
|
||||
<h1 className="text-2xl font-bold text-primary-800">Sign In</h1>
|
||||
<p className="text-gray-600 mt-2">Access your CIM Document Processor</p>
|
||||
</div>
|
||||
|
||||
@@ -78,14 +78,14 @@ export const LoginForm: React.FC<LoginFormProps> = ({ onSuccess }) => {
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500",
|
||||
formErrors.email ? "border-red-300" : "border-gray-300"
|
||||
"w-full px-3 py-2 border rounded-md shadow-soft placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors duration-200",
|
||||
formErrors.email ? "border-error-300" : "border-gray-300"
|
||||
)}
|
||||
placeholder="Enter your email"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{formErrors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">{formErrors.email}</p>
|
||||
<p className="mt-1 text-sm text-error-600">{formErrors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -104,8 +104,8 @@ export const LoginForm: React.FC<LoginFormProps> = ({ onSuccess }) => {
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 pr-10 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500",
|
||||
formErrors.password ? "border-red-300" : "border-gray-300"
|
||||
"w-full px-3 py-2 pr-10 border rounded-md shadow-soft placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors duration-200",
|
||||
formErrors.password ? "border-error-300" : "border-gray-300"
|
||||
)}
|
||||
placeholder="Enter your password"
|
||||
disabled={isLoading}
|
||||
@@ -124,14 +124,14 @@ export const LoginForm: React.FC<LoginFormProps> = ({ onSuccess }) => {
|
||||
</button>
|
||||
</div>
|
||||
{formErrors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">{formErrors.password}</p>
|
||||
<p className="mt-1 text-sm text-error-600">{formErrors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Global Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-3">
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
<div className="bg-error-50 border border-error-200 rounded-md p-3">
|
||||
<p className="text-sm text-error-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -140,11 +140,11 @@ export const LoginForm: React.FC<LoginFormProps> = ({ onSuccess }) => {
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
"w-full flex justify-center items-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white",
|
||||
"focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500",
|
||||
"w-full flex justify-center items-center py-2 px-4 border border-transparent rounded-md shadow-soft text-sm font-medium text-white transition-colors duration-200",
|
||||
"focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500",
|
||||
isLoading
|
||||
? "bg-gray-400 cursor-not-allowed"
|
||||
: "bg-blue-600 hover:bg-blue-700"
|
||||
: "bg-primary-600 hover:bg-primary-700"
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -38,21 +38,21 @@ export const LogoutButton: React.FC<LogoutButtonProps> = ({
|
||||
if (showConfirmDialog) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-sm mx-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Confirm Logout</h3>
|
||||
<div className="bg-white shadow-soft rounded-lg border border-gray-100 p-6 max-w-sm mx-4">
|
||||
<h3 className="text-lg font-medium text-primary-800 mb-4">Confirm Logout</h3>
|
||||
<p className="text-gray-600 mb-6">Are you sure you want to sign out?</p>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
disabled={isLoading}
|
||||
className="flex-1 bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50"
|
||||
className="flex-1 bg-error-600 text-white py-2 px-4 rounded-md hover:bg-error-700 focus:outline-none focus:ring-2 focus:ring-error-500 disabled:opacity-50 transition-colors duration-200"
|
||||
>
|
||||
{isLoading ? 'Signing out...' : 'Sign Out'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
className="flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-md hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
className="flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-md hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-500 transition-colors duration-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -63,8 +63,8 @@ export const LogoutButton: React.FC<LogoutButtonProps> = ({
|
||||
}
|
||||
|
||||
const baseClasses = variant === 'button'
|
||||
? "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50"
|
||||
: "inline-flex items-center text-sm text-gray-700 hover:text-red-600 focus:outline-none focus:underline";
|
||||
? "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-error-600 hover:bg-error-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-error-500 disabled:opacity-50 transition-colors duration-200"
|
||||
: "inline-flex items-center text-sm text-gray-700 hover:text-error-600 focus:outline-none focus:underline transition-colors duration-200";
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
270
frontend/src/components/ProcessingProgress.tsx
Normal file
270
frontend/src/components/ProcessingProgress.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { CheckCircle, AlertCircle, Clock, FileText, TrendingUp, Database, Save } from 'lucide-react';
|
||||
|
||||
interface ProcessingProgressProps {
|
||||
documentId: string;
|
||||
onComplete?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
interface ProgressData {
|
||||
documentId: string;
|
||||
jobId: string;
|
||||
status: 'uploading' | 'processing' | 'completed' | 'error';
|
||||
step: 'validation' | 'text_extraction' | 'analysis' | 'summary_generation' | 'storage';
|
||||
progress: number;
|
||||
message: string;
|
||||
startTime: string;
|
||||
estimatedTimeRemaining?: number;
|
||||
currentChunk?: number;
|
||||
totalChunks?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const ProcessingProgress: React.FC<ProcessingProgressProps> = ({
|
||||
documentId,
|
||||
onComplete,
|
||||
onError,
|
||||
}) => {
|
||||
const [progress, setProgress] = useState<ProgressData | null>(null);
|
||||
const [isPolling, setIsPolling] = useState(true);
|
||||
|
||||
const stepIcons = {
|
||||
validation: <CheckCircle className="h-4 w-4" />,
|
||||
text_extraction: <FileText className="h-4 w-4" />,
|
||||
analysis: <TrendingUp className="h-4 w-4" />,
|
||||
summary_generation: <FileText className="h-4 w-4" />,
|
||||
storage: <Save className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
const stepNames = {
|
||||
validation: 'Validation',
|
||||
text_extraction: 'Text Extraction',
|
||||
analysis: 'Analysis',
|
||||
summary_generation: 'Summary Generation',
|
||||
storage: 'Storage',
|
||||
};
|
||||
|
||||
const stepColors = {
|
||||
validation: 'text-blue-600',
|
||||
text_extraction: 'text-green-600',
|
||||
analysis: 'text-purple-600',
|
||||
summary_generation: 'text-orange-600',
|
||||
storage: 'text-indigo-600',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Guard against undefined or null document IDs
|
||||
if (!documentId || documentId === 'undefined' || documentId === 'null') {
|
||||
console.warn('ProcessingProgress: Invalid document ID:', documentId);
|
||||
return;
|
||||
}
|
||||
|
||||
const pollProgress = async () => {
|
||||
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) {
|
||||
setProgress(result.data);
|
||||
|
||||
// Handle completion
|
||||
if (result.data.status === 'completed') {
|
||||
setIsPolling(false);
|
||||
onComplete?.();
|
||||
}
|
||||
|
||||
// Handle error
|
||||
if (result.data.status === 'error') {
|
||||
setIsPolling(false);
|
||||
onError?.(result.data.error || 'Processing failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch progress:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Poll every 2 seconds
|
||||
const interval = setInterval(() => {
|
||||
if (isPolling) {
|
||||
pollProgress();
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// Initial poll
|
||||
pollProgress();
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [documentId, isPolling, onComplete, onError]);
|
||||
|
||||
if (!progress) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">Initializing Processing</h3>
|
||||
<p className="text-sm text-gray-600">Setting up document processing...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatTime = (seconds?: number) => {
|
||||
if (!seconds) return '';
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.round(seconds % 60);
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (progress.status) {
|
||||
case 'completed':
|
||||
return <CheckCircle className="h-6 w-6 text-green-600" />;
|
||||
case 'error':
|
||||
return <AlertCircle className="h-6 w-6 text-red-600" />;
|
||||
default:
|
||||
return <Clock className="h-6 w-6 text-blue-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (progress.status) {
|
||||
case 'completed':
|
||||
return 'text-green-600';
|
||||
case 'error':
|
||||
return 'text-red-600';
|
||||
default:
|
||||
return 'text-blue-600';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
{getStatusIcon()}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Document Processing
|
||||
</h3>
|
||||
<p className={`text-sm font-medium ${getStatusColor()}`}>
|
||||
{progress.status === 'completed' ? 'Completed' :
|
||||
progress.status === 'error' ? 'Failed' : 'In Progress'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{progress.estimatedTimeRemaining && (
|
||||
<div className="text-sm text-gray-500">
|
||||
Est. remaining: {formatTime(progress.estimatedTimeRemaining)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-2">
|
||||
<span>{progress.message}</span>
|
||||
<span>{progress.progress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className={`h-3 rounded-full transition-all duration-500 ease-out ${
|
||||
progress.status === 'error' ? 'bg-red-600' : 'bg-blue-600'
|
||||
}`}
|
||||
style={{ width: `${progress.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Step */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<span className={stepColors[progress.step]}>
|
||||
{stepIcons[progress.step]}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{stepNames[progress.step]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 ml-6">{progress.message}</p>
|
||||
</div>
|
||||
|
||||
{/* Chunk Progress (if applicable) */}
|
||||
{progress.currentChunk && progress.totalChunks && (
|
||||
<div className="mb-4 p-3 bg-blue-50 rounded-lg">
|
||||
<div className="flex justify-between text-sm text-blue-700 mb-1">
|
||||
<span>Processing chunks</span>
|
||||
<span>{progress.currentChunk} / {progress.totalChunks}</span>
|
||||
</div>
|
||||
<div className="w-full bg-blue-200 rounded-full h-2">
|
||||
<div
|
||||
className="h-2 bg-blue-600 rounded-full transition-all duration-300"
|
||||
style={{ width: `${(progress.currentChunk / progress.totalChunks) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{progress.error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-600" />
|
||||
<span className="text-sm font-medium text-red-800">Error</span>
|
||||
</div>
|
||||
<p className="text-sm text-red-700 mt-1">{progress.error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Processing Steps Overview */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3">Processing Steps</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(stepNames).map(([step, name]) => {
|
||||
const isCompleted = progress.progress >= getStepProgress(step as keyof typeof stepNames);
|
||||
const isCurrent = progress.step === step;
|
||||
|
||||
return (
|
||||
<div key={step} className="flex items-center space-x-2">
|
||||
<div className={`h-4 w-4 rounded-full border-2 ${
|
||||
isCompleted ? 'bg-green-600 border-green-600' :
|
||||
isCurrent ? 'bg-blue-600 border-blue-600' :
|
||||
'bg-gray-200 border-gray-300'
|
||||
}`}>
|
||||
{isCompleted && <CheckCircle className="h-3 w-3 text-white" />}
|
||||
</div>
|
||||
<span className={`text-sm ${
|
||||
isCompleted ? 'text-green-600 font-medium' :
|
||||
isCurrent ? 'text-blue-600 font-medium' :
|
||||
'text-gray-500'
|
||||
}`}>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to determine step progress
|
||||
const getStepProgress = (step: string): number => {
|
||||
const stepOrder = ['validation', 'text_extraction', 'analysis', 'summary_generation', 'storage'];
|
||||
const stepIndex = stepOrder.indexOf(step);
|
||||
return stepIndex >= 0 ? (stepIndex + 1) * 20 : 0;
|
||||
};
|
||||
|
||||
export default ProcessingProgress;
|
||||
207
frontend/src/components/QueueStatus.tsx
Normal file
207
frontend/src/components/QueueStatus.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Clock, CheckCircle, AlertCircle, PlayCircle, Users, FileText } from 'lucide-react';
|
||||
|
||||
interface QueueStatusProps {
|
||||
refreshTrigger?: number;
|
||||
}
|
||||
|
||||
interface QueueStats {
|
||||
queueLength: number;
|
||||
processingCount: number;
|
||||
totalJobs: number;
|
||||
completedJobs: number;
|
||||
failedJobs: number;
|
||||
}
|
||||
|
||||
interface ProcessingJob {
|
||||
id: string;
|
||||
type: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
createdAt: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
data: {
|
||||
documentId: string;
|
||||
userId: string;
|
||||
};
|
||||
}
|
||||
|
||||
const QueueStatus: React.FC<QueueStatusProps> = ({ refreshTrigger }) => {
|
||||
const [stats, setStats] = useState<QueueStats | null>(null);
|
||||
const [activeJobs, setActiveJobs] = useState<ProcessingJob[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchQueueStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/documents/queue/status', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setStats(result.data.stats);
|
||||
setActiveJobs(result.data.activeJobs || []);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch queue status:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchQueueStatus();
|
||||
|
||||
// Poll every 5 seconds
|
||||
const interval = setInterval(fetchQueueStatus, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshTrigger]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 bg-gray-200 rounded"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-5/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<p className="text-gray-500">Unable to load queue status</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle className="h-4 w-4 text-green-600" />;
|
||||
case 'failed':
|
||||
return <AlertCircle className="h-4 w-4 text-red-600" />;
|
||||
case 'processing':
|
||||
return <PlayCircle className="h-4 w-4 text-blue-600" />;
|
||||
default:
|
||||
return <Clock className="h-4 w-4 text-yellow-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'text-green-600 bg-green-50';
|
||||
case 'failed':
|
||||
return 'text-red-600 bg-red-50';
|
||||
case 'processing':
|
||||
return 'text-blue-600 bg-blue-50';
|
||||
default:
|
||||
return 'text-yellow-600 bg-yellow-50';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Processing Queue</h3>
|
||||
<button
|
||||
onClick={fetchQueueStatus}
|
||||
className="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Queue Statistics */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">{stats.queueLength}</div>
|
||||
<div className="text-sm text-gray-600">Queued</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-orange-600">{stats.processingCount}</div>
|
||||
<div className="text-sm text-gray-600">Processing</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{stats.completedJobs}</div>
|
||||
<div className="text-sm text-gray-600">Completed</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-red-600">{stats.failedJobs}</div>
|
||||
<div className="text-sm text-gray-600">Failed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Jobs */}
|
||||
{activeJobs.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3">Active Jobs</h4>
|
||||
<div className="space-y-2">
|
||||
{activeJobs.map((job) => (
|
||||
<div key={job.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
{getStatusIcon(job.status)}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{job.type === 'document_processing' ? 'Document Processing' : job.type}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
ID: {job.data.documentId.slice(0, 8)}...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(job.status)}`}>
|
||||
{job.status}
|
||||
</span>
|
||||
{job.startedAt && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(job.startedAt).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Queue Health Indicator */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Queue Health</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
{stats.queueLength === 0 && stats.processingCount === 0 ? (
|
||||
<div className="flex items-center space-x-1">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<span className="text-sm text-green-600">Idle</span>
|
||||
</div>
|
||||
) : stats.processingCount > 0 ? (
|
||||
<div className="flex items-center space-x-1">
|
||||
<PlayCircle className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-sm text-blue-600">Active</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Clock className="h-4 w-4 text-yellow-600" />
|
||||
<span className="text-sm text-yellow-600">Pending</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QueueStatus;
|
||||
@@ -1,6 +1,6 @@
|
||||
// Frontend environment configuration
|
||||
export const config = {
|
||||
apiBaseUrl: import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api',
|
||||
apiBaseUrl: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
appName: import.meta.env.VITE_APP_NAME || 'CIM Document Processor',
|
||||
maxFileSize: parseInt(import.meta.env.VITE_MAX_FILE_SIZE || '104857600'), // 100MB
|
||||
allowedFileTypes: (import.meta.env.VITE_ALLOWED_FILE_TYPES || 'application/pdf').split(','),
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
}
|
||||
@@ -26,17 +26,31 @@ class AuthService {
|
||||
async login(credentials: LoginCredentials): Promise<AuthResult> {
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/auth/login`, credentials);
|
||||
const authResult: AuthResult = response.data;
|
||||
const authResult = response.data;
|
||||
|
||||
if (!authResult.success) {
|
||||
throw new Error(authResult.message || 'Login failed');
|
||||
}
|
||||
|
||||
// Extract data from the response structure
|
||||
const { user, tokens } = authResult.data;
|
||||
const accessToken = tokens.accessToken;
|
||||
const refreshToken = tokens.refreshToken;
|
||||
|
||||
// Store token and set auth header
|
||||
this.token = authResult.token;
|
||||
localStorage.setItem('auth_token', authResult.token);
|
||||
localStorage.setItem('refresh_token', authResult.refreshToken);
|
||||
localStorage.setItem('user', JSON.stringify(authResult.user));
|
||||
this.token = accessToken;
|
||||
localStorage.setItem('auth_token', accessToken);
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
|
||||
this.setAuthHeader(authResult.token);
|
||||
this.setAuthHeader(accessToken);
|
||||
|
||||
return authResult;
|
||||
return {
|
||||
user,
|
||||
token: accessToken,
|
||||
refreshToken,
|
||||
expiresIn: tokens.expiresIn
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw new Error(error.response?.data?.message || 'Login failed');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import { authService } from './authService';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000/api';
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
// Create axios instance with auth interceptor
|
||||
const apiClient = axios.create({
|
||||
@@ -121,14 +121,16 @@ class DocumentService {
|
||||
/**
|
||||
* Upload a document for processing
|
||||
*/
|
||||
async uploadDocument(file: File, onProgress?: (progress: number) => void): Promise<Document> {
|
||||
async uploadDocument(file: File, onProgress?: (progress: number) => void, signal?: AbortSignal): Promise<Document> {
|
||||
const formData = new FormData();
|
||||
formData.append('document', file);
|
||||
formData.append('processImmediately', 'true'); // Automatically start processing
|
||||
|
||||
const response = await apiClient.post('/documents/upload', formData, {
|
||||
const response = await apiClient.post('/documents', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
signal, // Add abort signal support
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
|
||||
355
frontend/src/utils/parseCIMData.ts
Normal file
355
frontend/src/utils/parseCIMData.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Parse BPCP CIM Review Template data from generated summary
|
||||
* Converts the markdown-like format into structured data
|
||||
*/
|
||||
export function parseCIMReviewData(generatedSummary: string): any {
|
||||
if (!generatedSummary) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const data: any = {};
|
||||
|
||||
// Parse each section
|
||||
const sections = generatedSummary.split(/\*\*\([A-Z]\)\s+/);
|
||||
|
||||
sections.forEach(section => {
|
||||
if (!section.trim()) return;
|
||||
|
||||
const lines = section.split('\n').filter(line => line.trim());
|
||||
if (lines.length === 0) return;
|
||||
|
||||
const sectionTitle = lines[0].replace(/\*\*/, '').trim();
|
||||
const sectionKey = getSectionKey(sectionTitle);
|
||||
|
||||
if (sectionKey) {
|
||||
data[sectionKey] = parseSection(sectionTitle, lines.slice(1));
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function getSectionKey(sectionTitle: string): string | null {
|
||||
const sectionMap: Record<string, string> = {
|
||||
'Deal Overview': 'dealOverview',
|
||||
'Business Description': 'businessDescription',
|
||||
'Market & Industry Analysis': 'marketIndustryAnalysis',
|
||||
'Financial Summary': 'financialSummary',
|
||||
'Management Team Overview': 'managementTeamOverview',
|
||||
'Preliminary Investment Thesis': 'preliminaryInvestmentThesis',
|
||||
'Key Questions & Next Steps': 'keyQuestionsNextSteps'
|
||||
};
|
||||
|
||||
return sectionMap[sectionTitle] || null;
|
||||
}
|
||||
|
||||
function parseSection(sectionTitle: string, lines: string[]): any {
|
||||
const section: any = {};
|
||||
|
||||
switch (sectionTitle) {
|
||||
case 'Deal Overview':
|
||||
return parseDealOverview(lines);
|
||||
case 'Business Description':
|
||||
return parseBusinessDescription(lines);
|
||||
case 'Market & Industry Analysis':
|
||||
return parseMarketIndustryAnalysis(lines);
|
||||
case 'Financial Summary':
|
||||
return parseFinancialSummary(lines);
|
||||
case 'Management Team Overview':
|
||||
return parseManagementTeamOverview(lines);
|
||||
case 'Preliminary Investment Thesis':
|
||||
return parsePreliminaryInvestmentThesis(lines);
|
||||
case 'Key Questions & Next Steps':
|
||||
return parseKeyQuestionsNextSteps(lines);
|
||||
default:
|
||||
return section;
|
||||
}
|
||||
}
|
||||
|
||||
function parseDealOverview(lines: string[]): any {
|
||||
const overview: any = {};
|
||||
|
||||
lines.forEach(line => {
|
||||
const match = line.match(/-\s*`([^:]+):`\s*(.+)/);
|
||||
if (match) {
|
||||
const [, key, value] = match;
|
||||
const cleanKey = key.trim().replace(/\s+/g, '');
|
||||
const cleanValue = value.trim();
|
||||
|
||||
switch (cleanKey) {
|
||||
case 'TargetCompanyName':
|
||||
overview.targetCompanyName = cleanValue;
|
||||
break;
|
||||
case 'Industry/Sector':
|
||||
overview.industrySector = cleanValue;
|
||||
break;
|
||||
case 'Geography(HQ&KeyOperations)':
|
||||
overview.geography = cleanValue;
|
||||
break;
|
||||
case 'DealSource':
|
||||
overview.dealSource = cleanValue;
|
||||
break;
|
||||
case 'TransactionType':
|
||||
overview.transactionType = cleanValue;
|
||||
break;
|
||||
case 'DateCIMReceived':
|
||||
overview.dateCIMReceived = cleanValue;
|
||||
break;
|
||||
case 'DateReviewed':
|
||||
overview.dateReviewed = cleanValue;
|
||||
break;
|
||||
case 'Reviewer(s)':
|
||||
overview.reviewers = cleanValue;
|
||||
break;
|
||||
case 'CIMPageCount':
|
||||
overview.cimPageCount = cleanValue;
|
||||
break;
|
||||
case 'StatedReasonforSale':
|
||||
overview.statedReasonForSale = cleanValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return overview;
|
||||
}
|
||||
|
||||
function parseBusinessDescription(lines: string[]): any {
|
||||
const description: any = {
|
||||
customerBaseOverview: {},
|
||||
keySupplierOverview: {}
|
||||
};
|
||||
|
||||
let currentSubsection = '';
|
||||
|
||||
lines.forEach(line => {
|
||||
const match = line.match(/-\s*`([^:]+):`\s*(.+)/);
|
||||
if (match) {
|
||||
const [, key, value] = match;
|
||||
const cleanKey = key.trim().replace(/\s+/g, '');
|
||||
const cleanValue = value.trim();
|
||||
|
||||
switch (cleanKey) {
|
||||
case 'CoreOperationsSummary':
|
||||
description.coreOperationsSummary = cleanValue;
|
||||
break;
|
||||
case 'KeyProducts/Services&RevenueMix':
|
||||
description.keyProductsServices = cleanValue;
|
||||
break;
|
||||
case 'UniqueValueProposition':
|
||||
description.uniqueValueProposition = cleanValue;
|
||||
break;
|
||||
case 'KeyCustomerSegments':
|
||||
description.customerBaseOverview.keyCustomerSegments = cleanValue;
|
||||
break;
|
||||
case 'CustomerConcentration':
|
||||
description.customerBaseOverview.customerConcentrationRisk = cleanValue;
|
||||
break;
|
||||
case 'TypicalContractLength':
|
||||
description.customerBaseOverview.typicalContractLength = cleanValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return description;
|
||||
}
|
||||
|
||||
function parseMarketIndustryAnalysis(lines: string[]): any {
|
||||
const analysis: any = {
|
||||
competitiveLandscape: {}
|
||||
};
|
||||
|
||||
lines.forEach(line => {
|
||||
const match = line.match(/-\s*`([^:]+):`\s*(.+)/);
|
||||
if (match) {
|
||||
const [, key, value] = match;
|
||||
const cleanKey = key.trim().replace(/\s+/g, '');
|
||||
const cleanValue = value.trim();
|
||||
|
||||
switch (cleanKey) {
|
||||
case 'EstimatedMarketSize':
|
||||
analysis.estimatedMarketSize = cleanValue;
|
||||
break;
|
||||
case 'EstimatedMarketGrowthRate':
|
||||
analysis.estimatedMarketGrowthRate = cleanValue;
|
||||
break;
|
||||
case 'KeyIndustryTrends&Drivers':
|
||||
analysis.keyIndustryTrends = cleanValue;
|
||||
break;
|
||||
case 'KeyCompetitors':
|
||||
analysis.competitiveLandscape.keyCompetitors = cleanValue;
|
||||
break;
|
||||
case 'Target\'sMarketPosition':
|
||||
analysis.competitiveLandscape.targetMarketPosition = cleanValue;
|
||||
break;
|
||||
case 'BasisofCompetition':
|
||||
analysis.competitiveLandscape.basisOfCompetition = cleanValue;
|
||||
break;
|
||||
case 'BarrierstoEntry':
|
||||
analysis.barriersToEntry = cleanValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return analysis;
|
||||
}
|
||||
|
||||
function parseFinancialSummary(lines: string[]): any {
|
||||
const summary: any = {
|
||||
financials: {
|
||||
fy3: {}, fy2: {}, fy1: {}, ltm: {}
|
||||
}
|
||||
};
|
||||
|
||||
let currentTable = false;
|
||||
let tableData: string[] = [];
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.includes('|Metric|')) {
|
||||
currentTable = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTable && line.includes('|')) {
|
||||
tableData.push(line);
|
||||
} else if (currentTable) {
|
||||
currentTable = false;
|
||||
// Parse table data
|
||||
const parsedTable = parseFinancialTable(tableData);
|
||||
if (parsedTable) {
|
||||
summary.financials = parsedTable;
|
||||
}
|
||||
}
|
||||
|
||||
const match = line.match(/-\s*`([^:]+):`\s*(.+)/);
|
||||
if (match) {
|
||||
const [, key, value] = match;
|
||||
const cleanKey = key.trim().replace(/\s+/g, '');
|
||||
const cleanValue = value.trim();
|
||||
|
||||
switch (cleanKey) {
|
||||
case 'KeyFinancialNotes':
|
||||
summary.keyFinancialNotes = cleanValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
function parseFinancialTable(tableData: string[]): any {
|
||||
if (tableData.length < 2) return null;
|
||||
|
||||
const periods = ['fy3', 'fy2', 'fy1', 'ltm'];
|
||||
const financials: any = {};
|
||||
|
||||
periods.forEach(period => {
|
||||
financials[period] = {
|
||||
revenue: '',
|
||||
revenueGrowth: '',
|
||||
grossProfit: '',
|
||||
grossMargin: '',
|
||||
ebitda: '',
|
||||
ebitdaMargin: ''
|
||||
};
|
||||
});
|
||||
|
||||
// Simple parsing - in a real implementation, you'd want more robust table parsing
|
||||
return financials;
|
||||
}
|
||||
|
||||
function parseManagementTeamOverview(lines: string[]): any {
|
||||
const overview: any = {};
|
||||
|
||||
lines.forEach(line => {
|
||||
const match = line.match(/-\s*`([^:]+):`\s*(.+)/);
|
||||
if (match) {
|
||||
const [, key, value] = match;
|
||||
const cleanKey = key.trim().replace(/\s+/g, '');
|
||||
const cleanValue = value.trim();
|
||||
|
||||
switch (cleanKey) {
|
||||
case 'KeyLeadersIdentified':
|
||||
overview.keyLeaders = cleanValue;
|
||||
break;
|
||||
case 'InitialAssessment':
|
||||
overview.managementQualityAssessment = cleanValue;
|
||||
break;
|
||||
case 'Management\'sPost-TransactionRole':
|
||||
overview.postTransactionIntentions = cleanValue;
|
||||
break;
|
||||
case 'OrganizationalStructure':
|
||||
overview.organizationalStructure = cleanValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return overview;
|
||||
}
|
||||
|
||||
function parsePreliminaryInvestmentThesis(lines: string[]): any {
|
||||
const thesis: any = {};
|
||||
|
||||
lines.forEach(line => {
|
||||
const match = line.match(/-\s*`([^:]+):`\s*(.+)/);
|
||||
if (match) {
|
||||
const [, key, value] = match;
|
||||
const cleanKey = key.trim().replace(/\s+/g, '');
|
||||
const cleanValue = value.trim();
|
||||
|
||||
switch (cleanKey) {
|
||||
case 'KeyAttractions':
|
||||
thesis.keyAttractions = cleanValue;
|
||||
break;
|
||||
case 'PotentialRisks':
|
||||
thesis.potentialRisks = cleanValue;
|
||||
break;
|
||||
case 'ValueCreationLevers':
|
||||
thesis.valueCreationLevers = cleanValue;
|
||||
break;
|
||||
case 'AlignmentwithFundStrategy':
|
||||
thesis.alignmentWithFundStrategy = cleanValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return thesis;
|
||||
}
|
||||
|
||||
function parseKeyQuestionsNextSteps(lines: string[]): any {
|
||||
const questions: any = {};
|
||||
|
||||
lines.forEach(line => {
|
||||
const match = line.match(/-\s*`([^:]+):`\s*(.+)/);
|
||||
if (match) {
|
||||
const [, key, value] = match;
|
||||
const cleanKey = key.trim().replace(/\s+/g, '');
|
||||
const cleanValue = value.trim();
|
||||
|
||||
switch (cleanKey) {
|
||||
case 'CriticalQuestions':
|
||||
questions.criticalQuestions = cleanValue;
|
||||
break;
|
||||
case 'KeyMissingInformation':
|
||||
questions.missingInformation = cleanValue;
|
||||
break;
|
||||
case 'PreliminaryRecommendation':
|
||||
questions.preliminaryRecommendation = cleanValue;
|
||||
break;
|
||||
case 'Rationale':
|
||||
questions.rationaleForRecommendation = cleanValue;
|
||||
break;
|
||||
case 'ProposedNextSteps':
|
||||
questions.proposedNextSteps = cleanValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return questions;
|
||||
}
|
||||
@@ -7,24 +7,69 @@ export default {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Blue Point Capital inspired colors
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
50: '#f0f4f8',
|
||||
100: '#d9e2ec',
|
||||
200: '#bcccdc',
|
||||
300: '#9fb3c8',
|
||||
400: '#829ab1',
|
||||
500: '#627d98',
|
||||
600: '#486581',
|
||||
700: '#334e68',
|
||||
800: '#243b53',
|
||||
900: '#102a43',
|
||||
},
|
||||
// Gold accent color
|
||||
accent: {
|
||||
50: '#fffbf0',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
},
|
||||
// Clean grays for Google-like design
|
||||
gray: {
|
||||
50: '#f9fafb',
|
||||
100: '#f3f4f6',
|
||||
200: '#e5e7eb',
|
||||
300: '#d1d5db',
|
||||
400: '#9ca3af',
|
||||
500: '#6b7280',
|
||||
600: '#4b5563',
|
||||
700: '#374151',
|
||||
800: '#1f2937',
|
||||
900: '#111827',
|
||||
50: '#fafafa',
|
||||
100: '#f5f5f5',
|
||||
200: '#eeeeee',
|
||||
300: '#e0e0e0',
|
||||
400: '#bdbdbd',
|
||||
500: '#9e9e9e',
|
||||
600: '#757575',
|
||||
700: '#616161',
|
||||
800: '#424242',
|
||||
900: '#212121',
|
||||
},
|
||||
// Success/Error colors
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
},
|
||||
error: {
|
||||
50: '#fef2f2',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
boxShadow: {
|
||||
'soft': '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||
'medium': '0 4px 12px rgba(0, 0, 0, 0.12)',
|
||||
'large': '0 8px 24px rgba(0, 0, 0, 0.16)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user