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.get('/:id/progress', validateUUID('id'), documentController.getDocumentProgress);
|
||||||
router.delete('/:id', validateUUID('id'), documentController.deleteDocument);
|
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)
|
// Download endpoint (keeping this)
|
||||||
router.get('/:id/download', validateUUID('id'), async (req, res) => {
|
router.get('/:id/download', validateUUID('id'), async (req, res) => {
|
||||||
try {
|
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-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');
|
res.setHeader('x-correlation-id', req.correlationId || 'unknown');
|
||||||
return res.send(pdfBuffer);
|
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
|
// ONLY OPTIMIZED AGENTIC RAG PROCESSING ROUTE - All other processing routes disabled
|
||||||
router.post('/:id/process-optimized-agentic-rag', validateUUID('id'), async (req, res) => {
|
router.post('/:id/process-optimized-agentic-rag', validateUUID('id'), async (req, res) => {
|
||||||
try {
|
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 UploadMonitoringDashboard from './components/UploadMonitoringDashboard';
|
||||||
import LogoutButton from './components/LogoutButton';
|
import LogoutButton from './components/LogoutButton';
|
||||||
import { documentService, GCSErrorHandler, GCSError } from './services/documentService';
|
import { documentService, GCSErrorHandler, GCSError } from './services/documentService';
|
||||||
|
import { adminService } from './services/adminService';
|
||||||
// import { debugAuth, testAPIAuth } from './utils/authDebug';
|
// import { debugAuth, testAPIAuth } from './utils/authDebug';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +35,9 @@ const Dashboard: React.FC = () => {
|
|||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [activeTab, setActiveTab] = useState<'overview' | 'documents' | 'upload' | 'analytics' | 'monitoring'>('overview');
|
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
|
// Map backend status to frontend status
|
||||||
const mapBackendStatus = (backendStatus: string): string => {
|
const mapBackendStatus = (backendStatus: string): string => {
|
||||||
switch (backendStatus) {
|
switch (backendStatus) {
|
||||||
@@ -466,30 +470,34 @@ const Dashboard: React.FC = () => {
|
|||||||
<Upload className="h-4 w-4 mr-2" />
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
Upload
|
Upload
|
||||||
</button>
|
</button>
|
||||||
<button
|
{isAdmin && (
|
||||||
onClick={() => setActiveTab('analytics')}
|
<>
|
||||||
className={cn(
|
<button
|
||||||
'flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200',
|
onClick={() => setActiveTab('analytics')}
|
||||||
activeTab === 'analytics'
|
className={cn(
|
||||||
? 'border-primary-600 text-primary-700'
|
'flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200',
|
||||||
: 'border-transparent text-gray-500 hover:text-primary-600 hover:border-primary-300'
|
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>
|
<TrendingUp className="h-4 w-4 mr-2" />
|
||||||
<button
|
Analytics
|
||||||
onClick={() => setActiveTab('monitoring')}
|
</button>
|
||||||
className={cn(
|
<button
|
||||||
'flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200',
|
onClick={() => setActiveTab('monitoring')}
|
||||||
activeTab === 'monitoring'
|
className={cn(
|
||||||
? 'border-primary-600 text-primary-700'
|
'flex items-center py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200',
|
||||||
: 'border-transparent text-gray-500 hover:text-primary-600 hover:border-primary-300'
|
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>
|
<Activity className="h-4 w-4 mr-2" />
|
||||||
|
Monitoring
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -675,13 +683,32 @@ const Dashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'analytics' && (
|
{activeTab === 'analytics' && isAdmin && (
|
||||||
<Analytics />
|
<Analytics />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'monitoring' && (
|
{activeTab === 'monitoring' && isAdmin && (
|
||||||
<UploadMonitoringDashboard />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -190,6 +190,9 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [activeSection, setActiveSection] = useState<string>('deal-overview');
|
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
|
// Merge cimReviewData with existing data when it changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -243,10 +246,56 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = async () => {
|
||||||
onSave?.(data);
|
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 = () => {
|
const handleExport = () => {
|
||||||
onExport?.(data);
|
onExport?.(data);
|
||||||
};
|
};
|
||||||
@@ -294,7 +343,10 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
|
|||||||
{type === 'textarea' ? (
|
{type === 'textarea' ? (
|
||||||
<textarea
|
<textarea
|
||||||
value={value || ''}
|
value={value || ''}
|
||||||
onChange={(e) => updateNestedField(e.target.value)}
|
onChange={(e) => {
|
||||||
|
updateNestedField(e.target.value);
|
||||||
|
triggerAutoSave();
|
||||||
|
}}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
rows={rows || 3}
|
rows={rows || 3}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
@@ -304,7 +356,10 @@ const CIMReviewTemplate: React.FC<CIMReviewTemplateProps> = ({
|
|||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={value || ''}
|
value={value || ''}
|
||||||
onChange={(e) => updateNestedField(e.target.value)}
|
onChange={(e) => {
|
||||||
|
updateNestedField(e.target.value);
|
||||||
|
triggerAutoSave();
|
||||||
|
}}
|
||||||
disabled={readOnly}
|
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"
|
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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={value || ''}
|
value={value || ''}
|
||||||
onChange={(e) => updateNestedField(e.target.value)}
|
onChange={(e) => {
|
||||||
|
updateNestedField(e.target.value);
|
||||||
|
triggerAutoSave();
|
||||||
|
}}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={readOnly}
|
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"
|
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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={financials[period]?.revenue || ''}
|
value={financials[period]?.revenue || ''}
|
||||||
onChange={(e) => updateFinancials(period, 'revenue', e.target.value)}
|
onChange={(e) => {
|
||||||
|
updateFinancials(period, 'revenue', e.target.value);
|
||||||
|
triggerAutoSave();
|
||||||
|
}}
|
||||||
placeholder="$0"
|
placeholder="$0"
|
||||||
disabled={readOnly}
|
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"
|
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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={financials[period]?.revenueGrowth || ''}
|
value={financials[period]?.revenueGrowth || ''}
|
||||||
onChange={(e) => updateFinancials(period, 'revenueGrowth', e.target.value)}
|
onChange={(e) => {
|
||||||
|
updateFinancials(period, 'revenueGrowth', e.target.value);
|
||||||
|
triggerAutoSave();
|
||||||
|
}}
|
||||||
placeholder="0%"
|
placeholder="0%"
|
||||||
disabled={readOnly}
|
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"
|
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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={financials[period]?.ebitda || ''}
|
value={financials[period]?.ebitda || ''}
|
||||||
onChange={(e) => updateFinancials(period, 'ebitda', e.target.value)}
|
onChange={(e) => {
|
||||||
|
updateFinancials(period, 'ebitda', e.target.value);
|
||||||
|
triggerAutoSave();
|
||||||
|
}}
|
||||||
placeholder="$0"
|
placeholder="$0"
|
||||||
disabled={readOnly}
|
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"
|
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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={financials[period].ebitdaMargin}
|
value={financials[period].ebitdaMargin}
|
||||||
onChange={(e) => updateFinancials(period, 'ebitdaMargin', e.target.value)}
|
onChange={(e) => {
|
||||||
|
updateFinancials(period, 'ebitdaMargin', e.target.value);
|
||||||
|
triggerAutoSave();
|
||||||
|
}}
|
||||||
placeholder="0%"
|
placeholder="0%"
|
||||||
disabled={readOnly}
|
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"
|
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">
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
Comprehensive review template for Confidential Information Memorandums
|
Comprehensive review template for Confidential Information Memorandums
|
||||||
</p>
|
</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>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
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 className="h-4 w-4 mr-2" />
|
||||||
Save
|
{saveStatus === 'saving' ? 'Saving...' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
import { cn } from '../utils/cn';
|
import { cn } from '../utils/cn';
|
||||||
import CIMReviewTemplate from './CIMReviewTemplate';
|
import CIMReviewTemplate from './CIMReviewTemplate';
|
||||||
import LogoutButton from './LogoutButton';
|
import LogoutButton from './LogoutButton';
|
||||||
|
import { documentService } from '../services/documentService';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
|
||||||
interface ExtractedData {
|
interface ExtractedData {
|
||||||
@@ -55,6 +57,7 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
|
|||||||
onDownload,
|
onDownload,
|
||||||
onShare,
|
onShare,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { user } = useAuth();
|
||||||
const [activeTab, setActiveTab] = useState<'overview' | 'template' | 'raw'>('overview');
|
const [activeTab, setActiveTab] = useState<'overview' | 'template' | 'raw'>('overview');
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
@@ -63,6 +66,71 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
|
|||||||
{ id: 'raw', label: 'Raw Data', icon: FileText },
|
{ 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 = () => (
|
const renderOverview = () => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Document Header */}
|
{/* Document Header */}
|
||||||
@@ -81,11 +149,11 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
|
|||||||
Download
|
Download
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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" />
|
<Share2 className="h-4 w-4 mr-2" />
|
||||||
Share
|
Share via Email
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -348,6 +416,8 @@ const DocumentViewer: React.FC<DocumentViewerProps> = ({
|
|||||||
<CIMReviewTemplate
|
<CIMReviewTemplate
|
||||||
initialData={cimReviewData}
|
initialData={cimReviewData}
|
||||||
cimReviewData={cimReviewData}
|
cimReviewData={cimReviewData}
|
||||||
|
onSave={handleSaveCIMReview}
|
||||||
|
onExport={handleExportCIMReview}
|
||||||
readOnly={false}
|
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;
|
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
|
* Get document analytics and insights
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user