website/hub/services/static/js/roi-calculator/export-manager.js

599 lines
No EOL
25 KiB
JavaScript

/**
* Export Management Module
* Handles PDF and CSV export functionality
*/
class ExportManager {
constructor(calculator, uiManager) {
this.calculator = calculator;
this.uiManager = uiManager;
}
async exportToPDF() {
// Check if jsPDF is available
if (typeof window.jspdf === 'undefined') {
alert('PDF export library is loading. Please try again in a moment.');
return;
}
// Show model selection dialog
const selectedModels = await this.showModelSelectionDialog();
if (!selectedModels) {
return; // User cancelled
}
try {
const { jsPDF } = window.jspdf;
const doc = new jsPDF('p', 'mm', 'a4');
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const margin = 20;
// Store selected models for use throughout the export
this.selectedModels = selectedModels;
// Color scheme
const colors = {
primary: [0, 123, 255],
success: [40, 167, 69],
warning: [255, 193, 7],
danger: [220, 53, 69],
dark: [33, 37, 41],
muted: [108, 117, 125]
};
let currentPage = 1;
let yPos = 30;
// Helper function to add new page with header/footer
const addPage = () => {
doc.addPage();
currentPage++;
yPos = 30;
this.addPageHeader(doc, margin, colors);
};
// Helper function to check if we need a new page
const checkPageBreak = (requiredSpace) => {
if (yPos + requiredSpace > pageHeight - 30) {
addPage();
}
};
// Title Page
this.createTitlePage(doc, pageWidth, pageHeight, margin, colors);
// Executive Summary Page
addPage();
yPos = this.createExecutiveSummary(doc, yPos, margin, colors);
// Investment Parameters Page
checkPageBreak(60);
yPos = this.createParametersSection(doc, yPos, margin, colors);
// Model Comparison Page
checkPageBreak(80);
yPos = this.createModelComparison(doc, yPos, margin, colors);
// Charts Pages
await this.addChartsToPDF(doc, colors, margin, addPage, checkPageBreak);
// Detailed Results Table
this.createDetailedResults(doc, colors, margin, addPage);
// Add footers to all pages
this.addFootersToAllPages(doc, currentPage, colors);
// Save the PDF
const filename = `servala-roi-investment-analysis-${new Date().toISOString().split('T')[0]}.pdf`;
doc.save(filename);
} catch (error) {
console.error('PDF Export Error:', error);
alert('An error occurred while generating the PDF. Please try again or export as CSV instead.');
}
}
createTitlePage(doc, pageWidth, pageHeight, margin, colors) {
// Background design element
doc.setFillColor(...colors.primary);
doc.rect(0, 0, pageWidth, 60, 'F');
// White title text
doc.setTextColor(255, 255, 255);
doc.setFontSize(28);
doc.setFont('helvetica', 'bold');
doc.text('Investment Analysis Report', pageWidth/2, 35, { align: 'center' });
// Subtitle
doc.setFontSize(14);
doc.setFont('helvetica', 'normal');
doc.text('CSP Partnership ROI Calculator', pageWidth/2, 45, { align: 'center' });
// Company section
doc.setTextColor(...colors.dark);
doc.setFontSize(20);
doc.setFont('helvetica', 'bold');
doc.text('Servala Partnership Opportunity', pageWidth/2, 90, { align: 'center' });
// Investment summary box
const inputs = this.calculator.getInputValues();
const boxY = 110;
const boxHeight = 40;
doc.setFillColor(248, 249, 250);
doc.roundedRect(margin, boxY, pageWidth - 2*margin, boxHeight, 3, 3, 'F');
doc.setDrawColor(...colors.muted);
doc.roundedRect(margin, boxY, pageWidth - 2*margin, boxHeight, 3, 3, 'S');
doc.setFontSize(16);
doc.setTextColor(...colors.primary);
doc.text('Investment Overview', pageWidth/2, boxY + 12, { align: 'center' });
doc.setFontSize(12);
doc.setTextColor(...colors.dark);
doc.text(`Investment Amount: ${this.formatCurrency(inputs.investmentAmount)}`, pageWidth/2, boxY + 22, { align: 'center' });
doc.text(`Analysis Period: ${inputs.timeframe} years`, pageWidth/2, boxY + 32, { align: 'center' });
// Generated date
doc.setFontSize(10);
doc.setTextColor(...colors.muted);
const currentDate = new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
doc.text(`Generated on: ${currentDate}`, pageWidth/2, pageHeight - 30, { align: 'center' });
}
addPageHeader(doc, margin, colors) {
doc.setFillColor(...colors.primary);
doc.rect(0, 0, doc.internal.pageSize.getWidth(), 15, 'F');
doc.setTextColor(255, 255, 255);
doc.setFontSize(10);
doc.setFont('helvetica', 'bold');
doc.text('Servala CSP Investment Analysis', margin, 10);
}
createExecutiveSummary(doc, yPos, margin, colors) {
doc.setFontSize(18);
doc.setTextColor(...colors.primary);
doc.setFont('helvetica', 'bold');
doc.text('Executive Summary', margin, yPos);
yPos += 15;
const enabledResults = Object.values(this.calculator.results);
const directResults = enabledResults.filter(r => r.investmentModel === 'direct' && this.selectedModels.direct);
const loanResults = enabledResults.filter(r => r.investmentModel === 'loan' && this.selectedModels.loan);
doc.setFontSize(11);
doc.setTextColor(...colors.dark);
doc.setFont('helvetica', 'normal');
// Investment models comparison
if (directResults.length > 0 && loanResults.length > 0) {
const avgDirectROI = directResults.reduce((sum, r) => sum + r.roi, 0) / directResults.length;
const avgLoanROI = loanResults.reduce((sum, r) => sum + r.roi, 0) / loanResults.length;
const avgDirectNetPos = directResults.reduce((sum, r) => sum + r.netPosition, 0) / directResults.length;
const avgLoanNetPos = loanResults.reduce((sum, r) => sum + r.netPosition, 0) / loanResults.length;
doc.text('This analysis compares two investment models across multiple growth scenarios:', margin, yPos);
yPos += 10;
// Direct Investment Summary
doc.setFillColor(40, 167, 69, 0.1); // Success color with opacity
doc.roundedRect(margin, yPos, (doc.internal.pageSize.getWidth() - 3*margin)/2, 35, 2, 2, 'F');
doc.setTextColor(...colors.success);
doc.setFont('helvetica', 'bold');
doc.text('Direct Investment Model', margin + 5, yPos + 8);
doc.setTextColor(...colors.dark);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.text(`Average ROI: ${this.uiManager.formatPercentage(avgDirectROI)}`, margin + 5, yPos + 16);
doc.text(`Average Net Profit: ${this.formatCurrency(avgDirectNetPos)}`, margin + 5, yPos + 24);
doc.text('Performance-based with bonuses', margin + 5, yPos + 32);
// Loan Model Summary
const loanBoxX = margin + (doc.internal.pageSize.getWidth() - 3*margin)/2 + 10;
doc.setFillColor(255, 193, 7, 0.1); // Warning color with opacity
doc.roundedRect(loanBoxX, yPos, (doc.internal.pageSize.getWidth() - 3*margin)/2, 35, 2, 2, 'F');
doc.setTextColor(...colors.warning);
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.text('Loan Model', loanBoxX + 5, yPos + 8);
doc.setTextColor(...colors.dark);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.text(`Average ROI: ${this.uiManager.formatPercentage(avgLoanROI)}`, loanBoxX + 5, yPos + 16);
doc.text(`Average Net Profit: ${this.formatCurrency(avgLoanNetPos)}`, loanBoxX + 5, yPos + 24);
doc.text('Fixed returns, guaranteed', loanBoxX + 5, yPos + 32);
yPos += 45;
}
return yPos;
}
createParametersSection(doc, yPos, margin, colors) {
doc.setFontSize(16);
doc.setTextColor(...colors.primary);
doc.setFont('helvetica', 'bold');
doc.text('Investment Parameters', margin, yPos);
yPos += 15;
const inputs = this.calculator.getInputValues();
// Create parameter table
const params = [
['Investment Amount', this.formatCurrency(inputs.investmentAmount)],
['Investment Timeframe', `${inputs.timeframe} years`],
['Service Revenue per Instance', `${this.formatCurrency(inputs.revenuePerInstance)} / month`],
['Core Service Revenue per Instance', `${this.formatCurrency(inputs.coreServiceRevenue)} / month`],
['Total Revenue per Instance', `${this.formatCurrency(inputs.revenuePerInstance + inputs.coreServiceRevenue)} / month`],
['Loan Interest Rate', `${(inputs.loanInterestRate * 100).toFixed(1)}%`],
['Direct Investment Share', `${(inputs.servalaShare * 100).toFixed(0)}% to Servala`],
['Grace Period', `${inputs.gracePeriod} months`]
];
doc.setFontSize(11);
params.forEach(([label, value]) => {
doc.setTextColor(...colors.dark);
doc.setFont('helvetica', 'normal');
doc.text(label + ':', margin + 5, yPos);
doc.setFont('helvetica', 'bold');
doc.text(value, margin + 80, yPos);
yPos += 8;
});
return yPos + 10;
}
createModelComparison(doc, yPos, margin, colors) {
doc.setFontSize(16);
doc.setTextColor(...colors.primary);
doc.setFont('helvetica', 'bold');
doc.text('Investment Model Comparison', margin, yPos);
yPos += 15;
// Create comparison table
const scenarios = ['Conservative', 'Moderate', 'Aggressive'];
const tableData = [];
scenarios.forEach(scenarioName => {
const directResult = Object.values(this.calculator.results)
.find(r => r.scenario === scenarioName && r.investmentModel === 'direct');
const loanResult = Object.values(this.calculator.results)
.find(r => r.scenario === scenarioName && r.investmentModel === 'loan');
if (directResult && loanResult) {
tableData.push([
scenarioName,
this.formatCurrency(directResult.netPosition),
this.uiManager.formatPercentage(directResult.roi),
this.formatCurrency(loanResult.netPosition),
this.uiManager.formatPercentage(loanResult.roi)
]);
}
});
// Table headers
const headers = ['Scenario', 'Direct Net Profit', 'Direct ROI', 'Loan Net Profit', 'Loan ROI'];
const colWidths = [30, 35, 25, 35, 25];
// Draw table
this.drawTable(doc, margin, yPos, headers, tableData, colWidths, colors);
return yPos + (tableData.length + 2) * 8;
}
async addChartsToPDF(doc, colors, margin, addPage, checkPageBreak) {
// Add charts by capturing them as images
const chartIds = ['instanceGrowthChart', 'revenueChart', 'cashFlowChart', 'modelComparisonChart'];
const chartTitles = [
'ROI Progression Over Time',
'Net Financial Position',
'Performance Comparison',
'Investment Model Comparison'
];
for (let i = 0; i < chartIds.length; i++) {
checkPageBreak(120);
const canvas = document.getElementById(chartIds[i]);
if (canvas) {
// Add chart title
doc.setFontSize(14);
doc.setTextColor(...colors.primary);
doc.setFont('helvetica', 'bold');
doc.text(chartTitles[i], margin, doc.internal.pageSize.getHeight() - 250);
// Capture chart as image
const imgData = canvas.toDataURL('image/png', 1.0);
doc.addImage(imgData, 'PNG', margin, doc.internal.pageSize.getHeight() - 240,
doc.internal.pageSize.getWidth() - 2*margin, 100);
if (i < chartIds.length - 1) addPage();
}
}
}
createDetailedResults(doc, colors, margin, addPage) {
addPage();
let yPos = 30;
doc.setFontSize(16);
doc.setTextColor(...colors.primary);
doc.setFont('helvetica', 'bold');
doc.text('Detailed Financial Results', margin, yPos);
yPos += 15;
// Create detailed results table
const headers = ['Scenario', 'Model', 'Net Profit', 'ROI', 'Break-even'];
const colWidths = [35, 25, 35, 25, 30];
const tableData = [];
Object.values(this.calculator.results).forEach(result => {
tableData.push([
result.scenario,
result.investmentModel === 'direct' ? 'Direct' : 'Loan',
this.formatCurrency(result.netPosition),
this.uiManager.formatPercentage(result.roi),
result.breakEvenMonth ? `${result.breakEvenMonth} months` : 'N/A'
]);
});
this.drawTable(doc, margin, yPos, headers, tableData, colWidths, colors);
}
drawTable(doc, x, y, headers, data, colWidths, colors) {
const rowHeight = 8;
const startX = x;
let currentY = y;
// Draw headers
doc.setFillColor(...colors.primary);
doc.rect(startX, currentY - 6, colWidths.reduce((sum, w) => sum + w, 0), rowHeight, 'F');
doc.setTextColor(255, 255, 255);
doc.setFont('helvetica', 'bold');
doc.setFontSize(10);
let currentX = startX;
headers.forEach((header, i) => {
doc.text(header, currentX + 2, currentY - 1);
currentX += colWidths[i];
});
currentY += rowHeight;
// Draw data rows
doc.setTextColor(...colors.dark);
doc.setFont('helvetica', 'normal');
data.forEach((row, rowIndex) => {
if (rowIndex % 2 === 1) {
doc.setFillColor(248, 249, 250);
doc.rect(startX, currentY - 6, colWidths.reduce((sum, w) => sum + w, 0), rowHeight, 'F');
}
currentX = startX;
row.forEach((cell, i) => {
doc.text(String(cell), currentX + 2, currentY - 1);
currentX += colWidths[i];
});
currentY += rowHeight;
});
// Draw table border
doc.setDrawColor(...colors.muted);
doc.rect(startX, y - 6, colWidths.reduce((sum, w) => sum + w, 0), (data.length + 1) * rowHeight, 'S');
}
addFootersToAllPages(doc, totalPages, colors) {
for (let i = 1; i <= totalPages; i++) {
doc.setPage(i);
const pageHeight = doc.internal.pageSize.getHeight();
const pageWidth = doc.internal.pageSize.getWidth();
doc.setFontSize(8);
doc.setTextColor(...colors.muted);
doc.text(`Page ${i} of ${totalPages}`, pageWidth - 30, pageHeight - 10);
doc.text('Generated by Servala CSP ROI Calculator', 20, pageHeight - 10);
// Add a line above footer
doc.setDrawColor(...colors.muted);
doc.line(20, pageHeight - 15, pageWidth - 20, pageHeight - 15);
}
}
async showModelSelectionDialog() {
return new Promise((resolve) => {
// Create modal dialog
const modal = document.createElement('div');
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
`;
const dialog = document.createElement('div');
dialog.style.cssText = `
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
max-width: 400px;
width: 90%;
`;
dialog.innerHTML = `
<h5 style="margin-bottom: 1rem; color: #007bff;">Select Investment Models for PDF</h5>
<p style="margin-bottom: 1.5rem; color: #6c757d;">Choose which models to include in your PDF report:</p>
<div style="margin-bottom: 1rem;">
<label style="display: flex; align-items: center; margin-bottom: 0.5rem; cursor: pointer;">
<input type="checkbox" id="pdf-direct-model" checked style="margin-right: 0.5rem;">
<span style="color: #28a745; font-weight: bold;">Direct Investment Model</span>
</label>
<small style="color: #6c757d; margin-left: 1.5rem;">Performance-based revenue sharing with scaling bonuses</small>
</div>
<div style="margin-bottom: 2rem;">
<label style="display: flex; align-items: center; margin-bottom: 0.5rem; cursor: pointer;">
<input type="checkbox" id="pdf-loan-model" checked style="margin-right: 0.5rem;">
<span style="color: #ffc107; font-weight: bold;">Loan Model</span>
</label>
<small style="color: #6c757d; margin-left: 1.5rem;">Fixed interest lending with guaranteed returns</small>
</div>
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
<button id="pdf-cancel-btn" style="padding: 0.5rem 1rem; border: 1px solid #ccc; background: white; border-radius: 4px; cursor: pointer;">Cancel</button>
<button id="pdf-export-btn" style="padding: 0.5rem 1rem; border: none; background: #007bff; color: white; border-radius: 4px; cursor: pointer;">Export PDF</button>
</div>
`;
modal.appendChild(dialog);
document.body.appendChild(modal);
// Handle button clicks
document.getElementById('pdf-cancel-btn').addEventListener('click', () => {
document.body.removeChild(modal);
resolve(null);
});
document.getElementById('pdf-export-btn').addEventListener('click', () => {
const directSelected = document.getElementById('pdf-direct-model').checked;
const loanSelected = document.getElementById('pdf-loan-model').checked;
if (!directSelected && !loanSelected) {
alert('Please select at least one investment model.');
return;
}
document.body.removeChild(modal);
resolve({
direct: directSelected,
loan: loanSelected
});
});
// Close on background click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
document.body.removeChild(modal);
resolve(null);
}
});
});
}
formatCurrency(amount) {
try {
// Get current currency from the page
const currencyElement = document.getElementById('currency');
const currency = currencyElement ? currencyElement.value : 'CHF';
// Determine locale based on currency
const locale = currency === 'EUR' ? 'de-DE' : 'de-CH';
// Consistent currency formatting: currency in front, no decimals for whole numbers
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
} catch (error) {
console.error('Error formatting currency:', error);
return `CHF ${Math.round(amount).toLocaleString()}`;
}
}
exportToCSV() {
try {
// Create comprehensive CSV with summary and detailed data
let csvContent = 'CSP ROI Calculator Export\n';
csvContent += `Generated on: ${new Date().toLocaleDateString()}\n\n`;
// Add input parameters
csvContent += 'INPUT PARAMETERS\n';
const inputs = this.calculator.getInputValues();
csvContent += `Currency,${inputs.currency}\n`;
csvContent += `Investment Amount,${inputs.investmentAmount}\n`;
csvContent += `Timeframe (years),${inputs.timeframe}\n`;
csvContent += `Investment Model,${inputs.investmentModel === 'loan' ? 'Loan Model' : 'Direct Investment'}\n`;
if (inputs.investmentModel === 'loan') {
csvContent += `Loan Interest Rate (%),${(inputs.loanInterestRate * 100).toFixed(1)}\n`;
}
csvContent += `Service Revenue per Instance (${inputs.currency}),${inputs.revenuePerInstance}\n`;
csvContent += `Core Service Revenue per Instance (${inputs.currency}),${inputs.coreServiceRevenue}\n`;
csvContent += `Total Revenue per Instance (${inputs.currency}),${inputs.revenuePerInstance + inputs.coreServiceRevenue}\n`;
if (inputs.investmentModel === 'direct') {
csvContent += `Servala Share (%),${(inputs.servalaShare * 100).toFixed(0)}\n`;
csvContent += `Grace Period (months),${inputs.gracePeriod}\n`;
}
csvContent += '\n';
// Add scenario summary
csvContent += 'SCENARIO SUMMARY\n';
csvContent += `Scenario,Investment Model,Final Instances,Total Revenue (${inputs.currency}),CSP Revenue (${inputs.currency}),Servala Revenue (${inputs.currency}),ROI (%),Break-even (months)\n`;
Object.values(this.calculator.results).forEach(result => {
const modelText = result.investmentModel === 'loan' ? 'Loan' : 'Direct';
csvContent += `${result.scenario},${modelText},${result.finalInstances},${result.totalRevenue.toFixed(2)},${result.cspRevenue.toFixed(2)},${result.servalaRevenue.toFixed(2)},${result.roi.toFixed(2)},${result.breakEvenMonth || 'N/A'}\n`;
});
csvContent += '\n';
// Add detailed monthly data
csvContent += 'MONTHLY BREAKDOWN\n';
csvContent += `Month,Scenario,New Instances,Churned Instances,Total Instances,Service Revenue (${inputs.currency}),Core Revenue (${inputs.currency}),Total Revenue (${inputs.currency}),CSP Revenue (${inputs.currency}),Servala Revenue (${inputs.currency}),Cumulative CSP Revenue (${inputs.currency}),Cumulative Servala Revenue (${inputs.currency})\n`;
// Combine all monthly data
const allData = [];
Object.keys(this.calculator.monthlyData).forEach(scenario => {
this.calculator.monthlyData[scenario].forEach(monthData => {
allData.push(monthData);
});
});
allData.sort((a, b) => a.month - b.month || a.scenario.localeCompare(b.scenario));
allData.forEach(data => {
csvContent += `${data.month},${data.scenario},${data.newInstances},${data.churnedInstances},${data.totalInstances},${(data.serviceRevenue || data.monthlyRevenue || 0).toFixed(2)},${(data.coreRevenue || 0).toFixed(2)},${(data.totalRevenue || data.monthlyRevenue || 0).toFixed(2)},${data.cspRevenue.toFixed(2)},${data.servalaRevenue.toFixed(2)},${data.cumulativeCSPRevenue.toFixed(2)},${data.cumulativeServalaRevenue.toFixed(2)}\n`;
});
// Create and download file
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
const filename = `servala-csp-roi-data-${new Date().toISOString().split('T')[0]}.csv`;
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up
URL.revokeObjectURL(url);
} catch (error) {
console.error('CSV Export Error:', error);
alert('An error occurred while generating the CSV file. Please try again.');
}
}
}