fix critical issues in ROI calculator
- Replace hardcoded Django template tags in JavaScript with runtime CSRF token retrieval - Add comprehensive error handling for Chart.js dependencies and missing DOM elements - Enhance input validation with safe fallbacks for malformed number parsing - Add graceful degradation when external libraries fail to load 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
2981be12df
commit
51d80364c0
2 changed files with 240 additions and 90 deletions
|
|
@ -48,16 +48,58 @@ class ROICalculator {
|
||||||
}
|
}
|
||||||
|
|
||||||
getInputValues() {
|
getInputValues() {
|
||||||
const investmentModel = document.querySelector('input[name="investment-model"]:checked').value;
|
try {
|
||||||
return {
|
// Get investment model with fallback
|
||||||
investmentAmount: parseFloat(document.getElementById('investment-amount').getAttribute('data-value')),
|
const investmentModelElement = document.querySelector('input[name="investment-model"]:checked');
|
||||||
timeframe: parseInt(document.getElementById('timeframe').value),
|
const investmentModel = investmentModelElement ? investmentModelElement.value : 'direct';
|
||||||
investmentModel: investmentModel,
|
|
||||||
loanInterestRate: parseFloat(document.getElementById('loan-interest-rate').value) / 100,
|
// Get investment amount with validation
|
||||||
revenuePerInstance: parseFloat(document.getElementById('revenue-per-instance').value),
|
const investmentAmountElement = document.getElementById('investment-amount');
|
||||||
servalaShare: parseFloat(document.getElementById('servala-share').value) / 100,
|
const investmentAmountValue = investmentAmountElement ? investmentAmountElement.getAttribute('data-value') : '500000';
|
||||||
gracePeriod: parseInt(document.getElementById('grace-period').value)
|
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) {
|
calculateScenario(scenarioKey, inputs) {
|
||||||
|
|
@ -242,78 +284,123 @@ class ROICalculator {
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeCharts() {
|
initializeCharts() {
|
||||||
// Instance Growth Chart
|
// Check if Chart.js is available
|
||||||
const instanceCtx = document.getElementById('instanceGrowthChart').getContext('2d');
|
if (typeof Chart === 'undefined') {
|
||||||
this.charts.instanceGrowth = new Chart(instanceCtx, {
|
console.error('Chart.js library not loaded. Charts will not be available.');
|
||||||
type: 'line',
|
this.showChartError('Chart.js library failed to load. Please refresh the page.');
|
||||||
data: { labels: [], datasets: [] },
|
return;
|
||||||
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
|
try {
|
||||||
const revenueCtx = document.getElementById('revenueChart').getContext('2d');
|
// Instance Growth Chart
|
||||||
this.charts.revenue = new Chart(revenueCtx, {
|
const instanceCanvas = document.getElementById('instanceGrowthChart');
|
||||||
type: 'line',
|
if (!instanceCanvas) {
|
||||||
data: { labels: [], datasets: [] },
|
console.error('Instance growth chart canvas not found');
|
||||||
options: {
|
return;
|
||||||
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' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
const instanceCtx = instanceCanvas.getContext('2d');
|
||||||
|
this.charts.instanceGrowth = new Chart(instanceCtx, {
|
||||||
// Cash Flow Chart
|
type: 'line',
|
||||||
const cashFlowCtx = document.getElementById('cashFlowChart').getContext('2d');
|
data: { labels: [], datasets: [] },
|
||||||
this.charts.cashFlow = new Chart(cashFlowCtx, {
|
options: {
|
||||||
type: 'bar',
|
responsive: true,
|
||||||
data: { labels: [], datasets: [] },
|
maintainAspectRatio: false,
|
||||||
options: {
|
plugins: {
|
||||||
responsive: true,
|
legend: { position: 'top' }
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: { position: 'top' }
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
title: { display: true, text: 'Monthly Cash Flow (CHF)' }
|
|
||||||
},
|
},
|
||||||
x: {
|
scales: {
|
||||||
title: { display: true, text: 'Month' }
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
title: { display: true, text: 'Total Instances' }
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
title: { display: true, text: 'Month' }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Revenue Chart
|
||||||
|
const revenueCanvas = document.getElementById('revenueChart');
|
||||||
|
if (!revenueCanvas) {
|
||||||
|
console.error('Revenue chart canvas not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const revenueCtx = revenueCanvas.getContext('2d');
|
||||||
|
this.charts.revenue = new Chart(revenueCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: { labels: [], datasets: [] },
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
title: { display: true, text: 'Cumulative Revenue (CHF)' }
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
title: { display: true, text: 'Month' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cash Flow Chart
|
||||||
|
const cashFlowCanvas = document.getElementById('cashFlowChart');
|
||||||
|
if (!cashFlowCanvas) {
|
||||||
|
console.error('Cash flow chart canvas not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cashFlowCtx = cashFlowCanvas.getContext('2d');
|
||||||
|
this.charts.cashFlow = new Chart(cashFlowCtx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: { labels: [], datasets: [] },
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
title: { display: true, text: 'Monthly Cash Flow (CHF)' }
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
title: { display: true, text: 'Month' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing charts:', error);
|
||||||
|
this.showChartError('Failed to initialize charts. Please refresh the page.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showChartError(message) {
|
||||||
|
// Show error message in place of charts
|
||||||
|
const chartContainers = ['instanceGrowthChart', 'revenueChart', 'cashFlowChart'];
|
||||||
|
chartContainers.forEach(containerId => {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (container) {
|
||||||
|
container.style.display = 'flex';
|
||||||
|
container.style.justifyContent = 'center';
|
||||||
|
container.style.alignItems = 'center';
|
||||||
|
container.style.minHeight = '300px';
|
||||||
|
container.style.backgroundColor = '#f8f9fa';
|
||||||
|
container.style.border = '1px solid #dee2e6';
|
||||||
|
container.style.borderRadius = '4px';
|
||||||
|
container.innerHTML = `<div class="text-muted text-center"><i class="bi bi-exclamation-triangle"></i><br>${message}</div>`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCharts() {
|
updateCharts() {
|
||||||
const scenarios = Object.keys(this.results);
|
const scenarios = Object.keys(this.results);
|
||||||
if (scenarios.length === 0) return;
|
if (scenarios.length === 0 || !this.charts.instanceGrowth) return;
|
||||||
|
|
||||||
const colors = {
|
const colors = {
|
||||||
conservative: '#28a745',
|
conservative: '#28a745',
|
||||||
|
|
@ -530,31 +617,68 @@ function formatNumberWithCommas(num) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseFormattedNumber(str) {
|
function parseFormattedNumber(str) {
|
||||||
return parseFloat(str.replace(/,/g, '')) || 0;
|
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) {
|
function handleInvestmentAmountInput(input) {
|
||||||
// Remove non-numeric characters except commas
|
if (!input || typeof input.value !== 'string') {
|
||||||
let value = input.value.replace(/[^\d,]/g, '');
|
console.error('Invalid input element provided to handleInvestmentAmountInput');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Parse the numeric value
|
try {
|
||||||
let numericValue = parseFormattedNumber(value);
|
// Remove non-numeric characters except commas
|
||||||
|
let value = input.value.replace(/[^\d,]/g, '');
|
||||||
|
|
||||||
// Enforce min/max limits
|
// Parse the numeric value
|
||||||
if (numericValue < 100000) numericValue = 100000;
|
let numericValue = parseFormattedNumber(value);
|
||||||
if (numericValue > 2000000) numericValue = 2000000;
|
|
||||||
|
|
||||||
// Update the data attribute with the raw numeric value
|
// Enforce min/max limits
|
||||||
input.setAttribute('data-value', numericValue.toString());
|
const minValue = 100000;
|
||||||
|
const maxValue = 2000000;
|
||||||
|
|
||||||
|
if (numericValue < minValue) {
|
||||||
|
numericValue = minValue;
|
||||||
|
} else if (numericValue > maxValue) {
|
||||||
|
numericValue = maxValue;
|
||||||
|
}
|
||||||
|
|
||||||
// Format and display the value with commas
|
// Update the data attribute with the raw numeric value
|
||||||
input.value = formatNumberWithCommas(numericValue);
|
input.setAttribute('data-value', numericValue.toString());
|
||||||
|
|
||||||
// Update the slider
|
// Format and display the value with commas
|
||||||
document.getElementById('investment-slider').value = numericValue;
|
input.value = formatNumberWithCommas(numericValue);
|
||||||
|
|
||||||
// Trigger calculations
|
// Update the slider if it exists
|
||||||
updateCalculations();
|
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) {
|
function updateInvestmentAmount(value) {
|
||||||
|
|
@ -974,13 +1098,13 @@ function logout() {
|
||||||
// Create a form to submit logout request
|
// Create a form to submit logout request
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
form.method = 'POST';
|
form.method = 'POST';
|
||||||
form.action = '{% url "services:csp_roi_calculator" %}';
|
form.action = window.location.pathname;
|
||||||
|
|
||||||
// Add CSRF token
|
// Add CSRF token from page meta tag or cookie
|
||||||
const csrfInput = document.createElement('input');
|
const csrfInput = document.createElement('input');
|
||||||
csrfInput.type = 'hidden';
|
csrfInput.type = 'hidden';
|
||||||
csrfInput.name = 'csrfmiddlewaretoken';
|
csrfInput.name = 'csrfmiddlewaretoken';
|
||||||
csrfInput.value = '{{ csrf_token }}';
|
csrfInput.value = getCSRFToken();
|
||||||
form.appendChild(csrfInput);
|
form.appendChild(csrfInput);
|
||||||
|
|
||||||
// Add logout parameter
|
// Add logout parameter
|
||||||
|
|
@ -994,3 +1118,25 @@ function logout() {
|
||||||
form.submit();
|
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 '';
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@
|
||||||
|
|
||||||
{% block title %}CSP ROI Calculator{% endblock %}
|
{% block title %}CSP ROI Calculator{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<link rel="stylesheet" type="text/css" href='{% static "css/roi-calculator.css" %}'>
|
<link rel="stylesheet" type="text/css" href='{% static "css/roi-calculator.css" %}'>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue