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:
@@ -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 {
|
||||
|
||||
242
backend/src/services/csvExportService.ts
Normal file
242
backend/src/services/csvExportService.ts
Normal 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;
|
||||
@@ -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,6 +470,8 @@ const Dashboard: React.FC = () => {
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Upload
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setActiveTab('analytics')}
|
||||
className={cn(
|
||||
@@ -490,6 +496,8 @@ const Dashboard: React.FC = () => {
|
||||
<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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
93
frontend/src/services/adminService.ts
Normal file
93
frontend/src/services/adminService.ts
Normal 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();
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user