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 %}