refactor roi calc js into modular files
This commit is contained in:
parent
51d80364c0
commit
afe3817395
11 changed files with 1611 additions and 1144 deletions
|
|
@ -66,7 +66,9 @@ uv run --extra dev manage.py collectstatic
|
||||||
|
|
||||||
### Frontend Assets
|
### Frontend Assets
|
||||||
- Static files in `hub/services/static/`
|
- Static files in `hub/services/static/`
|
||||||
- JavaScript organized by feature (price-calculator/, roi-calculator.js)
|
- JavaScript organized by feature:
|
||||||
|
- `price-calculator/` - Modular price calculator components
|
||||||
|
- `roi-calculator/` - Modular ROI calculator with separate concerns (core, UI, charts, exports)
|
||||||
- CSS using Bootstrap 5 with custom styling
|
- CSS using Bootstrap 5 with custom styling
|
||||||
- Chart.js for data visualization
|
- Chart.js for data visualization
|
||||||
|
|
||||||
|
|
|
||||||
105
hub/services/static/js/roi-calculator-modular.js
Normal file
105
hub/services/static/js/roi-calculator-modular.js
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
/**
|
||||||
|
* ROI Calculator - Modular Version
|
||||||
|
* This file loads all modules and provides global function wrappers for backward compatibility
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Global function wrappers for backward compatibility with existing HTML
|
||||||
|
function updateCalculations() {
|
||||||
|
if (window.ROICalculatorApp) {
|
||||||
|
window.ROICalculatorApp.updateCalculations();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportToPDF() {
|
||||||
|
if (window.ROICalculatorApp) {
|
||||||
|
window.ROICalculatorApp.exportToPDF();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportToCSV() {
|
||||||
|
if (window.ROICalculatorApp) {
|
||||||
|
window.ROICalculatorApp.exportToCSV();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInvestmentAmountInput(input) {
|
||||||
|
InputUtils.handleInvestmentAmountInput(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateInvestmentAmount(value) {
|
||||||
|
if (window.ROICalculatorApp) {
|
||||||
|
window.ROICalculatorApp.updateInvestmentAmount(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRevenuePerInstance(value) {
|
||||||
|
if (window.ROICalculatorApp) {
|
||||||
|
window.ROICalculatorApp.updateRevenuePerInstance(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateServalaShare(value) {
|
||||||
|
if (window.ROICalculatorApp) {
|
||||||
|
window.ROICalculatorApp.updateServalaShare(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGracePeriod(value) {
|
||||||
|
if (window.ROICalculatorApp) {
|
||||||
|
window.ROICalculatorApp.updateGracePeriod(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLoanRate(value) {
|
||||||
|
if (window.ROICalculatorApp) {
|
||||||
|
window.ROICalculatorApp.updateLoanRate(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScenarioChurn(scenarioKey, churnRate) {
|
||||||
|
if (window.ROICalculatorApp) {
|
||||||
|
window.ROICalculatorApp.updateScenarioChurn(scenarioKey, churnRate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScenarioPhase(scenarioKey, phaseIndex, newInstancesPerMonth) {
|
||||||
|
if (window.ROICalculatorApp) {
|
||||||
|
window.ROICalculatorApp.updateScenarioPhase(scenarioKey, phaseIndex, newInstancesPerMonth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAdvancedParameters() {
|
||||||
|
if (window.ROICalculatorApp) {
|
||||||
|
window.ROICalculatorApp.resetAdvancedParameters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleScenario(scenarioKey) {
|
||||||
|
if (window.ROICalculatorApp) {
|
||||||
|
window.ROICalculatorApp.toggleScenario(scenarioKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCollapsible(elementId) {
|
||||||
|
if (window.ROICalculatorApp) {
|
||||||
|
window.ROICalculatorApp.toggleCollapsible(elementId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCalculator() {
|
||||||
|
if (window.ROICalculatorApp) {
|
||||||
|
window.ROICalculatorApp.resetCalculator();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleInvestmentModel() {
|
||||||
|
if (window.ROICalculatorApp) {
|
||||||
|
window.ROICalculatorApp.toggleInvestmentModel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
if (window.ROICalculatorApp) {
|
||||||
|
window.ROICalculatorApp.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
34
hub/services/static/js/roi-calculator/README.md
Normal file
34
hub/services/static/js/roi-calculator/README.md
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# ROI Calculator Modules
|
||||||
|
|
||||||
|
This directory contains the modular ROI Calculator implementation, split into focused, maintainable modules.
|
||||||
|
|
||||||
|
## Module Structure
|
||||||
|
|
||||||
|
### Core Modules
|
||||||
|
|
||||||
|
- **`calculator-core.js`** - Main ROICalculator class with calculation logic
|
||||||
|
- **`chart-manager.js`** - Chart.js integration and chart rendering
|
||||||
|
- **`ui-manager.js`** - DOM updates, table rendering, and metric display
|
||||||
|
- **`export-manager.js`** - PDF and CSV export functionality
|
||||||
|
- **`input-utils.js`** - Input validation, parsing, and formatting utilities
|
||||||
|
- **`roi-calculator-app.js`** - Main application coordinator class
|
||||||
|
|
||||||
|
### Integration
|
||||||
|
|
||||||
|
- **`../roi-calculator-modular.js`** - Global function wrappers for backward compatibility
|
||||||
|
|
||||||
|
## Key Improvements
|
||||||
|
|
||||||
|
1. **Modular Architecture**: Each module has a single responsibility
|
||||||
|
2. **Error Handling**: Comprehensive try-catch blocks with graceful fallbacks
|
||||||
|
3. **No Global Variables**: App instance contained in window.ROICalculatorApp
|
||||||
|
4. **Type Safety**: Input validation and null checks throughout
|
||||||
|
5. **Separation of Concerns**: Calculation, UI, charts, and exports are separated
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
All modules are automatically loaded via the HTML template. The ROICalculatorApp class coordinates all modules and provides the same public API as the original monolithic version.
|
||||||
|
|
||||||
|
## Backward Compatibility
|
||||||
|
|
||||||
|
All existing HTML onclick handlers and function calls continue to work through the global wrapper functions in `roi-calculator-modular.js`.
|
||||||
280
hub/services/static/js/roi-calculator/calculator-core.js
Normal file
280
hub/services/static/js/roi-calculator/calculator-core.js
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
/**
|
||||||
|
* Core ROI Calculator Class
|
||||||
|
* Handles calculation logic and data management
|
||||||
|
*/
|
||||||
|
class ROICalculator {
|
||||||
|
constructor() {
|
||||||
|
this.scenarios = {
|
||||||
|
conservative: {
|
||||||
|
name: 'Conservative',
|
||||||
|
enabled: true,
|
||||||
|
churnRate: 0.02,
|
||||||
|
phases: [
|
||||||
|
{ months: 6, newInstancesPerMonth: 50 },
|
||||||
|
{ months: 6, newInstancesPerMonth: 75 },
|
||||||
|
{ months: 12, newInstancesPerMonth: 100 },
|
||||||
|
{ months: 12, newInstancesPerMonth: 150 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
moderate: {
|
||||||
|
name: 'Moderate',
|
||||||
|
enabled: true,
|
||||||
|
churnRate: 0.03,
|
||||||
|
phases: [
|
||||||
|
{ months: 6, newInstancesPerMonth: 100 },
|
||||||
|
{ months: 6, newInstancesPerMonth: 200 },
|
||||||
|
{ months: 12, newInstancesPerMonth: 300 },
|
||||||
|
{ months: 12, newInstancesPerMonth: 400 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
aggressive: {
|
||||||
|
name: 'Aggressive',
|
||||||
|
enabled: true,
|
||||||
|
churnRate: 0.05,
|
||||||
|
phases: [
|
||||||
|
{ months: 6, newInstancesPerMonth: 200 },
|
||||||
|
{ months: 6, newInstancesPerMonth: 400 },
|
||||||
|
{ months: 12, newInstancesPerMonth: 600 },
|
||||||
|
{ months: 12, newInstancesPerMonth: 800 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.charts = {};
|
||||||
|
this.monthlyData = {};
|
||||||
|
this.results = {};
|
||||||
|
|
||||||
|
// Note: Charts and initial calculation will be handled by the app coordinator
|
||||||
|
}
|
||||||
|
|
||||||
|
getInputValues() {
|
||||||
|
try {
|
||||||
|
// Get investment model with fallback
|
||||||
|
const investmentModelElement = document.querySelector('input[name="investment-model"]:checked');
|
||||||
|
const investmentModel = investmentModelElement ? investmentModelElement.value : 'direct';
|
||||||
|
|
||||||
|
// Get investment amount with validation
|
||||||
|
const investmentAmountElement = document.getElementById('investment-amount');
|
||||||
|
const investmentAmountValue = investmentAmountElement ? investmentAmountElement.getAttribute('data-value') : '500000';
|
||||||
|
const investmentAmount = parseFloat(investmentAmountValue) || 500000;
|
||||||
|
|
||||||
|
// Get timeframe with validation
|
||||||
|
const timeframeElement = document.getElementById('timeframe');
|
||||||
|
const timeframe = timeframeElement ? parseInt(timeframeElement.value) || 3 : 3;
|
||||||
|
|
||||||
|
// Get loan interest rate with validation
|
||||||
|
const loanRateElement = document.getElementById('loan-interest-rate');
|
||||||
|
const loanInterestRate = loanRateElement ? (parseFloat(loanRateElement.value) || 5.0) / 100 : 0.05;
|
||||||
|
|
||||||
|
// Get revenue per instance with validation
|
||||||
|
const revenueElement = document.getElementById('revenue-per-instance');
|
||||||
|
const revenuePerInstance = revenueElement ? parseFloat(revenueElement.value) || 50 : 50;
|
||||||
|
|
||||||
|
// Get servala share with validation
|
||||||
|
const shareElement = document.getElementById('servala-share');
|
||||||
|
const servalaShare = shareElement ? (parseFloat(shareElement.value) || 25) / 100 : 0.25;
|
||||||
|
|
||||||
|
// Get grace period with validation
|
||||||
|
const graceElement = document.getElementById('grace-period');
|
||||||
|
const gracePeriod = graceElement ? parseInt(graceElement.value) || 6 : 6;
|
||||||
|
|
||||||
|
return {
|
||||||
|
investmentAmount,
|
||||||
|
timeframe,
|
||||||
|
investmentModel,
|
||||||
|
loanInterestRate,
|
||||||
|
revenuePerInstance,
|
||||||
|
servalaShare,
|
||||||
|
gracePeriod
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting input values:', error);
|
||||||
|
// Return safe default values
|
||||||
|
return {
|
||||||
|
investmentAmount: 500000,
|
||||||
|
timeframe: 3,
|
||||||
|
investmentModel: 'direct',
|
||||||
|
loanInterestRate: 0.05,
|
||||||
|
revenuePerInstance: 50,
|
||||||
|
servalaShare: 0.25,
|
||||||
|
gracePeriod: 6
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateScenario(scenarioKey, inputs) {
|
||||||
|
try {
|
||||||
|
const scenario = this.scenarios[scenarioKey];
|
||||||
|
if (!scenario.enabled) return null;
|
||||||
|
|
||||||
|
// Calculate loan payment if using loan model
|
||||||
|
let monthlyLoanPayment = 0;
|
||||||
|
if (inputs.investmentModel === 'loan') {
|
||||||
|
const monthlyRate = inputs.loanInterestRate / 12;
|
||||||
|
const numPayments = inputs.timeframe * 12;
|
||||||
|
// Calculate fixed monthly payment using amortization formula
|
||||||
|
if (monthlyRate > 0) {
|
||||||
|
monthlyLoanPayment = inputs.investmentAmount *
|
||||||
|
(monthlyRate * Math.pow(1 + monthlyRate, numPayments)) /
|
||||||
|
(Math.pow(1 + monthlyRate, numPayments) - 1);
|
||||||
|
} else {
|
||||||
|
monthlyLoanPayment = inputs.investmentAmount / numPayments;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate investment scaling factor (only for direct investment)
|
||||||
|
// Base investment of CHF 500,000 = 1.0x multiplier
|
||||||
|
// Higher investments get multiplicative benefits for instance acquisition
|
||||||
|
const baseInvestment = 500000;
|
||||||
|
const investmentScaleFactor = inputs.investmentModel === 'loan' ? 1.0 : Math.sqrt(inputs.investmentAmount / baseInvestment);
|
||||||
|
|
||||||
|
// Calculate churn reduction factor based on investment (only for direct investment)
|
||||||
|
// Higher investment = better customer success = lower churn
|
||||||
|
const churnReductionFactor = inputs.investmentModel === 'loan' ? 1.0 : Math.max(0.7, 1 - (inputs.investmentAmount - baseInvestment) / 2000000 * 0.3);
|
||||||
|
|
||||||
|
// Calculate adjusted churn rate with investment-based reduction
|
||||||
|
const adjustedChurnRate = scenario.churnRate * churnReductionFactor;
|
||||||
|
|
||||||
|
const totalMonths = inputs.timeframe * 12;
|
||||||
|
const monthlyData = [];
|
||||||
|
let currentInstances = 0;
|
||||||
|
let cumulativeCSPRevenue = 0;
|
||||||
|
let cumulativeServalaRevenue = 0;
|
||||||
|
let breakEvenMonth = null;
|
||||||
|
|
||||||
|
// Track phase progression
|
||||||
|
let currentPhase = 0;
|
||||||
|
let monthsInCurrentPhase = 0;
|
||||||
|
|
||||||
|
for (let month = 1; month <= totalMonths; month++) {
|
||||||
|
// Determine current phase
|
||||||
|
if (monthsInCurrentPhase >= scenario.phases[currentPhase].months && currentPhase < scenario.phases.length - 1) {
|
||||||
|
currentPhase++;
|
||||||
|
monthsInCurrentPhase = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate new instances for this month with investment scaling
|
||||||
|
const baseNewInstances = scenario.phases[currentPhase].newInstancesPerMonth;
|
||||||
|
const newInstances = Math.floor(baseNewInstances * investmentScaleFactor);
|
||||||
|
|
||||||
|
// Calculate churn using the pre-calculated adjusted churn rate
|
||||||
|
const churnedInstances = Math.floor(currentInstances * adjustedChurnRate);
|
||||||
|
|
||||||
|
// Update total instances
|
||||||
|
currentInstances = currentInstances + newInstances - churnedInstances;
|
||||||
|
|
||||||
|
// Calculate revenue based on investment model
|
||||||
|
let cspRevenue, servalaRevenue, monthlyRevenue;
|
||||||
|
|
||||||
|
if (inputs.investmentModel === 'loan') {
|
||||||
|
// Loan model: CSP receives fixed monthly loan payment
|
||||||
|
cspRevenue = monthlyLoanPayment;
|
||||||
|
servalaRevenue = 0;
|
||||||
|
monthlyRevenue = monthlyLoanPayment;
|
||||||
|
} else {
|
||||||
|
// Direct investment model: Revenue based on instances
|
||||||
|
monthlyRevenue = currentInstances * inputs.revenuePerInstance;
|
||||||
|
|
||||||
|
// Determine revenue split based on grace period
|
||||||
|
if (month <= inputs.gracePeriod) {
|
||||||
|
cspRevenue = monthlyRevenue;
|
||||||
|
servalaRevenue = 0;
|
||||||
|
} else {
|
||||||
|
cspRevenue = monthlyRevenue * (1 - inputs.servalaShare);
|
||||||
|
servalaRevenue = monthlyRevenue * inputs.servalaShare;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cumulative revenue
|
||||||
|
cumulativeCSPRevenue += cspRevenue;
|
||||||
|
cumulativeServalaRevenue += servalaRevenue;
|
||||||
|
|
||||||
|
// Check for break-even
|
||||||
|
if (breakEvenMonth === null && cumulativeCSPRevenue >= inputs.investmentAmount) {
|
||||||
|
breakEvenMonth = month;
|
||||||
|
}
|
||||||
|
|
||||||
|
monthlyData.push({
|
||||||
|
month,
|
||||||
|
scenario: scenario.name,
|
||||||
|
newInstances,
|
||||||
|
churnedInstances,
|
||||||
|
totalInstances: currentInstances,
|
||||||
|
monthlyRevenue,
|
||||||
|
cspRevenue,
|
||||||
|
servalaRevenue,
|
||||||
|
cumulativeCSPRevenue,
|
||||||
|
cumulativeServalaRevenue,
|
||||||
|
investmentScaleFactor: investmentScaleFactor,
|
||||||
|
adjustedChurnRate: adjustedChurnRate
|
||||||
|
});
|
||||||
|
|
||||||
|
monthsInCurrentPhase++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate final metrics
|
||||||
|
const totalRevenue = cumulativeCSPRevenue + cumulativeServalaRevenue;
|
||||||
|
const roi = ((cumulativeCSPRevenue - inputs.investmentAmount) / inputs.investmentAmount) * 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
scenario: scenario.name,
|
||||||
|
investmentModel: inputs.investmentModel,
|
||||||
|
finalInstances: currentInstances,
|
||||||
|
totalRevenue,
|
||||||
|
cspRevenue: cumulativeCSPRevenue,
|
||||||
|
servalaRevenue: cumulativeServalaRevenue,
|
||||||
|
roi,
|
||||||
|
breakEvenMonth,
|
||||||
|
monthlyData,
|
||||||
|
investmentScaleFactor: investmentScaleFactor,
|
||||||
|
adjustedChurnRate: adjustedChurnRate * 100
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error calculating scenario ${scenarioKey}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCalculations() {
|
||||||
|
try {
|
||||||
|
const inputs = this.getInputValues();
|
||||||
|
this.results = {};
|
||||||
|
this.monthlyData = {};
|
||||||
|
|
||||||
|
// Show loading spinner
|
||||||
|
const loadingSpinner = document.getElementById('loading-spinner');
|
||||||
|
const summaryMetrics = document.getElementById('summary-metrics');
|
||||||
|
|
||||||
|
if (loadingSpinner) loadingSpinner.style.display = 'block';
|
||||||
|
if (summaryMetrics) summaryMetrics.style.display = 'none';
|
||||||
|
|
||||||
|
// Calculate results for each enabled scenario
|
||||||
|
Object.keys(this.scenarios).forEach(scenarioKey => {
|
||||||
|
const result = this.calculateScenario(scenarioKey, inputs);
|
||||||
|
if (result) {
|
||||||
|
this.results[scenarioKey] = result;
|
||||||
|
this.monthlyData[scenarioKey] = result.monthlyData;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
setTimeout(() => {
|
||||||
|
this.updateSummaryMetrics();
|
||||||
|
this.updateCharts();
|
||||||
|
this.updateComparisonTable();
|
||||||
|
this.updateMonthlyBreakdown();
|
||||||
|
|
||||||
|
// Hide loading spinner
|
||||||
|
if (loadingSpinner) loadingSpinner.style.display = 'none';
|
||||||
|
if (summaryMetrics) summaryMetrics.style.display = 'flex';
|
||||||
|
}, 500);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating calculations:', error);
|
||||||
|
// Hide loading spinner on error
|
||||||
|
const loadingSpinner = document.getElementById('loading-spinner');
|
||||||
|
const summaryMetrics = document.getElementById('summary-metrics');
|
||||||
|
if (loadingSpinner) loadingSpinner.style.display = 'none';
|
||||||
|
if (summaryMetrics) summaryMetrics.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
182
hub/services/static/js/roi-calculator/chart-manager.js
Normal file
182
hub/services/static/js/roi-calculator/chart-manager.js
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
/**
|
||||||
|
* Chart Management Module
|
||||||
|
* Handles Chart.js initialization and updates
|
||||||
|
*/
|
||||||
|
class ChartManager {
|
||||||
|
constructor(calculator) {
|
||||||
|
this.calculator = calculator;
|
||||||
|
this.charts = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeCharts() {
|
||||||
|
// Check if Chart.js is available
|
||||||
|
if (typeof Chart === 'undefined') {
|
||||||
|
console.error('Chart.js library not loaded. Charts will not be available.');
|
||||||
|
this.showChartError('Chart.js library failed to load. Please refresh the page.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Instance Growth Chart
|
||||||
|
const instanceCanvas = document.getElementById('instanceGrowthChart');
|
||||||
|
if (!instanceCanvas) {
|
||||||
|
console.error('Instance growth chart canvas not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const instanceCtx = instanceCanvas.getContext('2d');
|
||||||
|
this.charts.instanceGrowth = new Chart(instanceCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: { labels: [], datasets: [] },
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
title: { display: true, text: 'Total Instances' }
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
title: { display: true, text: 'Month' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Revenue Chart
|
||||||
|
const revenueCanvas = document.getElementById('revenueChart');
|
||||||
|
if (!revenueCanvas) {
|
||||||
|
console.error('Revenue chart canvas not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const revenueCtx = revenueCanvas.getContext('2d');
|
||||||
|
this.charts.revenue = new Chart(revenueCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: { labels: [], datasets: [] },
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
title: { display: true, text: 'Cumulative Revenue (CHF)' }
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
title: { display: true, text: 'Month' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cash Flow Chart
|
||||||
|
const cashFlowCanvas = document.getElementById('cashFlowChart');
|
||||||
|
if (!cashFlowCanvas) {
|
||||||
|
console.error('Cash flow chart canvas not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cashFlowCtx = cashFlowCanvas.getContext('2d');
|
||||||
|
this.charts.cashFlow = new Chart(cashFlowCtx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: { labels: [], datasets: [] },
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
title: { display: true, text: 'Monthly Cash Flow (CHF)' }
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
title: { display: true, text: 'Month' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing charts:', error);
|
||||||
|
this.showChartError('Failed to initialize charts. Please refresh the page.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showChartError(message) {
|
||||||
|
// Show error message in place of charts
|
||||||
|
const chartContainers = ['instanceGrowthChart', 'revenueChart', 'cashFlowChart'];
|
||||||
|
chartContainers.forEach(containerId => {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (container) {
|
||||||
|
container.style.display = 'flex';
|
||||||
|
container.style.justifyContent = 'center';
|
||||||
|
container.style.alignItems = 'center';
|
||||||
|
container.style.minHeight = '300px';
|
||||||
|
container.style.backgroundColor = '#f8f9fa';
|
||||||
|
container.style.border = '1px solid #dee2e6';
|
||||||
|
container.style.borderRadius = '4px';
|
||||||
|
container.innerHTML = `<div class="text-muted text-center"><i class="bi bi-exclamation-triangle"></i><br>${message}</div>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCharts() {
|
||||||
|
try {
|
||||||
|
const scenarios = Object.keys(this.calculator.results);
|
||||||
|
if (scenarios.length === 0 || !this.charts.instanceGrowth) return;
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
conservative: '#28a745',
|
||||||
|
moderate: '#ffc107',
|
||||||
|
aggressive: '#dc3545'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get month labels
|
||||||
|
const maxMonths = Math.max(...scenarios.map(s => this.calculator.monthlyData[s].length));
|
||||||
|
const monthLabels = Array.from({ length: maxMonths }, (_, i) => `M${i + 1}`);
|
||||||
|
|
||||||
|
// Update Instance Growth Chart
|
||||||
|
this.charts.instanceGrowth.data.labels = monthLabels;
|
||||||
|
this.charts.instanceGrowth.data.datasets = scenarios.map(scenario => ({
|
||||||
|
label: this.calculator.scenarios[scenario].name,
|
||||||
|
data: this.calculator.monthlyData[scenario].map(d => d.totalInstances),
|
||||||
|
borderColor: colors[scenario],
|
||||||
|
backgroundColor: colors[scenario] + '20',
|
||||||
|
tension: 0.4
|
||||||
|
}));
|
||||||
|
this.charts.instanceGrowth.update();
|
||||||
|
|
||||||
|
// Update Revenue Chart
|
||||||
|
this.charts.revenue.data.labels = monthLabels;
|
||||||
|
this.charts.revenue.data.datasets = scenarios.map(scenario => ({
|
||||||
|
label: this.calculator.scenarios[scenario].name + ' (CSP)',
|
||||||
|
data: this.calculator.monthlyData[scenario].map(d => d.cumulativeCSPRevenue),
|
||||||
|
borderColor: colors[scenario],
|
||||||
|
backgroundColor: colors[scenario] + '20',
|
||||||
|
tension: 0.4
|
||||||
|
}));
|
||||||
|
this.charts.revenue.update();
|
||||||
|
|
||||||
|
// Update Cash Flow Chart (show average across scenarios)
|
||||||
|
const avgCashFlow = monthLabels.map((_, monthIndex) => {
|
||||||
|
const monthData = scenarios.map(scenario =>
|
||||||
|
this.calculator.monthlyData[scenario][monthIndex]?.cspRevenue || 0
|
||||||
|
);
|
||||||
|
return monthData.reduce((sum, val) => sum + val, 0) / monthData.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.charts.cashFlow.data.labels = monthLabels;
|
||||||
|
this.charts.cashFlow.data.datasets = [{
|
||||||
|
label: 'Average Monthly CSP Revenue',
|
||||||
|
data: avgCashFlow,
|
||||||
|
backgroundColor: '#007bff'
|
||||||
|
}];
|
||||||
|
this.charts.cashFlow.update();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating charts:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
235
hub/services/static/js/roi-calculator/export-manager.js
Normal file
235
hub/services/static/js/roi-calculator/export-manager.js
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
/**
|
||||||
|
* Export Management Module
|
||||||
|
* Handles PDF and CSV export functionality
|
||||||
|
*/
|
||||||
|
class ExportManager {
|
||||||
|
constructor(calculator, uiManager) {
|
||||||
|
this.calculator = calculator;
|
||||||
|
this.uiManager = uiManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
exportToPDF() {
|
||||||
|
// Check if jsPDF is available
|
||||||
|
if (typeof window.jspdf === 'undefined') {
|
||||||
|
alert('PDF export library is loading. Please try again in a moment.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { jsPDF } = window.jspdf;
|
||||||
|
const doc = new jsPDF();
|
||||||
|
|
||||||
|
// Add header
|
||||||
|
doc.setFontSize(20);
|
||||||
|
doc.setTextColor(0, 123, 255); // Bootstrap primary blue
|
||||||
|
doc.text('CSP ROI Calculator Report', 20, 25);
|
||||||
|
|
||||||
|
// Add generation date
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.setTextColor(100, 100, 100);
|
||||||
|
const currentDate = new Date().toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
doc.text(`Generated on: ${currentDate}`, 20, 35);
|
||||||
|
|
||||||
|
// Reset text color
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
|
||||||
|
// Add input parameters section
|
||||||
|
doc.setFontSize(16);
|
||||||
|
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();
|
||||||
|
yPos = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
yPos += 10;
|
||||||
|
doc.setFontSize(16);
|
||||||
|
doc.text('Executive Summary', 20, yPos);
|
||||||
|
yPos += 10;
|
||||||
|
|
||||||
|
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;
|
||||||
|
doc.text('Key assumptions:', 25, yPos);
|
||||||
|
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
|
||||||
|
const pageCount = doc.internal.getNumberOfPages();
|
||||||
|
for (let i = 1; i <= pageCount; i++) {
|
||||||
|
doc.setPage(i);
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(150, 150, 150);
|
||||||
|
doc.text(`Page ${i} of ${pageCount}`, doc.internal.pageSize.getWidth() - 30, doc.internal.pageSize.getHeight() - 10);
|
||||||
|
doc.text('Generated by Servala CSP ROI Calculator', 20, doc.internal.pageSize.getHeight() - 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the PDF
|
||||||
|
const filename = `servala-csp-roi-report-${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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 += `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 += `Revenue per Instance,${inputs.revenuePerInstance}\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,CSP Revenue,Servala Revenue,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,Monthly Revenue,CSP Revenue,Servala Revenue,Cumulative CSP Revenue,Cumulative Servala Revenue\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.monthlyRevenue.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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
112
hub/services/static/js/roi-calculator/input-utils.js
Normal file
112
hub/services/static/js/roi-calculator/input-utils.js
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
/**
|
||||||
|
* Input Utilities Module
|
||||||
|
* Handles input formatting, validation, and parsing
|
||||||
|
*/
|
||||||
|
class InputUtils {
|
||||||
|
static formatNumberWithCommas(num) {
|
||||||
|
try {
|
||||||
|
return parseInt(num).toLocaleString('en-US');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting number with commas:', error);
|
||||||
|
return String(num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static parseFormattedNumber(str) {
|
||||||
|
if (typeof str !== 'string') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Remove all non-numeric characters except decimal points and commas
|
||||||
|
const cleaned = str.replace(/[^\d,.-]/g, '');
|
||||||
|
|
||||||
|
// Handle empty string
|
||||||
|
if (!cleaned) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove commas and parse as float
|
||||||
|
const result = parseFloat(cleaned.replace(/,/g, ''));
|
||||||
|
|
||||||
|
// Return 0 for invalid numbers or NaN
|
||||||
|
return isNaN(result) ? 0 : result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing formatted number:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static handleInvestmentAmountInput(input) {
|
||||||
|
if (!input || typeof input.value !== 'string') {
|
||||||
|
console.error('Invalid input element provided to handleInvestmentAmountInput');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Remove non-numeric characters except commas
|
||||||
|
let value = input.value.replace(/[^\d,]/g, '');
|
||||||
|
|
||||||
|
// Parse the numeric value
|
||||||
|
let numericValue = InputUtils.parseFormattedNumber(value);
|
||||||
|
|
||||||
|
// Enforce min/max limits
|
||||||
|
const minValue = 100000;
|
||||||
|
const maxValue = 2000000;
|
||||||
|
|
||||||
|
if (numericValue < minValue) {
|
||||||
|
numericValue = minValue;
|
||||||
|
} else if (numericValue > maxValue) {
|
||||||
|
numericValue = maxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the data attribute with the raw numeric value
|
||||||
|
input.setAttribute('data-value', numericValue.toString());
|
||||||
|
|
||||||
|
// Format and display the value with commas
|
||||||
|
input.value = InputUtils.formatNumberWithCommas(numericValue);
|
||||||
|
|
||||||
|
// Update the slider if it exists
|
||||||
|
const slider = document.getElementById('investment-slider');
|
||||||
|
if (slider) {
|
||||||
|
slider.value = numericValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger calculations
|
||||||
|
if (window.ROICalculatorApp && window.ROICalculatorApp.calculator) {
|
||||||
|
window.ROICalculatorApp.calculator.updateCalculations();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling investment amount input:', error);
|
||||||
|
// Set a safe default value
|
||||||
|
input.setAttribute('data-value', '500000');
|
||||||
|
input.value = '500,000';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static getCSRFToken() {
|
||||||
|
try {
|
||||||
|
// Try to get CSRF token from meta tag first
|
||||||
|
const metaTag = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
if (metaTag) {
|
||||||
|
return metaTag.getAttribute('content');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to cookie method
|
||||||
|
const cookies = document.cookie.split(';');
|
||||||
|
for (let cookie of cookies) {
|
||||||
|
const [name, value] = cookie.trim().split('=');
|
||||||
|
if (name === 'csrftoken') {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no CSRF token found, return empty string (will cause server error, but won't break JS)
|
||||||
|
console.error('CSRF token not found');
|
||||||
|
return '';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting CSRF token:', error);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
467
hub/services/static/js/roi-calculator/roi-calculator-app.js
Normal file
467
hub/services/static/js/roi-calculator/roi-calculator-app.js
Normal file
|
|
@ -0,0 +1,467 @@
|
||||||
|
/**
|
||||||
|
* ROI Calculator Application
|
||||||
|
* Main application class that coordinates all modules
|
||||||
|
*/
|
||||||
|
class ROICalculatorApp {
|
||||||
|
constructor() {
|
||||||
|
this.calculator = null;
|
||||||
|
this.chartManager = null;
|
||||||
|
this.uiManager = null;
|
||||||
|
this.exportManager = null;
|
||||||
|
this.isInitialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
if (this.isInitialized) {
|
||||||
|
console.warn('ROI Calculator already initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create the main calculator instance
|
||||||
|
this.calculator = new ROICalculator();
|
||||||
|
|
||||||
|
// Create UI and chart managers
|
||||||
|
this.uiManager = new UIManager(this.calculator);
|
||||||
|
this.chartManager = new ChartManager(this.calculator);
|
||||||
|
this.exportManager = new ExportManager(this.calculator, this.uiManager);
|
||||||
|
|
||||||
|
// Replace the methods in calculator with manager methods
|
||||||
|
this.calculator.updateSummaryMetrics = () => this.uiManager.updateSummaryMetrics();
|
||||||
|
this.calculator.updateCharts = () => this.chartManager.updateCharts();
|
||||||
|
this.calculator.updateComparisonTable = () => this.uiManager.updateComparisonTable();
|
||||||
|
this.calculator.updateMonthlyBreakdown = () => this.uiManager.updateMonthlyBreakdown();
|
||||||
|
this.calculator.initializeCharts = () => this.chartManager.initializeCharts();
|
||||||
|
this.calculator.formatCurrency = (amount) => this.uiManager.formatCurrency(amount);
|
||||||
|
this.calculator.formatCurrencyDetailed = (amount) => this.uiManager.formatCurrencyDetailed(amount);
|
||||||
|
this.calculator.formatPercentage = (value) => this.uiManager.formatPercentage(value);
|
||||||
|
|
||||||
|
// Re-initialize charts with the chart manager
|
||||||
|
this.chartManager.initializeCharts();
|
||||||
|
this.calculator.charts = this.chartManager.charts;
|
||||||
|
|
||||||
|
// Initialize tooltips
|
||||||
|
this.initializeTooltips();
|
||||||
|
|
||||||
|
// Check export libraries
|
||||||
|
this.checkExportLibraries();
|
||||||
|
|
||||||
|
// Run initial calculation
|
||||||
|
this.calculator.updateCalculations();
|
||||||
|
|
||||||
|
this.isInitialized = true;
|
||||||
|
console.log('ROI Calculator initialized successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize ROI Calculator:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize tooltips with Bootstrap (loaded directly via CDN)
|
||||||
|
initializeTooltips() {
|
||||||
|
// Wait for Bootstrap to be available
|
||||||
|
if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip) {
|
||||||
|
try {
|
||||||
|
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||||
|
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||||
|
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to initialize Bootstrap tooltips:', error);
|
||||||
|
this.initializeNativeTooltips();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Retry after a short delay for deferred scripts
|
||||||
|
setTimeout(() => this.initializeTooltips(), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeNativeTooltips() {
|
||||||
|
try {
|
||||||
|
var tooltipElements = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||||
|
tooltipElements.forEach(function (element) {
|
||||||
|
// Ensure the title attribute is set for native tooltips
|
||||||
|
var tooltipContent = element.getAttribute('title');
|
||||||
|
if (!tooltipContent) {
|
||||||
|
// Get tooltip content from data-bs-original-title or title attribute
|
||||||
|
tooltipContent = element.getAttribute('data-bs-original-title');
|
||||||
|
if (tooltipContent) {
|
||||||
|
element.setAttribute('title', tooltipContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing native tooltips:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if export libraries are loaded
|
||||||
|
checkExportLibraries() {
|
||||||
|
try {
|
||||||
|
const pdfButton = document.querySelector('button[onclick="exportToPDF()"]');
|
||||||
|
|
||||||
|
if (typeof window.jspdf === 'undefined') {
|
||||||
|
if (pdfButton) {
|
||||||
|
pdfButton.disabled = true;
|
||||||
|
pdfButton.innerHTML = '<i class="bi bi-file-pdf"></i> Loading PDF...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
if (typeof window.jspdf !== 'undefined' && pdfButton) {
|
||||||
|
pdfButton.disabled = false;
|
||||||
|
pdfButton.innerHTML = '<i class="bi bi-file-pdf"></i> Export PDF Report';
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking export libraries:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API methods for global function calls
|
||||||
|
updateCalculations() {
|
||||||
|
if (this.calculator) {
|
||||||
|
this.calculator.updateCalculations();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exportToPDF() {
|
||||||
|
if (this.exportManager) {
|
||||||
|
this.exportManager.exportToPDF();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exportToCSV() {
|
||||||
|
if (this.exportManager) {
|
||||||
|
this.exportManager.exportToCSV();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInvestmentAmount(value) {
|
||||||
|
try {
|
||||||
|
const input = document.getElementById('investment-amount');
|
||||||
|
if (input) {
|
||||||
|
input.setAttribute('data-value', value);
|
||||||
|
input.value = InputUtils.formatNumberWithCommas(value);
|
||||||
|
this.updateCalculations();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating investment amount:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRevenuePerInstance(value) {
|
||||||
|
try {
|
||||||
|
const element = document.getElementById('revenue-per-instance');
|
||||||
|
if (element) {
|
||||||
|
element.value = value;
|
||||||
|
this.updateCalculations();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating revenue per instance:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateServalaShare(value) {
|
||||||
|
try {
|
||||||
|
const element = document.getElementById('servala-share');
|
||||||
|
if (element) {
|
||||||
|
element.value = value;
|
||||||
|
this.updateCalculations();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating servala share:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGracePeriod(value) {
|
||||||
|
try {
|
||||||
|
const element = document.getElementById('grace-period');
|
||||||
|
if (element) {
|
||||||
|
element.value = value;
|
||||||
|
this.updateCalculations();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating grace period:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLoanRate(value) {
|
||||||
|
try {
|
||||||
|
const element = document.getElementById('loan-interest-rate');
|
||||||
|
if (element) {
|
||||||
|
element.value = value;
|
||||||
|
this.updateCalculations();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating loan rate:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateScenarioChurn(scenarioKey, churnRate) {
|
||||||
|
try {
|
||||||
|
if (this.calculator && this.calculator.scenarios[scenarioKey]) {
|
||||||
|
this.calculator.scenarios[scenarioKey].churnRate = parseFloat(churnRate) / 100;
|
||||||
|
this.updateCalculations();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating scenario churn:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateScenarioPhase(scenarioKey, phaseIndex, newInstancesPerMonth) {
|
||||||
|
try {
|
||||||
|
if (this.calculator && this.calculator.scenarios[scenarioKey] && this.calculator.scenarios[scenarioKey].phases[phaseIndex]) {
|
||||||
|
this.calculator.scenarios[scenarioKey].phases[phaseIndex].newInstancesPerMonth = parseInt(newInstancesPerMonth);
|
||||||
|
this.updateCalculations();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating scenario phase:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetAdvancedParameters() {
|
||||||
|
if (!confirm('Reset all advanced parameters to default values?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!this.calculator) return;
|
||||||
|
|
||||||
|
// Reset Conservative
|
||||||
|
this.calculator.scenarios.conservative.churnRate = 0.02;
|
||||||
|
this.calculator.scenarios.conservative.phases = [
|
||||||
|
{ months: 6, newInstancesPerMonth: 50 },
|
||||||
|
{ months: 6, newInstancesPerMonth: 75 },
|
||||||
|
{ months: 12, newInstancesPerMonth: 100 },
|
||||||
|
{ months: 12, newInstancesPerMonth: 150 }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Reset Moderate
|
||||||
|
this.calculator.scenarios.moderate.churnRate = 0.03;
|
||||||
|
this.calculator.scenarios.moderate.phases = [
|
||||||
|
{ months: 6, newInstancesPerMonth: 100 },
|
||||||
|
{ months: 6, newInstancesPerMonth: 200 },
|
||||||
|
{ months: 12, newInstancesPerMonth: 300 },
|
||||||
|
{ months: 12, newInstancesPerMonth: 400 }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Reset Aggressive
|
||||||
|
this.calculator.scenarios.aggressive.churnRate = 0.05;
|
||||||
|
this.calculator.scenarios.aggressive.phases = [
|
||||||
|
{ months: 6, newInstancesPerMonth: 200 },
|
||||||
|
{ months: 6, newInstancesPerMonth: 400 },
|
||||||
|
{ months: 12, newInstancesPerMonth: 600 },
|
||||||
|
{ months: 12, newInstancesPerMonth: 800 }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Update UI inputs
|
||||||
|
const inputMappings = [
|
||||||
|
['conservative-churn', '2.0'],
|
||||||
|
['conservative-phase-0', '50'],
|
||||||
|
['conservative-phase-1', '75'],
|
||||||
|
['conservative-phase-2', '100'],
|
||||||
|
['conservative-phase-3', '150'],
|
||||||
|
['moderate-churn', '3.0'],
|
||||||
|
['moderate-phase-0', '100'],
|
||||||
|
['moderate-phase-1', '200'],
|
||||||
|
['moderate-phase-2', '300'],
|
||||||
|
['moderate-phase-3', '400'],
|
||||||
|
['aggressive-churn', '5.0'],
|
||||||
|
['aggressive-phase-0', '200'],
|
||||||
|
['aggressive-phase-1', '400'],
|
||||||
|
['aggressive-phase-2', '600'],
|
||||||
|
['aggressive-phase-3', '800']
|
||||||
|
];
|
||||||
|
|
||||||
|
inputMappings.forEach(([id, value]) => {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (element) {
|
||||||
|
element.value = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateCalculations();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resetting advanced parameters:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleScenario(scenarioKey) {
|
||||||
|
try {
|
||||||
|
const checkbox = document.getElementById(scenarioKey + '-enabled');
|
||||||
|
if (!checkbox || !this.calculator) return;
|
||||||
|
|
||||||
|
const enabled = checkbox.checked;
|
||||||
|
this.calculator.scenarios[scenarioKey].enabled = enabled;
|
||||||
|
|
||||||
|
const card = document.getElementById(scenarioKey + '-card');
|
||||||
|
if (card) {
|
||||||
|
if (enabled) {
|
||||||
|
card.classList.add('active');
|
||||||
|
card.classList.remove('disabled');
|
||||||
|
} else {
|
||||||
|
card.classList.remove('active');
|
||||||
|
card.classList.add('disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateCalculations();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling scenario:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleCollapsible(elementId) {
|
||||||
|
try {
|
||||||
|
const content = document.getElementById(elementId);
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
const header = content.previousElementSibling;
|
||||||
|
if (!header) return;
|
||||||
|
|
||||||
|
const chevron = header.querySelector('.bi-chevron-down, .bi-chevron-up');
|
||||||
|
|
||||||
|
if (content.classList.contains('show')) {
|
||||||
|
content.classList.remove('show');
|
||||||
|
if (chevron) {
|
||||||
|
chevron.classList.remove('bi-chevron-up');
|
||||||
|
chevron.classList.add('bi-chevron-down');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content.classList.add('show');
|
||||||
|
if (chevron) {
|
||||||
|
chevron.classList.remove('bi-chevron-down');
|
||||||
|
chevron.classList.add('bi-chevron-up');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling collapsible:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetCalculator() {
|
||||||
|
if (!confirm('Are you sure you want to reset all parameters to default values?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Reset input values
|
||||||
|
const investmentInput = document.getElementById('investment-amount');
|
||||||
|
if (investmentInput) {
|
||||||
|
investmentInput.setAttribute('data-value', '500000');
|
||||||
|
investmentInput.value = '500,000';
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetMappings = [
|
||||||
|
['investment-slider', 500000],
|
||||||
|
['timeframe', 3],
|
||||||
|
['loan-interest-rate', 5.0],
|
||||||
|
['loan-rate-slider', 5.0],
|
||||||
|
['revenue-per-instance', 50],
|
||||||
|
['revenue-slider', 50],
|
||||||
|
['servala-share', 25],
|
||||||
|
['share-slider', 25],
|
||||||
|
['grace-period', 6],
|
||||||
|
['grace-slider', 6]
|
||||||
|
];
|
||||||
|
|
||||||
|
resetMappings.forEach(([id, value]) => {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (element) {
|
||||||
|
element.value = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check direct model radio button
|
||||||
|
const directModel = document.getElementById('direct-model');
|
||||||
|
if (directModel) {
|
||||||
|
directModel.checked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset scenarios
|
||||||
|
['conservative', 'moderate', 'aggressive'].forEach(scenario => {
|
||||||
|
const checkbox = document.getElementById(scenario + '-enabled');
|
||||||
|
const card = document.getElementById(scenario + '-card');
|
||||||
|
|
||||||
|
if (checkbox) checkbox.checked = true;
|
||||||
|
if (this.calculator) this.calculator.scenarios[scenario].enabled = true;
|
||||||
|
if (card) {
|
||||||
|
card.classList.add('active');
|
||||||
|
card.classList.remove('disabled');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset advanced parameters
|
||||||
|
this.resetAdvancedParameters();
|
||||||
|
|
||||||
|
// Reset investment model toggle
|
||||||
|
this.toggleInvestmentModel();
|
||||||
|
|
||||||
|
// Recalculate
|
||||||
|
this.updateCalculations();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resetting calculator:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleInvestmentModel() {
|
||||||
|
try {
|
||||||
|
const selectedModelElement = document.querySelector('input[name="investment-model"]:checked');
|
||||||
|
const selectedModel = selectedModelElement ? selectedModelElement.value : 'direct';
|
||||||
|
|
||||||
|
const loanSection = document.getElementById('loan-rate-section');
|
||||||
|
const modelDescription = document.getElementById('model-description');
|
||||||
|
|
||||||
|
if (selectedModel === 'loan') {
|
||||||
|
if (loanSection) loanSection.style.display = 'block';
|
||||||
|
if (modelDescription) modelDescription.textContent = 'Loan Model: Guaranteed returns with fixed monthly payments';
|
||||||
|
} else {
|
||||||
|
if (loanSection) loanSection.style.display = 'none';
|
||||||
|
if (modelDescription) modelDescription.textContent = 'Direct Investment: Higher potential returns based on your sales performance';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateCalculations();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling investment model:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
if (!confirm('Are you sure you want to logout?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a form to submit logout request
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = window.location.pathname;
|
||||||
|
|
||||||
|
// Add CSRF token from page meta tag or cookie
|
||||||
|
const csrfInput = document.createElement('input');
|
||||||
|
csrfInput.type = 'hidden';
|
||||||
|
csrfInput.name = 'csrfmiddlewaretoken';
|
||||||
|
csrfInput.value = InputUtils.getCSRFToken();
|
||||||
|
form.appendChild(csrfInput);
|
||||||
|
|
||||||
|
// Add logout parameter
|
||||||
|
const logoutInput = document.createElement('input');
|
||||||
|
logoutInput.type = 'hidden';
|
||||||
|
logoutInput.name = 'logout';
|
||||||
|
logoutInput.value = 'true';
|
||||||
|
form.appendChild(logoutInput);
|
||||||
|
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during logout:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the application when DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
window.ROICalculatorApp = new ROICalculatorApp();
|
||||||
|
window.ROICalculatorApp.initialize();
|
||||||
|
});
|
||||||
166
hub/services/static/js/roi-calculator/ui-manager.js
Normal file
166
hub/services/static/js/roi-calculator/ui-manager.js
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
/**
|
||||||
|
* UI Management Module
|
||||||
|
* Handles DOM updates, table rendering, and metric display
|
||||||
|
*/
|
||||||
|
class UIManager {
|
||||||
|
constructor(calculator) {
|
||||||
|
this.calculator = calculator;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSummaryMetrics() {
|
||||||
|
try {
|
||||||
|
const enabledResults = Object.values(this.calculator.results);
|
||||||
|
if (enabledResults.length === 0) {
|
||||||
|
this.setElementText('total-instances', '0');
|
||||||
|
this.setElementText('total-revenue', 'CHF 0');
|
||||||
|
this.setElementText('roi-percentage', '0%');
|
||||||
|
this.setElementText('breakeven-time', 'N/A');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate averages across enabled scenarios
|
||||||
|
const avgInstances = Math.round(enabledResults.reduce((sum, r) => sum + r.finalInstances, 0) / enabledResults.length);
|
||||||
|
const avgRevenue = enabledResults.reduce((sum, r) => sum + r.totalRevenue, 0) / enabledResults.length;
|
||||||
|
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;
|
||||||
|
|
||||||
|
this.setElementText('total-instances', avgInstances.toLocaleString());
|
||||||
|
this.setElementText('total-revenue', this.formatCurrency(avgRevenue));
|
||||||
|
this.setElementText('roi-percentage', this.formatPercentage(avgROI));
|
||||||
|
this.setElementText('breakeven-time', isNaN(avgBreakeven) ? 'N/A' : `${Math.round(avgBreakeven)} months`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating summary metrics:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateComparisonTable() {
|
||||||
|
try {
|
||||||
|
const tbody = document.getElementById('comparison-tbody');
|
||||||
|
if (!tbody) {
|
||||||
|
console.error('Comparison table body not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
Object.values(this.calculator.results).forEach(result => {
|
||||||
|
const modelLabel = result.investmentModel === 'loan' ?
|
||||||
|
'<span class="badge bg-warning">Loan</span>' :
|
||||||
|
'<span class="badge bg-success">Direct</span>';
|
||||||
|
|
||||||
|
const row = tbody.insertRow();
|
||||||
|
row.innerHTML = `
|
||||||
|
<td><strong>${result.scenario}</strong></td>
|
||||||
|
<td>${modelLabel}</td>
|
||||||
|
<td>${result.finalInstances.toLocaleString()}</td>
|
||||||
|
<td>${this.formatCurrencyDetailed(result.totalRevenue)}</td>
|
||||||
|
<td>${this.formatCurrencyDetailed(result.cspRevenue)}</td>
|
||||||
|
<td>${this.formatCurrencyDetailed(result.servalaRevenue)}</td>
|
||||||
|
<td class="${result.roi >= 0 ? 'text-success' : 'text-danger'}">${this.formatPercentage(result.roi)}</td>
|
||||||
|
<td>${result.breakEvenMonth ? result.breakEvenMonth + ' months' : 'N/A'}</td>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating comparison table:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMonthlyBreakdown() {
|
||||||
|
try {
|
||||||
|
const tbody = document.getElementById('monthly-tbody');
|
||||||
|
if (!tbody) {
|
||||||
|
console.error('Monthly breakdown table body not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
// Combine all monthly data and sort by month and scenario
|
||||||
|
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 => {
|
||||||
|
const row = tbody.insertRow();
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>${data.month}</td>
|
||||||
|
<td><span class="badge bg-secondary">${data.scenario}</span></td>
|
||||||
|
<td>${data.newInstances}</td>
|
||||||
|
<td>${data.churnedInstances}</td>
|
||||||
|
<td>${data.totalInstances.toLocaleString()}</td>
|
||||||
|
<td>${this.formatCurrencyDetailed(data.monthlyRevenue)}</td>
|
||||||
|
<td>${this.formatCurrencyDetailed(data.cspRevenue)}</td>
|
||||||
|
<td>${this.formatCurrencyDetailed(data.servalaRevenue)}</td>
|
||||||
|
<td>${this.formatCurrencyDetailed(data.cumulativeCSPRevenue)}</td>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating monthly breakdown:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setElementText(elementId, text) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (element) {
|
||||||
|
element.textContent = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatCurrency(amount) {
|
||||||
|
try {
|
||||||
|
// Use compact notation for large numbers in metric cards
|
||||||
|
if (amount >= 1000000) {
|
||||||
|
return new Intl.NumberFormat('de-CH', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'CHF',
|
||||||
|
notation: 'compact',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 1
|
||||||
|
}).format(amount);
|
||||||
|
} else {
|
||||||
|
return new Intl.NumberFormat('de-CH', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'CHF',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting currency:', error);
|
||||||
|
return `CHF ${amount.toFixed(0)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatCurrencyDetailed(amount) {
|
||||||
|
try {
|
||||||
|
// Use full formatting for detailed views (tables, exports)
|
||||||
|
return new Intl.NumberFormat('de-CH', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'CHF',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(amount);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting detailed currency:', error);
|
||||||
|
return `CHF ${amount.toFixed(0)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatPercentage(value) {
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat('de-CH', {
|
||||||
|
style: 'percent',
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1
|
||||||
|
}).format(value / 100);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting percentage:', error);
|
||||||
|
return `${value.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,33 @@
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script src="{% static "js/chart.umd.min.js" %}"></script>
|
<script src="{% static "js/chart.umd.min.js" %}"></script>
|
||||||
<script src="{% static "js/jspdf.umd.min.js" %}"></script>
|
<script src="{% static "js/jspdf.umd.min.js" %}"></script>
|
||||||
<script src="{% static "js/roi-calculator.js" %}"></script>
|
<!-- ROI Calculator Modules -->
|
||||||
|
<script src="{% static "js/roi-calculator/input-utils.js" %}"></script>
|
||||||
|
<script src="{% static "js/roi-calculator/calculator-core.js" %}"></script>
|
||||||
|
<script src="{% static "js/roi-calculator/chart-manager.js" %}"></script>
|
||||||
|
<script src="{% static "js/roi-calculator/ui-manager.js" %}"></script>
|
||||||
|
<script src="{% static "js/roi-calculator/export-manager.js" %}"></script>
|
||||||
|
<script src="{% static "js/roi-calculator/roi-calculator-app.js" %}"></script>
|
||||||
|
<script>
|
||||||
|
// Global function wrappers for HTML onclick handlers
|
||||||
|
function updateCalculations() { window.ROICalculatorApp?.updateCalculations(); }
|
||||||
|
function exportToPDF() { window.ROICalculatorApp?.exportToPDF(); }
|
||||||
|
function exportToCSV() { window.ROICalculatorApp?.exportToCSV(); }
|
||||||
|
function handleInvestmentAmountInput(input) { InputUtils.handleInvestmentAmountInput(input); }
|
||||||
|
function updateInvestmentAmount(value) { window.ROICalculatorApp?.updateInvestmentAmount(value); }
|
||||||
|
function updateRevenuePerInstance(value) { window.ROICalculatorApp?.updateRevenuePerInstance(value); }
|
||||||
|
function updateServalaShare(value) { window.ROICalculatorApp?.updateServalaShare(value); }
|
||||||
|
function updateGracePeriod(value) { window.ROICalculatorApp?.updateGracePeriod(value); }
|
||||||
|
function updateLoanRate(value) { window.ROICalculatorApp?.updateLoanRate(value); }
|
||||||
|
function updateScenarioChurn(scenarioKey, churnRate) { window.ROICalculatorApp?.updateScenarioChurn(scenarioKey, churnRate); }
|
||||||
|
function updateScenarioPhase(scenarioKey, phaseIndex, newInstancesPerMonth) { window.ROICalculatorApp?.updateScenarioPhase(scenarioKey, phaseIndex, newInstancesPerMonth); }
|
||||||
|
function resetAdvancedParameters() { window.ROICalculatorApp?.resetAdvancedParameters(); }
|
||||||
|
function toggleScenario(scenarioKey) { window.ROICalculatorApp?.toggleScenario(scenarioKey); }
|
||||||
|
function toggleCollapsible(elementId) { window.ROICalculatorApp?.toggleCollapsible(elementId); }
|
||||||
|
function resetCalculator() { window.ROICalculatorApp?.resetCalculator(); }
|
||||||
|
function toggleInvestmentModel() { window.ROICalculatorApp?.toggleInvestmentModel(); }
|
||||||
|
function logout() { window.ROICalculatorApp?.logout(); }
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue