2025-07-22 08:50:48 +02:00
|
|
|
/**
|
|
|
|
|
* Export Management Module
|
|
|
|
|
* Handles PDF and CSV export functionality
|
|
|
|
|
*/
|
|
|
|
|
class ExportManager {
|
|
|
|
|
constructor(calculator, uiManager) {
|
|
|
|
|
this.calculator = calculator;
|
|
|
|
|
this.uiManager = uiManager;
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-23 12:04:08 +02:00
|
|
|
async exportToPDF() {
|
2025-07-22 08:50:48 +02:00
|
|
|
// Check if jsPDF is available
|
|
|
|
|
if (typeof window.jspdf === 'undefined') {
|
|
|
|
|
alert('PDF export library is loading. Please try again in a moment.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-23 12:04:08 +02:00
|
|
|
// Show model selection dialog
|
|
|
|
|
const selectedModels = await this.showModelSelectionDialog();
|
|
|
|
|
if (!selectedModels) {
|
|
|
|
|
return; // User cancelled
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-22 08:50:48 +02:00
|
|
|
try {
|
|
|
|
|
const { jsPDF } = window.jspdf;
|
2025-07-23 12:04:08 +02:00
|
|
|
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]
|
|
|
|
|
};
|
2025-07-22 08:50:48 +02:00
|
|
|
|
2025-07-23 12:04:08 +02:00
|
|
|
let currentPage = 1;
|
|
|
|
|
let yPos = 30;
|
2025-07-22 08:50:48 +02:00
|
|
|
|
2025-07-23 12:04:08 +02:00
|
|
|
// Helper function to add new page with header/footer
|
|
|
|
|
const addPage = () => {
|
|
|
|
|
doc.addPage();
|
|
|
|
|
currentPage++;
|
|
|
|
|
yPos = 30;
|
|
|
|
|
this.addPageHeader(doc, margin, colors);
|
|
|
|
|
};
|
2025-07-22 08:50:48 +02:00
|
|
|
|
2025-07-23 12:04:08 +02:00
|
|
|
// Helper function to check if we need a new page
|
|
|
|
|
const checkPageBreak = (requiredSpace) => {
|
|
|
|
|
if (yPos + requiredSpace > pageHeight - 30) {
|
|
|
|
|
addPage();
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-07-22 08:50:48 +02:00
|
|
|
|
2025-07-23 12:04:08 +02:00
|
|
|
// Title Page
|
|
|
|
|
this.createTitlePage(doc, pageWidth, pageHeight, margin, colors);
|
|
|
|
|
|
|
|
|
|
// Executive Summary Page
|
|
|
|
|
addPage();
|
|
|
|
|
yPos = this.createExecutiveSummary(doc, yPos, margin, colors);
|
2025-07-22 08:50:48 +02:00
|
|
|
|
2025-07-23 12:04:08 +02:00
|
|
|
// Investment Parameters Page
|
|
|
|
|
checkPageBreak(60);
|
|
|
|
|
yPos = this.createParametersSection(doc, yPos, margin, colors);
|
2025-07-22 08:50:48 +02:00
|
|
|
|
2025-07-23 12:04:08 +02:00
|
|
|
// Model Comparison Page
|
|
|
|
|
checkPageBreak(80);
|
|
|
|
|
yPos = this.createModelComparison(doc, yPos, margin, colors);
|
2025-07-22 08:50:48 +02:00
|
|
|
|
2025-07-23 12:04:08 +02:00
|
|
|
// 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);
|
2025-07-22 08:50:48 +02:00
|
|
|
|
2025-07-23 12:04:08 +02:00
|
|
|
// 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);
|
2025-07-23 14:50:53 +02:00
|
|
|
doc.text(`Investment Amount: ${this.formatCurrency(inputs.investmentAmount)}`, pageWidth/2, boxY + 22, { align: 'center' });
|
2025-07-23 12:04:08 +02:00
|
|
|
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);
|
2025-07-23 14:50:53 +02:00
|
|
|
doc.text(`Average Net Profit: ${this.formatCurrency(avgDirectNetPos)}`, margin + 5, yPos + 24);
|
2025-07-23 12:04:08 +02:00
|
|
|
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');
|
2025-07-22 08:50:48 +02:00
|
|
|
doc.setFontSize(11);
|
2025-07-23 12:04:08 +02:00
|
|
|
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);
|
2025-07-23 14:50:53 +02:00
|
|
|
doc.text(`Average Net Profit: ${this.formatCurrency(avgLoanNetPos)}`, loanBoxX + 5, yPos + 24);
|
2025-07-23 12:04:08 +02:00
|
|
|
doc.text('Fixed returns, guaranteed', loanBoxX + 5, yPos + 32);
|
|
|
|
|
|
|
|
|
|
yPos += 45;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return yPos;
|
|
|
|
|
}
|
2025-07-22 08:50:48 +02:00
|
|
|
|
2025-07-23 12:04:08 +02:00
|
|
|
createParametersSection(doc, yPos, margin, colors) {
|
|
|
|
|
doc.setFontSize(16);
|
|
|
|
|
doc.setTextColor(...colors.primary);
|
|
|
|
|
doc.setFont('helvetica', 'bold');
|
|
|
|
|
doc.text('Investment Parameters', margin, yPos);
|
|
|
|
|
yPos += 15;
|
2025-07-22 08:50:48 +02:00
|
|
|
|
2025-07-23 12:04:08 +02:00
|
|
|
const inputs = this.calculator.getInputValues();
|
|
|
|
|
|
|
|
|
|
// Create parameter table
|
|
|
|
|
const params = [
|
2025-07-23 14:50:53 +02:00
|
|
|
['Investment Amount', this.formatCurrency(inputs.investmentAmount)],
|
2025-07-23 12:04:08 +02:00
|
|
|
['Investment Timeframe', `${inputs.timeframe} years`],
|
2025-07-23 14:50:53 +02:00
|
|
|
['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`],
|
2025-07-23 12:04:08 +02:00
|
|
|
['Loan Interest Rate', `${(inputs.loanInterestRate * 100).toFixed(1)}%`],
|
|
|
|
|
['Direct Investment Share', `${(inputs.servalaShare * 100).toFixed(0)}% to Servala`],
|
|
|
|
|
['Grace Period', `${inputs.gracePeriod} months`]
|
|
|
|
|
];
|
2025-07-22 08:50:48 +02:00
|
|
|
|
2025-07-23 12:04:08 +02:00
|
|
|
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,
|
2025-07-23 14:50:53 +02:00
|
|
|
this.formatCurrency(directResult.netPosition),
|
2025-07-23 12:04:08 +02:00
|
|
|
this.uiManager.formatPercentage(directResult.roi),
|
2025-07-23 14:50:53 +02:00
|
|
|
this.formatCurrency(loanResult.netPosition),
|
2025-07-23 12:04:08 +02:00
|
|
|
this.uiManager.formatPercentage(loanResult.roi)
|
|
|
|
|
]);
|
2025-07-22 08:50:48 +02:00
|
|
|
}
|
2025-07-23 12:04:08 +02:00
|
|
|
});
|
2025-07-22 08:50:48 +02:00
|
|
|
|
2025-07-23 12:04:08 +02:00
|
|
|
// 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;
|
|
|
|
|
}
|
2025-07-22 08:50:48 +02:00
|
|
|
|
2025-07-23 12:04:08 +02:00
|
|
|
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'
|
|
|
|
|
];
|
2025-07-22 08:50:48 +02:00
|
|
|
|
2025-07-23 12:04:08 +02:00
|
|
|
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();
|
2025-07-22 08:50:48 +02:00
|
|
|
}
|
2025-07-23 12:04:08 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2025-07-22 08:50:48 +02:00
|
|
|
|
2025-07-23 12:04:08 +02:00
|
|
|
// 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',
|
2025-07-23 14:50:53 +02:00
|
|
|
this.formatCurrency(result.netPosition),
|
2025-07-23 12:04:08 +02:00
|
|
|
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');
|
2025-07-22 08:50:48 +02:00
|
|
|
}
|
2025-07-23 12:04:08 +02:00
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
}
|
2025-07-22 08:50:48 +02:00
|
|
|
|
2025-07-23 12:04:08 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-07-22 08:50:48 +02:00
|
|
|
|
2025-07-23 14:50:53 +02:00
|
|
|
formatCurrency(amount) {
|
2025-07-23 12:04:08 +02:00
|
|
|
try {
|
2025-07-23 14:50:53 +02:00
|
|
|
// 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, {
|
2025-07-23 12:04:08 +02:00
|
|
|
style: 'currency',
|
2025-07-23 14:50:53 +02:00
|
|
|
currency: currency,
|
2025-07-23 12:04:08 +02:00
|
|
|
minimumFractionDigits: 0,
|
|
|
|
|
maximumFractionDigits: 0
|
|
|
|
|
}).format(amount);
|
2025-07-22 08:50:48 +02:00
|
|
|
} catch (error) {
|
2025-07-23 14:50:53 +02:00
|
|
|
console.error('Error formatting currency:', error);
|
2025-07-23 12:04:08 +02:00
|
|
|
return `CHF ${Math.round(amount).toLocaleString()}`;
|
2025-07-22 08:50:48 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
2025-07-23 14:50:53 +02:00
|
|
|
csvContent += `Currency,${inputs.currency}\n`;
|
2025-07-22 08:50:48 +02:00
|
|
|
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`;
|
|
|
|
|
}
|
2025-07-23 14:50:53 +02:00
|
|
|
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`;
|
2025-07-22 08:50:48 +02:00
|
|
|
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';
|
2025-07-23 14:50:53 +02:00
|
|
|
csvContent += `Scenario,Investment Model,Final Instances,Total Revenue (${inputs.currency}),CSP Revenue (${inputs.currency}),Servala Revenue (${inputs.currency}),ROI (%),Break-even (months)\n`;
|
2025-07-22 08:50:48 +02:00
|
|
|
|
|
|
|
|
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';
|
2025-07-23 14:50:53 +02:00
|
|
|
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`;
|
2025-07-22 08:50:48 +02:00
|
|
|
|
|
|
|
|
// 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 => {
|
2025-07-23 14:50:53 +02:00
|
|
|
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`;
|
2025-07-22 08:50:48 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|