redesign ROI PDF

This commit is contained in:
Tobias Brunner 2025-07-23 12:04:08 +02:00
parent c1ed95eff5
commit a07788cb74
Signed by: tobru
SSH key fingerprint: SHA256:kOXg1R6c11XW3/Pt9dbLdQvOJGFAy+B2K6v6PtRWBGQ

View file

@ -8,149 +8,83 @@ class ExportManager {
this.uiManager = uiManager; this.uiManager = uiManager;
} }
exportToPDF() { async exportToPDF() {
// Check if jsPDF is available // Check if jsPDF is available
if (typeof window.jspdf === 'undefined') { if (typeof window.jspdf === 'undefined') {
alert('PDF export library is loading. Please try again in a moment.'); alert('PDF export library is loading. Please try again in a moment.');
return; return;
} }
// Show model selection dialog
const selectedModels = await this.showModelSelectionDialog();
if (!selectedModels) {
return; // User cancelled
}
try { try {
const { jsPDF } = window.jspdf; const { jsPDF } = window.jspdf;
const doc = new jsPDF(); const doc = new jsPDF('p', 'mm', 'a4');
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const margin = 20;
// Add header // Store selected models for use throughout the export
doc.setFontSize(20); this.selectedModels = selectedModels;
doc.setTextColor(0, 123, 255); // Bootstrap primary blue
doc.text('CSP ROI Calculator Report', 20, 25);
// Add generation date // Color scheme
doc.setFontSize(10); const colors = {
doc.setTextColor(100, 100, 100); primary: [0, 123, 255],
const currentDate = new Date().toLocaleDateString('en-US', { success: [40, 167, 69],
year: 'numeric', warning: [255, 193, 7],
month: 'long', danger: [220, 53, 69],
day: 'numeric' dark: [33, 37, 41],
}); muted: [108, 117, 125]
doc.text(`Generated on: ${currentDate}`, 20, 35); };
// Reset text color let currentPage = 1;
doc.setTextColor(0, 0, 0); let yPos = 30;
// Add input parameters section // Helper function to add new page with header/footer
doc.setFontSize(16); const addPage = () => {
doc.text('Investment Parameters', 20, 50);
const inputs = this.calculator.getInputValues();
let yPos = 60;
doc.setFontSize(11);
const params = [
['Investment Amount:', this.uiManager.formatCurrencyDetailed(inputs.investmentAmount)],
['Investment Timeframe:', `${inputs.timeframe} years`],
['Investment Model:', inputs.investmentModel === 'loan' ? 'Loan Model' : 'Direct Investment'],
...(inputs.investmentModel === 'loan' ? [['Loan Interest Rate:', `${(inputs.loanInterestRate * 100).toFixed(1)}%`]] : []),
['Revenue per Instance:', this.uiManager.formatCurrencyDetailed(inputs.revenuePerInstance)],
...(inputs.investmentModel === 'direct' ? [
['Servala Revenue Share:', `${(inputs.servalaShare * 100).toFixed(0)}%`],
['Grace Period:', `${inputs.gracePeriod} months`]
] : [])
];
params.forEach(([label, value]) => {
doc.text(label, 25, yPos);
doc.text(value, 80, yPos);
yPos += 8;
});
// Add scenario results section
yPos += 10;
doc.setFontSize(16);
doc.text('Scenario Results', 20, yPos);
yPos += 10;
doc.setFontSize(11);
Object.values(this.calculator.results).forEach(result => {
if (yPos > 250) {
doc.addPage();
yPos = 20;
}
// Scenario header
doc.setFontSize(14);
doc.setTextColor(0, 123, 255);
doc.text(`${result.scenario} Scenario`, 25, yPos);
yPos += 10;
doc.setFontSize(11);
doc.setTextColor(0, 0, 0);
const resultData = [
['Final Instances:', result.finalInstances.toLocaleString()],
['Total Revenue:', this.uiManager.formatCurrencyDetailed(result.totalRevenue)],
['CSP Revenue:', this.uiManager.formatCurrencyDetailed(result.cspRevenue)],
['Servala Revenue:', this.uiManager.formatCurrencyDetailed(result.servalaRevenue)],
['ROI:', this.uiManager.formatPercentage(result.roi)],
['Break-even:', result.breakEvenMonth ? `${result.breakEvenMonth} months` : 'Not achieved']
];
resultData.forEach(([label, value]) => {
doc.text(label, 30, yPos);
doc.text(value, 90, yPos);
yPos += 7;
});
yPos += 8;
});
// Add summary section
if (yPos > 220) {
doc.addPage(); doc.addPage();
yPos = 20; currentPage++;
} yPos = 30;
this.addPageHeader(doc, margin, colors);
};
yPos += 10; // Helper function to check if we need a new page
doc.setFontSize(16); const checkPageBreak = (requiredSpace) => {
doc.text('Executive Summary', 20, yPos); if (yPos + requiredSpace > pageHeight - 30) {
yPos += 10; addPage();
doc.setFontSize(11);
const enabledResults = Object.values(this.calculator.results);
if (enabledResults.length > 0) {
const avgROI = enabledResults.reduce((sum, r) => sum + r.roi, 0) / enabledResults.length;
const avgBreakeven = enabledResults.filter(r => r.breakEvenMonth).reduce((sum, r) => sum + r.breakEvenMonth, 0) / enabledResults.filter(r => r.breakEvenMonth).length;
doc.text(`This analysis evaluates ${enabledResults.length} growth scenario(s) over a ${inputs.timeframe}-year period.`, 25, yPos);
yPos += 8;
doc.text(`Average projected ROI: ${this.uiManager.formatPercentage(avgROI)}`, 25, yPos);
yPos += 8;
if (!isNaN(avgBreakeven)) {
doc.text(`Average break-even timeline: ${Math.round(avgBreakeven)} months`, 25, yPos);
yPos += 8;
} }
};
yPos += 5; // Title Page
doc.text('Key assumptions:', 25, yPos); this.createTitlePage(doc, pageWidth, pageHeight, margin, colors);
yPos += 8;
doc.text('• Growth rates based on market analysis and industry benchmarks', 30, yPos);
yPos += 6;
doc.text('• Churn rates reflect typical SaaS industry standards', 30, yPos);
yPos += 6;
doc.text('• Revenue calculations include grace period provisions', 30, yPos);
}
// Add footer // Executive Summary Page
const pageCount = doc.internal.getNumberOfPages(); addPage();
for (let i = 1; i <= pageCount; i++) { yPos = this.createExecutiveSummary(doc, yPos, margin, colors);
doc.setPage(i);
doc.setFontSize(8); // Investment Parameters Page
doc.setTextColor(150, 150, 150); checkPageBreak(60);
doc.text(`Page ${i} of ${pageCount}`, doc.internal.pageSize.getWidth() - 30, doc.internal.pageSize.getHeight() - 10); yPos = this.createParametersSection(doc, yPos, margin, colors);
doc.text('Generated by Servala CSP ROI Calculator', 20, doc.internal.pageSize.getHeight() - 10);
} // 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 // Save the PDF
const filename = `servala-csp-roi-report-${new Date().toISOString().split('T')[0]}.pdf`; const filename = `servala-roi-investment-analysis-${new Date().toISOString().split('T')[0]}.pdf`;
doc.save(filename); doc.save(filename);
} catch (error) { } catch (error) {
@ -159,6 +93,424 @@ class ExportManager {
} }
} }
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.formatCHF(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.formatCHF(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.formatCHF(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.formatCHF(inputs.investmentAmount)],
['Investment Timeframe', `${inputs.timeframe} years`],
['Revenue per Instance', `${this.formatCHF(inputs.revenuePerInstance)} / 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.formatCHF(directResult.netPosition),
this.uiManager.formatPercentage(directResult.roi),
this.formatCHF(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.formatCHF(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);
}
});
});
}
formatCHF(amount) {
try {
// Consistent CHF formatting: CHF in front, no decimals for whole numbers
return new Intl.NumberFormat('de-CH', {
style: 'currency',
currency: 'CHF',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
} catch (error) {
console.error('Error formatting CHF:', error);
return `CHF ${Math.round(amount).toLocaleString()}`;
}
}
exportToCSV() { exportToCSV() {
try { try {
// Create comprehensive CSV with summary and detailed data // Create comprehensive CSV with summary and detailed data