diff --git a/CLAUDE.md b/CLAUDE.md
index d4f6ad4..3c3c39f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -66,7 +66,9 @@ uv run --extra dev manage.py collectstatic
### Frontend Assets
- 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
- Chart.js for data visualization
diff --git a/hub/services/static/js/roi-calculator-modular.js b/hub/services/static/js/roi-calculator-modular.js
new file mode 100644
index 0000000..313a19d
--- /dev/null
+++ b/hub/services/static/js/roi-calculator-modular.js
@@ -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();
+ }
+}
\ No newline at end of file
diff --git a/hub/services/static/js/roi-calculator.js b/hub/services/static/js/roi-calculator.js
deleted file mode 100644
index eba1b73..0000000
--- a/hub/services/static/js/roi-calculator.js
+++ /dev/null
@@ -1,1142 +0,0 @@
-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 = {};
-
- // Initialize charts
- this.initializeCharts();
-
- // Initial calculation
- this.updateCalculations();
- }
-
- 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) {
- 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
- };
- }
-
- updateCalculations() {
- const inputs = this.getInputValues();
- this.results = {};
- this.monthlyData = {};
-
- // Show loading spinner
- document.getElementById('loading-spinner').style.display = 'block';
- document.getElementById('summary-metrics').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
- document.getElementById('loading-spinner').style.display = 'none';
- document.getElementById('summary-metrics').style.display = 'flex';
- }, 500);
- }
-
- updateSummaryMetrics() {
- const enabledResults = Object.values(this.results);
- if (enabledResults.length === 0) {
- document.getElementById('total-instances').textContent = '0';
- document.getElementById('total-revenue').textContent = 'CHF 0';
- document.getElementById('roi-percentage').textContent = '0%';
- document.getElementById('breakeven-time').textContent = '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;
-
- document.getElementById('total-instances').textContent = avgInstances.toLocaleString();
- document.getElementById('total-revenue').textContent = this.formatCurrency(avgRevenue);
- document.getElementById('roi-percentage').textContent = this.formatPercentage(avgROI);
- document.getElementById('breakeven-time').textContent = isNaN(avgBreakeven) ? 'N/A' : `${Math.round(avgBreakeven)} months`;
- }
-
- 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 = `
${message}
`;
- }
- });
- }
-
- updateCharts() {
- const scenarios = Object.keys(this.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.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.scenarios[scenario].name,
- data: this.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.scenarios[scenario].name + ' (CSP)',
- data: this.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.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();
- }
-
- updateComparisonTable() {
- const tbody = document.getElementById('comparison-tbody');
- tbody.innerHTML = '';
-
- Object.values(this.results).forEach(result => {
- const modelLabel = result.investmentModel === 'loan' ?
- 'Loan' :
- 'Direct';
-
- const row = tbody.insertRow();
- row.innerHTML = `
- ${result.scenario} |
- ${modelLabel} |
- ${result.finalInstances.toLocaleString()} |
- ${this.formatCurrencyDetailed(result.totalRevenue)} |
- ${this.formatCurrencyDetailed(result.cspRevenue)} |
- ${this.formatCurrencyDetailed(result.servalaRevenue)} |
- ${this.formatPercentage(result.roi)} |
- ${result.breakEvenMonth ? result.breakEvenMonth + ' months' : 'N/A'} |
- `;
- });
- }
-
- updateMonthlyBreakdown() {
- const tbody = document.getElementById('monthly-tbody');
- tbody.innerHTML = '';
-
- // Combine all monthly data and sort by month and scenario
- const allData = [];
- Object.keys(this.monthlyData).forEach(scenario => {
- this.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 = `
- ${data.month} |
- ${data.scenario} |
- ${data.newInstances} |
- ${data.churnedInstances} |
- ${data.totalInstances.toLocaleString()} |
- ${this.formatCurrencyDetailed(data.monthlyRevenue)} |
- ${this.formatCurrencyDetailed(data.cspRevenue)} |
- ${this.formatCurrencyDetailed(data.servalaRevenue)} |
- ${this.formatCurrencyDetailed(data.cumulativeCSPRevenue)} |
- `;
- });
- }
-
- formatCurrency(amount) {
- // 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);
- }
- }
-
- formatCurrencyDetailed(amount) {
- // Use full formatting for detailed views (tables, exports)
- return new Intl.NumberFormat('de-CH', {
- style: 'currency',
- currency: 'CHF',
- minimumFractionDigits: 0,
- maximumFractionDigits: 0
- }).format(amount);
- }
-
- formatPercentage(value) {
- return new Intl.NumberFormat('de-CH', {
- style: 'percent',
- minimumFractionDigits: 1,
- maximumFractionDigits: 1
- }).format(value / 100);
- }
-}
-
-// Initialize calculator
-let calculator;
-
-document.addEventListener('DOMContentLoaded', function () {
- // Initialize calculator
- calculator = new ROICalculator();
-
- // Initialize tooltips - check if Bootstrap is available
- initializeTooltips();
-
- // Check if export libraries are loaded and enable/disable buttons accordingly
- checkExportLibraries();
-});
-
-// Initialize tooltips with Bootstrap (loaded directly via CDN)
-function 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);
- initializeNativeTooltips();
- }
- } else {
- // Retry after a short delay for deferred scripts
- setTimeout(initializeTooltips, 100);
- }
-}
-
-function initializeNativeTooltips() {
- 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);
- }
- }
- });
-}
-
-// Check if export libraries are loaded
-function checkExportLibraries() {
- const pdfButton = document.querySelector('button[onclick="exportToPDF()"]');
-
- if (typeof window.jspdf === 'undefined') {
- if (pdfButton) {
- pdfButton.disabled = true;
- pdfButton.innerHTML = ' Loading PDF...';
- }
-
- // Retry after a short delay
- setTimeout(() => {
- if (typeof window.jspdf !== 'undefined' && pdfButton) {
- pdfButton.disabled = false;
- pdfButton.innerHTML = ' Export PDF Report';
- }
- }, 2000);
- }
-}
-
-// Input update functions
-function formatNumberWithCommas(num) {
- return parseInt(num).toLocaleString('en-US');
-}
-
-function parseFormattedNumber(str) {
- if (typeof str !== 'string') {
- return 0;
- }
-
- // 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;
-}
-
-function 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 = 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 = formatNumberWithCommas(numericValue);
-
- // Update the slider if it exists
- const slider = document.getElementById('investment-slider');
- if (slider) {
- slider.value = numericValue;
- }
-
- // Trigger calculations
- 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';
- }
-}
-
-function updateInvestmentAmount(value) {
- const input = document.getElementById('investment-amount');
- input.setAttribute('data-value', value);
- input.value = formatNumberWithCommas(value);
- updateCalculations();
-}
-
-function updateRevenuePerInstance(value) {
- document.getElementById('revenue-per-instance').value = value;
- updateCalculations();
-}
-
-function updateServalaShare(value) {
- document.getElementById('servala-share').value = value;
- updateCalculations();
-}
-
-function updateGracePeriod(value) {
- document.getElementById('grace-period').value = value;
- updateCalculations();
-}
-
-function updateCalculations() {
- if (calculator) {
- calculator.updateCalculations();
- }
-}
-
-// Advanced parameter functions
-function updateScenarioChurn(scenarioKey, churnRate) {
- calculator.scenarios[scenarioKey].churnRate = parseFloat(churnRate) / 100;
- updateCalculations();
-}
-
-function updateScenarioPhase(scenarioKey, phaseIndex, newInstancesPerMonth) {
- calculator.scenarios[scenarioKey].phases[phaseIndex].newInstancesPerMonth = parseInt(newInstancesPerMonth);
- updateCalculations();
-}
-
-function resetAdvancedParameters() {
- if (confirm('Reset all advanced parameters to default values?')) {
- // Reset Conservative
- calculator.scenarios.conservative.churnRate = 0.02;
- calculator.scenarios.conservative.phases = [
- { months: 6, newInstancesPerMonth: 50 },
- { months: 6, newInstancesPerMonth: 75 },
- { months: 12, newInstancesPerMonth: 100 },
- { months: 12, newInstancesPerMonth: 150 }
- ];
-
- // Reset Moderate
- calculator.scenarios.moderate.churnRate = 0.03;
- calculator.scenarios.moderate.phases = [
- { months: 6, newInstancesPerMonth: 100 },
- { months: 6, newInstancesPerMonth: 200 },
- { months: 12, newInstancesPerMonth: 300 },
- { months: 12, newInstancesPerMonth: 400 }
- ];
-
- // Reset Aggressive
- calculator.scenarios.aggressive.churnRate = 0.05;
- calculator.scenarios.aggressive.phases = [
- { months: 6, newInstancesPerMonth: 200 },
- { months: 6, newInstancesPerMonth: 400 },
- { months: 12, newInstancesPerMonth: 600 },
- { months: 12, newInstancesPerMonth: 800 }
- ];
-
- // Update UI inputs
- document.getElementById('conservative-churn').value = '2.0';
- document.getElementById('conservative-phase-0').value = '50';
- document.getElementById('conservative-phase-1').value = '75';
- document.getElementById('conservative-phase-2').value = '100';
- document.getElementById('conservative-phase-3').value = '150';
-
- document.getElementById('moderate-churn').value = '3.0';
- document.getElementById('moderate-phase-0').value = '100';
- document.getElementById('moderate-phase-1').value = '200';
- document.getElementById('moderate-phase-2').value = '300';
- document.getElementById('moderate-phase-3').value = '400';
-
- document.getElementById('aggressive-churn').value = '5.0';
- document.getElementById('aggressive-phase-0').value = '200';
- document.getElementById('aggressive-phase-1').value = '400';
- document.getElementById('aggressive-phase-2').value = '600';
- document.getElementById('aggressive-phase-3').value = '800';
-
- updateCalculations();
- }
-}
-
-// Scenario management
-function toggleScenario(scenarioKey) {
- const enabled = document.getElementById(scenarioKey + '-enabled').checked;
- calculator.scenarios[scenarioKey].enabled = enabled;
-
- const card = document.getElementById(scenarioKey + '-card');
- if (enabled) {
- card.classList.add('active');
- card.classList.remove('disabled');
- } else {
- card.classList.remove('active');
- card.classList.add('disabled');
- }
-
- updateCalculations();
-}
-
-// UI utility functions
-function toggleCollapsible(elementId) {
- const content = document.getElementById(elementId);
- const header = content.previousElementSibling;
- const chevron = header.querySelector('.bi-chevron-down, .bi-chevron-up');
-
- if (content.classList.contains('show')) {
- content.classList.remove('show');
- chevron.classList.remove('bi-chevron-up');
- chevron.classList.add('bi-chevron-down');
- } else {
- content.classList.add('show');
- chevron.classList.remove('bi-chevron-down');
- chevron.classList.add('bi-chevron-up');
- }
-}
-
-// Export functions
-function 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 = calculator.getInputValues();
- let yPos = 60;
-
- doc.setFontSize(11);
- const params = [
- ['Investment Amount:', calculator.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:', calculator.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(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:', calculator.formatCurrencyDetailed(result.totalRevenue)],
- ['CSP Revenue:', calculator.formatCurrencyDetailed(result.cspRevenue)],
- ['Servala Revenue:', calculator.formatCurrencyDetailed(result.servalaRevenue)],
- ['ROI:', calculator.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(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: ${calculator.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.');
- }
-}
-
-function 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 = 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(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(calculator.monthlyData).forEach(scenario => {
- 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.');
- }
-}
-
-// Reset function
-function resetCalculator() {
- if (confirm('Are you sure you want to reset all parameters to default values?')) {
- // Reset input values
- const investmentInput = document.getElementById('investment-amount');
- investmentInput.setAttribute('data-value', '500000');
- investmentInput.value = '500,000';
- document.getElementById('investment-slider').value = 500000;
- document.getElementById('timeframe').value = 3;
- document.getElementById('direct-model').checked = true;
- document.getElementById('loan-interest-rate').value = 5.0;
- document.getElementById('loan-rate-slider').value = 5.0;
- document.getElementById('revenue-per-instance').value = 50;
- document.getElementById('revenue-slider').value = 50;
- document.getElementById('servala-share').value = 25;
- document.getElementById('share-slider').value = 25;
- document.getElementById('grace-period').value = 6;
- document.getElementById('grace-slider').value = 6;
-
- // Reset scenarios
- ['conservative', 'moderate', 'aggressive'].forEach(scenario => {
- document.getElementById(scenario + '-enabled').checked = true;
- calculator.scenarios[scenario].enabled = true;
- document.getElementById(scenario + '-card').classList.add('active');
- document.getElementById(scenario + '-card').classList.remove('disabled');
- });
-
- // Reset advanced parameters
- resetAdvancedParameters();
-
- // Reset investment model toggle
- toggleInvestmentModel();
-
- // Recalculate (this will be called by resetAdvancedParameters, but we ensure it happens)
- updateCalculations();
- }
-}
-
-// Investment model toggle functions
-function toggleInvestmentModel() {
- const selectedModel = document.querySelector('input[name="investment-model"]:checked').value;
- const loanSection = document.getElementById('loan-rate-section');
- const modelDescription = document.getElementById('model-description');
-
- if (selectedModel === 'loan') {
- loanSection.style.display = 'block';
- modelDescription.textContent = 'Loan Model: Guaranteed returns with fixed monthly payments';
- } else {
- loanSection.style.display = 'none';
- modelDescription.textContent = 'Direct Investment: Higher potential returns based on your sales performance';
- }
-
- updateCalculations();
-}
-
-function updateLoanRate(value) {
- document.getElementById('loan-interest-rate').value = value;
- updateCalculations();
-}
-
-// Logout function
-function logout() {
- if (confirm('Are you sure you want to logout?')) {
- // 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 = 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();
- }
-}
-
-// Helper function to get CSRF token
-function getCSRFToken() {
- // 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 '';
-}
diff --git a/hub/services/static/js/roi-calculator/README.md b/hub/services/static/js/roi-calculator/README.md
new file mode 100644
index 0000000..a30822c
--- /dev/null
+++ b/hub/services/static/js/roi-calculator/README.md
@@ -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`.
\ No newline at end of file
diff --git a/hub/services/static/js/roi-calculator/calculator-core.js b/hub/services/static/js/roi-calculator/calculator-core.js
new file mode 100644
index 0000000..86f5511
--- /dev/null
+++ b/hub/services/static/js/roi-calculator/calculator-core.js
@@ -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';
+ }
+ }
+}
\ No newline at end of file
diff --git a/hub/services/static/js/roi-calculator/chart-manager.js b/hub/services/static/js/roi-calculator/chart-manager.js
new file mode 100644
index 0000000..de4b6e5
--- /dev/null
+++ b/hub/services/static/js/roi-calculator/chart-manager.js
@@ -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 = `
${message}
`;
+ }
+ });
+ }
+
+ 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);
+ }
+ }
+}
\ No newline at end of file
diff --git a/hub/services/static/js/roi-calculator/export-manager.js b/hub/services/static/js/roi-calculator/export-manager.js
new file mode 100644
index 0000000..53cd3fe
--- /dev/null
+++ b/hub/services/static/js/roi-calculator/export-manager.js
@@ -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.');
+ }
+ }
+}
\ No newline at end of file
diff --git a/hub/services/static/js/roi-calculator/input-utils.js b/hub/services/static/js/roi-calculator/input-utils.js
new file mode 100644
index 0000000..391088a
--- /dev/null
+++ b/hub/services/static/js/roi-calculator/input-utils.js
@@ -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 '';
+ }
+ }
+}
\ No newline at end of file
diff --git a/hub/services/static/js/roi-calculator/roi-calculator-app.js b/hub/services/static/js/roi-calculator/roi-calculator-app.js
new file mode 100644
index 0000000..d3a8111
--- /dev/null
+++ b/hub/services/static/js/roi-calculator/roi-calculator-app.js
@@ -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 = ' Loading PDF...';
+ }
+
+ // Retry after a short delay
+ setTimeout(() => {
+ if (typeof window.jspdf !== 'undefined' && pdfButton) {
+ pdfButton.disabled = false;
+ pdfButton.innerHTML = ' 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();
+});
\ No newline at end of file
diff --git a/hub/services/static/js/roi-calculator/ui-manager.js b/hub/services/static/js/roi-calculator/ui-manager.js
new file mode 100644
index 0000000..9db5536
--- /dev/null
+++ b/hub/services/static/js/roi-calculator/ui-manager.js
@@ -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' ?
+ 'Loan' :
+ 'Direct';
+
+ const row = tbody.insertRow();
+ row.innerHTML = `
+ ${result.scenario} |
+ ${modelLabel} |
+ ${result.finalInstances.toLocaleString()} |
+ ${this.formatCurrencyDetailed(result.totalRevenue)} |
+ ${this.formatCurrencyDetailed(result.cspRevenue)} |
+ ${this.formatCurrencyDetailed(result.servalaRevenue)} |
+ ${this.formatPercentage(result.roi)} |
+ ${result.breakEvenMonth ? result.breakEvenMonth + ' months' : 'N/A'} |
+ `;
+ });
+ } 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 = `
+ ${data.month} |
+ ${data.scenario} |
+ ${data.newInstances} |
+ ${data.churnedInstances} |
+ ${data.totalInstances.toLocaleString()} |
+ ${this.formatCurrencyDetailed(data.monthlyRevenue)} |
+ ${this.formatCurrencyDetailed(data.cspRevenue)} |
+ ${this.formatCurrencyDetailed(data.servalaRevenue)} |
+ ${this.formatCurrencyDetailed(data.cumulativeCSPRevenue)} |
+ `;
+ });
+ } 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)}%`;
+ }
+ }
+}
\ No newline at end of file
diff --git a/hub/services/templates/calculator/csp_roi_calculator.html b/hub/services/templates/calculator/csp_roi_calculator.html
index 462d584..b69306b 100644
--- a/hub/services/templates/calculator/csp_roi_calculator.html
+++ b/hub/services/templates/calculator/csp_roi_calculator.html
@@ -14,7 +14,33 @@
{% block extra_js %}
-
+
+
+
+
+
+
+
+
{% endblock %}
{% block content %}