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:
Jon
2025-07-27 20:25:46 -04:00
parent f82d9bffd6
commit c67dab22b4
51 changed files with 6208 additions and 1374 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 ? (

View File

@@ -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

View 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;

View 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;

View File

@@ -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(','),

View File

@@ -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;
}
}

View File

@@ -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');

View File

@@ -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);

View 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;
}

View File

@@ -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)',
},
},
},