feat: Implement comprehensive CIM Review editing and admin features

- Add inline editing for CIM Review template with auto-save functionality
- Implement CSV export with comprehensive data formatting
- Add automated file naming (YYYYMMDD_CompanyName_CIM_Review.pdf/csv)
- Create admin role system for jpressnell@bluepointcapital.com
- Hide analytics/monitoring tabs from non-admin users
- Add email sharing functionality via mailto links
- Implement save status indicators and last saved timestamps
- Add backend endpoints for CIM Review save/load and CSV export
- Create admin service for role-based access control
- Update document viewer with save/export handlers
- Add proper error handling and user feedback

Backup: Live version preserved in backup-live-version-e0a37bf-clean branch
This commit is contained in:
Jon
2025-08-14 11:54:25 -04:00
parent e0a37bf9f9
commit c8c2783241
7 changed files with 792 additions and 40 deletions

View File

@@ -90,6 +90,116 @@ router.get('/:id', validateUUID('id'), documentController.getDocument);
router.get('/:id/progress', validateUUID('id'), documentController.getDocumentProgress);
router.delete('/:id', validateUUID('id'), documentController.deleteDocument);
// CIM Review data endpoints
router.post('/:id/review', validateUUID('id'), async (req, res) => {
try {
const userId = req.user?.uid;
if (!userId) {
return res.status(401).json({
error: 'User not authenticated',
correlationId: req.correlationId
});
}
const { id } = req.params;
const reviewData = req.body;
if (!reviewData) {
return res.status(400).json({
error: 'Review data is required',
correlationId: req.correlationId
});
}
// Check if document exists and user has access
const document = await DocumentModel.findById(id);
if (!document) {
return res.status(404).json({
error: 'Document not found',
correlationId: req.correlationId
});
}
if (document.user_id !== userId) {
return res.status(403).json({
error: 'Access denied',
correlationId: req.correlationId
});
}
// Update the document with new analysis data
await DocumentModel.updateAnalysisResults(id, reviewData);
logger.info('CIM Review data saved successfully', {
documentId: id,
userId,
correlationId: req.correlationId
});
return res.json({
success: true,
message: 'CIM Review data saved successfully',
correlationId: req.correlationId || undefined
});
} catch (error) {
logger.error('Failed to save CIM Review data', {
error,
correlationId: req.correlationId
});
return res.status(500).json({
error: 'Failed to save CIM Review data',
correlationId: req.correlationId || undefined
});
}
});
router.get('/:id/review', validateUUID('id'), async (req, res) => {
try {
const userId = req.user?.uid;
if (!userId) {
return res.status(401).json({
error: 'User not authenticated',
correlationId: req.correlationId
});
}
const { id } = req.params;
// Check if document exists and user has access
const document = await DocumentModel.findById(id);
if (!document) {
return res.status(404).json({
error: 'Document not found',
correlationId: req.correlationId
});
}
if (document.user_id !== userId) {
return res.status(403).json({
error: 'Access denied',
correlationId: req.correlationId
});
}
return res.json({
success: true,
reviewData: document.analysis_data || {},
correlationId: req.correlationId || undefined
});
} catch (error) {
logger.error('Failed to get CIM Review data', {
error,
correlationId: req.correlationId
});
return res.status(500).json({
error: 'Failed to get CIM Review data',
correlationId: req.correlationId || undefined
});
}
});
// Download endpoint (keeping this)
router.get('/:id/download', validateUUID('id'), async (req, res) => {
try {
@@ -144,8 +254,17 @@ router.get('/:id/download', validateUUID('id'), async (req, res) => {
});
}
// Generate standardized filename
const companyName = document.analysis_data?.dealOverview?.targetCompanyName || 'Unknown';
const date = new Date().toISOString().split('T')[0].replace(/-/g, ''); // YYYYMMDD
const sanitizedCompanyName = companyName
.replace(/[^a-zA-Z0-9\s]/g, '') // Remove special characters
.replace(/\s+/g, '_') // Replace spaces with underscores
.toUpperCase();
const filename = `${date}_${sanitizedCompanyName}_CIM_Review.pdf`;
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${document.original_file_name.replace(/\.[^/.]+$/, '')}_cim_review.pdf"`);
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('x-correlation-id', req.correlationId || 'unknown');
return res.send(pdfBuffer);
@@ -172,6 +291,84 @@ router.get('/:id/download', validateUUID('id'), async (req, res) => {
}
});
// CSV Export endpoint
router.get('/:id/export-csv', validateUUID('id'), async (req, res) => {
try {
const userId = req.user?.uid;
if (!userId) {
return res.status(401).json({
error: 'User not authenticated',
correlationId: req.correlationId
});
}
const { id } = req.params;
if (!id) {
return res.status(400).json({
error: 'Document ID is required',
correlationId: req.correlationId
});
}
const document = await DocumentModel.findById(id);
if (!document) {
return res.status(404).json({
error: 'Document not found',
correlationId: req.correlationId
});
}
if (document.user_id !== userId) {
return res.status(403).json({
error: 'Access denied',
correlationId: req.correlationId
});
}
// Check if document has analysis data
if (!document.analysis_data) {
return res.status(404).json({
error: 'No analysis data available for CSV export',
correlationId: req.correlationId
});
}
// Generate CSV
try {
const { default: CSVExportService } = await import('../services/csvExportService');
const companyName = document.analysis_data?.dealOverview?.targetCompanyName || 'Unknown';
const csvContent = CSVExportService.generateCIMReviewCSV(document.analysis_data, companyName);
const filename = CSVExportService.generateCSVFilename(companyName);
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('x-correlation-id', req.correlationId || 'unknown');
return res.send(csvContent);
} catch (csvError) {
logger.error('CSV generation failed', {
error: csvError,
correlationId: req.correlationId
});
return res.status(500).json({
error: 'CSV generation failed',
correlationId: req.correlationId || undefined
});
}
} catch (error) {
logger.error('CSV export failed', {
error,
correlationId: req.correlationId
});
return res.status(500).json({
error: 'CSV export failed',
correlationId: req.correlationId || undefined
});
}
});
// ONLY OPTIMIZED AGENTIC RAG PROCESSING ROUTE - All other processing routes disabled
router.post('/:id/process-optimized-agentic-rag', validateUUID('id'), async (req, res) => {
try {

View File

@@ -0,0 +1,242 @@
import { logger } from '../utils/logger';
export interface CIMReviewData {
dealOverview: {
targetCompanyName: string;
industrySector: string;
geography: string;
dealSource: string;
transactionType: string;
dateCIMReceived: string;
dateReviewed: string;
reviewers: string;
cimPageCount: string;
statedReasonForSale: string;
employeeCount: string;
};
businessDescription: {
coreOperationsSummary: string;
keyProductsServices: string;
uniqueValueProposition: string;
customerBaseOverview: {
keyCustomerSegments: string;
customerConcentrationRisk: string;
typicalContractLength: string;
};
keySupplierOverview: {
dependenceConcentrationRisk: string;
};
};
marketIndustryAnalysis: {
estimatedMarketSize: string;
estimatedMarketGrowthRate: string;
keyIndustryTrends: string;
competitiveLandscape: {
keyCompetitors: string;
targetMarketPosition: string;
basisOfCompetition: string;
};
barriersToEntry: 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;
};
managementTeamOverview: {
keyLeaders: string;
managementQualityAssessment: string;
postTransactionIntentions: string;
organizationalStructure: string;
};
preliminaryInvestmentThesis: {
keyAttractions: string;
potentialRisks: string;
valueCreationLevers: string;
alignmentWithFundStrategy: string;
};
keyQuestionsNextSteps: {
criticalQuestions: string;
missingInformation: string;
preliminaryRecommendation: string;
rationaleForRecommendation: string;
proposedNextSteps: string;
};
}
class CSVExportService {
/**
* Convert CIM Review data to CSV format
*/
static generateCIMReviewCSV(reviewData: CIMReviewData, companyName: string = 'Unknown'): string {
try {
const csvRows: string[] = [];
// Add header
csvRows.push('BPCP CIM Review Summary');
csvRows.push(`Company: ${companyName}`);
csvRows.push(`Generated: ${new Date().toISOString()}`);
csvRows.push(''); // Empty row
// Deal Overview Section
csvRows.push('DEAL OVERVIEW');
csvRows.push('Field,Value');
if (reviewData.dealOverview) {
Object.entries(reviewData.dealOverview).forEach(([key, value]) => {
csvRows.push(`${this.formatFieldName(key)},${this.escapeCSVValue(value)}`);
});
}
csvRows.push(''); // Empty row
// Business Description Section
csvRows.push('BUSINESS DESCRIPTION');
csvRows.push('Field,Value');
if (reviewData.businessDescription) {
Object.entries(reviewData.businessDescription).forEach(([key, value]) => {
if (typeof value === 'object') {
// Handle nested objects like customerBaseOverview
Object.entries(value).forEach(([nestedKey, nestedValue]) => {
csvRows.push(`${this.formatFieldName(key)} - ${this.formatFieldName(nestedKey)},${this.escapeCSVValue(nestedValue)}`);
});
} else {
csvRows.push(`${this.formatFieldName(key)},${this.escapeCSVValue(value)}`);
}
});
}
csvRows.push(''); // Empty row
// Market & Industry Analysis Section
csvRows.push('MARKET & INDUSTRY ANALYSIS');
csvRows.push('Field,Value');
if (reviewData.marketIndustryAnalysis) {
Object.entries(reviewData.marketIndustryAnalysis).forEach(([key, value]) => {
if (typeof value === 'object') {
// Handle nested objects like competitiveLandscape
Object.entries(value).forEach(([nestedKey, nestedValue]) => {
csvRows.push(`${this.formatFieldName(key)} - ${this.formatFieldName(nestedKey)},${this.escapeCSVValue(nestedValue)}`);
});
} else {
csvRows.push(`${this.formatFieldName(key)},${this.escapeCSVValue(value)}`);
}
});
}
csvRows.push(''); // Empty row
// Financial Summary Section
csvRows.push('FINANCIAL SUMMARY');
csvRows.push('Period,Revenue,Revenue Growth,Gross Profit,Gross Margin,EBITDA,EBITDA Margin');
if (reviewData.financialSummary?.financials) {
Object.entries(reviewData.financialSummary.financials).forEach(([period, financials]) => {
csvRows.push(`${period.toUpperCase()},${this.escapeCSVValue(financials.revenue)},${this.escapeCSVValue(financials.revenueGrowth)},${this.escapeCSVValue(financials.grossProfit)},${this.escapeCSVValue(financials.grossMargin)},${this.escapeCSVValue(financials.ebitda)},${this.escapeCSVValue(financials.ebitdaMargin)}`);
});
}
csvRows.push(''); // Empty row
// Additional Financial Metrics
csvRows.push('ADDITIONAL FINANCIAL METRICS');
csvRows.push('Field,Value');
if (reviewData.financialSummary) {
const additionalMetrics = [
'qualityOfEarnings',
'revenueGrowthDrivers',
'marginStabilityAnalysis',
'capitalExpenditures',
'workingCapitalIntensity',
'freeCashFlowQuality'
];
additionalMetrics.forEach(metric => {
if (reviewData.financialSummary[metric]) {
csvRows.push(`${this.formatFieldName(metric)},${this.escapeCSVValue(reviewData.financialSummary[metric])}`);
}
});
}
csvRows.push(''); // Empty row
// Management Team Overview Section
csvRows.push('MANAGEMENT TEAM OVERVIEW');
csvRows.push('Field,Value');
if (reviewData.managementTeamOverview) {
Object.entries(reviewData.managementTeamOverview).forEach(([key, value]) => {
csvRows.push(`${this.formatFieldName(key)},${this.escapeCSVValue(value)}`);
});
}
csvRows.push(''); // Empty row
// Preliminary Investment Thesis Section
csvRows.push('PRELIMINARY INVESTMENT THESIS');
csvRows.push('Field,Value');
if (reviewData.preliminaryInvestmentThesis) {
Object.entries(reviewData.preliminaryInvestmentThesis).forEach(([key, value]) => {
csvRows.push(`${this.formatFieldName(key)},${this.escapeCSVValue(value)}`);
});
}
csvRows.push(''); // Empty row
// Key Questions & Next Steps Section
csvRows.push('KEY QUESTIONS & NEXT STEPS');
csvRows.push('Field,Value');
if (reviewData.keyQuestionsNextSteps) {
Object.entries(reviewData.keyQuestionsNextSteps).forEach(([key, value]) => {
csvRows.push(`${this.formatFieldName(key)},${this.escapeCSVValue(value)}`);
});
}
return csvRows.join('\n');
} catch (error) {
logger.error('Failed to generate CSV from CIM Review data', { error });
throw new Error('Failed to generate CSV export');
}
}
/**
* Format field names for better readability
*/
private static formatFieldName(fieldName: string): string {
return fieldName
.replace(/([A-Z])/g, ' $1') // Add space before capital letters
.replace(/^./, str => str.toUpperCase()) // Capitalize first letter
.trim();
}
/**
* Escape CSV values to handle commas, quotes, and newlines
*/
private static escapeCSVValue(value: string): string {
if (!value) return '';
// Replace newlines with spaces and trim
const cleanValue = value.replace(/\n/g, ' ').replace(/\r/g, ' ').trim();
// If value contains comma, quote, or newline, wrap in quotes and escape internal quotes
if (cleanValue.includes(',') || cleanValue.includes('"') || cleanValue.includes('\n')) {
return `"${cleanValue.replace(/"/g, '""')}"`;
}
return cleanValue;
}
/**
* Generate standardized filename for CSV export
*/
static generateCSVFilename(companyName: string): string {
const date = new Date().toISOString().split('T')[0].replace(/-/g, ''); // YYYYMMDD
const sanitizedCompanyName = companyName
.replace(/[^a-zA-Z0-9\s]/g, '') // Remove special characters
.replace(/\s+/g, '_') // Replace spaces with underscores
.toUpperCase();
return `${date}_${sanitizedCompanyName}_CIM_Data.csv`;
}
}
export default CSVExportService;

View File

@@ -10,6 +10,7 @@ import Analytics from './components/Analytics';
import UploadMonitoringDashboard from './components/UploadMonitoringDashboard';
import LogoutButton from './components/LogoutButton';
import { documentService, GCSErrorHandler, GCSError } from './services/documentService';
import { adminService } from './services/adminService';
// import { debugAuth, testAPIAuth } from './utils/authDebug';
import {
@@ -34,6 +35,9 @@ const Dashboard: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const [activeTab, setActiveTab] = useState<'overview' | 'documents' | 'upload' | 'analytics' | 'monitoring'>('overview');
// Check if user is admin
const isAdmin = adminService.isAdmin(user?.email);
// Map backend status to frontend status
const mapBackendStatus = (backendStatus: string): string => {
switch (backendStatus) {
@@ -466,30 +470,34 @@ const Dashboard: React.FC = () => {
<Upload className="h-4 w-4 mr-2" />
Upload
</button>
<button
onClick={() => setActiveTab('analytics')}
className={cn(
'flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200',
activeTab === 'analytics'
? 'border-primary-600 text-primary-700'
: 'border-transparent text-gray-500 hover:text-primary-600 hover:border-primary-300'
)}
>
<TrendingUp className="h-4 w-4 mr-2" />
Analytics
</button>
<button
onClick={() => setActiveTab('monitoring')}
className={cn(
'flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200',
activeTab === 'monitoring'
? 'border-primary-600 text-primary-700'
: 'border-transparent text-gray-500 hover:text-primary-600 hover:border-primary-300'
)}
>
<Activity className="h-4 w-4 mr-2" />
Monitoring
</button>
{isAdmin && (
<>
<button
onClick={() => setActiveTab('analytics')}
className={cn(
'flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200',
activeTab === 'analytics'
? 'border-primary-600 text-primary-700'
: 'border-transparent text-gray-500 hover:text-primary-600 hover:border-primary-300'
)}
>
<TrendingUp className="h-4 w-4 mr-2" />
Analytics
</button>
<button
onClick={() => setActiveTab('monitoring')}
className={cn(
'flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200',
activeTab === 'monitoring'
? 'border-primary-600 text-primary-700'
: 'border-transparent text-gray-500 hover:text-primary-600 hover:border-primary-300'
)}
>
<Activity className="h-4 w-4 mr-2" />
Monitoring
</button>
</>
)}
</nav>
</div>
</div>
@@ -675,13 +683,32 @@ const Dashboard: React.FC = () => {
</div>
)}
{activeTab === 'analytics' && (
{activeTab === 'analytics' && isAdmin && (
<Analytics />
)}
{activeTab === 'monitoring' && (
{activeTab === 'monitoring' && isAdmin && (
<UploadMonitoringDashboard />
)}
{/* Redirect non-admin users away from admin tabs */}
{activeTab === 'analytics' && !isAdmin && (
<div className="bg-white shadow-soft rounded-lg border border-gray-100 p-6">
<div className="text-center">
<h3 className="text-lg font-medium text-gray-900 mb-2">Access Denied</h3>
<p className="text-gray-600">You don't have permission to view analytics.</p>
</div>
</div>
)}
{activeTab === 'monitoring' && !isAdmin && (
<div className="bg-white shadow-soft rounded-lg border border-gray-100 p-6">
<div className="text-center">
<h3 className="text-lg font-medium text-gray-900 mb-2">Access Denied</h3>
<p className="text-gray-600">You don't have permission to view monitoring.</p>
</div>
</div>
)}
</div>
</div>
</div>

View File

@@ -190,6 +190,9 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
});
const [activeSection, setActiveSection] = useState<string>('deal-overview');
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [autoSaveTimeout, setAutoSaveTimeout] = useState<NodeJS.Timeout | null>(null);
// Merge cimReviewData with existing data when it changes
useEffect(() => {
@@ -243,10 +246,56 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
}));
};
const handleSave = () => {
onSave?.(data);
const handleSave = async () => {
if (readOnly) return;
try {
setSaveStatus('saving');
await onSave?.(data);
setSaveStatus('saved');
setLastSaved(new Date());
// Clear saved status after 3 seconds
setTimeout(() => {
setSaveStatus('idle');
}, 3000);
} catch (error) {
console.error('Save failed:', error);
setSaveStatus('error');
// Clear error status after 5 seconds
setTimeout(() => {
setSaveStatus('idle');
}, 5000);
}
};
// Auto-save functionality
const triggerAutoSave = () => {
if (readOnly) return;
// Clear existing timeout
if (autoSaveTimeout) {
clearTimeout(autoSaveTimeout);
}
// Set new timeout for auto-save (2 seconds after last change)
const timeout = setTimeout(() => {
handleSave();
}, 2000);
setAutoSaveTimeout(timeout);
};
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (autoSaveTimeout) {
clearTimeout(autoSaveTimeout);
}
};
}, [autoSaveTimeout]);
const handleExport = () => {
onExport?.(data);
};
@@ -294,7 +343,10 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
{type === 'textarea' ? (
<textarea
value={value || ''}
onChange={(e) => updateNestedField(e.target.value)}
onChange={(e) => {
updateNestedField(e.target.value);
triggerAutoSave();
}}
placeholder={placeholder}
rows={rows || 3}
disabled={readOnly}
@@ -304,7 +356,10 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
<input
type="date"
value={value || ''}
onChange={(e) => updateNestedField(e.target.value)}
onChange={(e) => {
updateNestedField(e.target.value);
triggerAutoSave();
}}
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"
/>
@@ -312,7 +367,10 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
<input
type="text"
value={value || ''}
onChange={(e) => updateNestedField(e.target.value)}
onChange={(e) => {
updateNestedField(e.target.value);
triggerAutoSave();
}}
placeholder={placeholder}
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"
@@ -399,7 +457,10 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
<input
type="text"
value={financials[period]?.revenue || ''}
onChange={(e) => updateFinancials(period, 'revenue', e.target.value)}
onChange={(e) => {
updateFinancials(period, 'revenue', e.target.value);
triggerAutoSave();
}}
placeholder="$0"
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"
@@ -416,7 +477,10 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
<input
type="text"
value={financials[period]?.revenueGrowth || ''}
onChange={(e) => updateFinancials(period, 'revenueGrowth', e.target.value)}
onChange={(e) => {
updateFinancials(period, 'revenueGrowth', e.target.value);
triggerAutoSave();
}}
placeholder="0%"
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"
@@ -433,7 +497,10 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
<input
type="text"
value={financials[period]?.ebitda || ''}
onChange={(e) => updateFinancials(period, 'ebitda', e.target.value)}
onChange={(e) => {
updateFinancials(period, 'ebitda', e.target.value);
triggerAutoSave();
}}
placeholder="$0"
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"
@@ -450,7 +517,10 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
<input
type="text"
value={financials[period].ebitdaMargin}
onChange={(e) => updateFinancials(period, 'ebitdaMargin', e.target.value)}
onChange={(e) => {
updateFinancials(period, 'ebitdaMargin', e.target.value);
triggerAutoSave();
}}
placeholder="0%"
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"
@@ -596,15 +666,48 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
<p className="text-sm text-gray-600 mt-1">
Comprehensive review template for Confidential Information Memorandums
</p>
{/* Save Status */}
{!readOnly && (
<div className="flex items-center mt-2 space-x-2">
{saveStatus === 'saving' && (
<div className="flex items-center text-blue-600">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600 mr-2"></div>
<span className="text-sm">Saving...</span>
</div>
)}
{saveStatus === 'saved' && (
<div className="flex items-center text-green-600">
<svg className="h-4 w-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<span className="text-sm">Saved</span>
</div>
)}
{saveStatus === 'error' && (
<div className="flex items-center text-red-600">
<svg className="h-4 w-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
<span className="text-sm">Save failed</span>
</div>
)}
{lastSaved && saveStatus === 'idle' && (
<span className="text-sm text-gray-500">
Last saved: {lastSaved.toLocaleTimeString()}
</span>
)}
</div>
)}
</div>
<div className="flex items-center space-x-3">
{!readOnly && (
<button
onClick={handleSave}
className="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"
disabled={saveStatus === 'saving'}
className="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 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="h-4 w-4 mr-2" />
Save
{saveStatus === 'saving' ? 'Saving...' : 'Save'}
</button>
)}
<button

View File

@@ -15,6 +15,8 @@ import {
import { cn } from '../utils/cn';
import CIMReviewTemplate from './CIMReviewTemplate';
import LogoutButton from './LogoutButton';
import { documentService } from '../services/documentService';
import { useAuth } from '../contexts/AuthContext';
interface ExtractedData {
@@ -55,6 +57,7 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
onDownload,
onShare,
}) => {
const { user } = useAuth();
const [activeTab, setActiveTab] = useState<'overview' | 'template' | 'raw'>('overview');
const tabs = [
@@ -63,6 +66,71 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
{ id: 'raw', label: 'Raw Data', icon: FileText },
];
// Handle saving CIM Review data
const handleSaveCIMReview = async (data: any) => {
try {
await documentService.saveCIMReview(documentId, data);
console.log('CIM Review data saved successfully');
} catch (error) {
console.error('Failed to save CIM Review data:', error);
throw error;
}
};
// Handle exporting CIM Review data
const handleExportCIMReview = async (data: any) => {
try {
const csvBlob = await documentService.exportCSV(documentId);
// Create download link
const url = window.URL.createObjectURL(csvBlob);
const link = document.createElement('a');
link.href = url;
// Generate filename
const companyName = data?.dealOverview?.targetCompanyName || 'Unknown';
const date = new Date().toISOString().split('T')[0].replace(/-/g, ''); // YYYYMMDD
const sanitizedCompanyName = companyName
.replace(/[^a-zA-Z0-9\s]/g, '') // Remove special characters
.replace(/\s+/g, '_') // Replace spaces with underscores
.toUpperCase();
const filename = `${date}_${sanitizedCompanyName}_CIM_Data.csv`;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
console.log('CIM Review data exported successfully');
} catch (error) {
console.error('Failed to export CIM Review data:', error);
throw error;
}
};
// Handle email sharing
const handleEmailShare = () => {
const companyName = cimReviewData?.dealOverview?.targetCompanyName || documentName;
const subject = encodeURIComponent(`CIM Review: ${companyName}`);
const body = encodeURIComponent(`Please find attached the CIM Review for ${companyName}.
This document contains a comprehensive analysis including:
- Deal Overview
- Business Description
- Market & Industry Analysis
- Financial Summary
- Management Team Overview
- Investment Thesis
- Key Questions & Next Steps
Best regards,
${user?.name || user?.email || 'CIM Document Processor User'}`);
const mailtoLink = `mailto:?subject=${subject}&body=${body}`;
window.open(mailtoLink, '_blank');
};
const renderOverview = () => (
<div className="space-y-6">
{/* Document Header */}
@@ -81,11 +149,11 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
Download
</button>
<button
onClick={onShare}
onClick={handleEmailShare}
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<Share2 className="h-4 w-4 mr-2" />
Share
Share via Email
</button>
</div>
</div>
@@ -348,6 +416,8 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
<CIMReviewTemplate
initialData={cimReviewData}
cimReviewData={cimReviewData}
onSave={handleSaveCIMReview}
onExport={handleExportCIMReview}
readOnly={false}
/>
</>

View File

@@ -0,0 +1,93 @@
import { apiClient } from './apiClient';
export interface AdminUser {
id: string;
email: string;
name: string;
role: 'user' | 'admin';
createdAt: string;
lastLogin?: string;
isActive: boolean;
}
export interface UserActivity {
userId: string;
email: string;
name: string;
loginCount: number;
lastLogin: string;
documentsProcessed: number;
totalProcessingTime: number;
averageProcessingTime: number;
}
export interface SystemMetrics {
totalUsers: number;
activeUsers: number;
totalDocuments: number;
documentsProcessed: number;
averageProcessingTime: number;
successRate: number;
totalCost: number;
systemUptime: number;
}
class AdminService {
private readonly ADMIN_EMAIL = 'jpressnell@bluepointcapital.com';
/**
* Check if current user is admin
*/
isAdmin(userEmail?: string): boolean {
return userEmail === this.ADMIN_EMAIL;
}
/**
* Get all users (admin only)
*/
async getUsers(): Promise<AdminUser[]> {
const response = await apiClient.get('/admin/users');
return response.data.users;
}
/**
* Get user activity statistics (admin only)
*/
async getUserActivity(): Promise<UserActivity[]> {
const response = await apiClient.get('/admin/user-activity');
return response.data.activity;
}
/**
* Get system metrics (admin only)
*/
async getSystemMetrics(): Promise<SystemMetrics> {
const response = await apiClient.get('/admin/system-metrics');
return response.data.metrics;
}
/**
* Get enhanced analytics (admin only)
*/
async getEnhancedAnalytics(days: number = 30): Promise<any> {
const response = await apiClient.get(`/admin/enhanced-analytics?days=${days}`);
return response.data;
}
/**
* Get weekly summary report (admin only)
*/
async getWeeklySummary(): Promise<any> {
const response = await apiClient.get('/admin/weekly-summary');
return response.data;
}
/**
* Send weekly summary email (admin only)
*/
async sendWeeklySummaryEmail(): Promise<void> {
await apiClient.post('/admin/send-weekly-summary');
}
}
export const adminService = new AdminService();

View File

@@ -476,6 +476,26 @@ class DocumentService {
return response.data;
}
/**
* Export CIM Review data as CSV
*/
async exportCSV(documentId: string): Promise<Blob> {
try {
const response = await apiClient.get(`/documents/${documentId}/export-csv`, {
responseType: 'blob',
});
return response.data;
} catch (error: any) {
// Handle GCS-specific errors
if (error.response?.data?.type === 'storage_error' ||
error.message?.includes('GCS') ||
error.message?.includes('storage.googleapis.com')) {
throw GCSErrorHandler.createGCSError(error, 'csv_export');
}
throw error;
}
}
/**
* Get document analytics and insights
*/