website/hub/services/static/js/roi-calculator/ui-manager.js

309 lines
No EOL
14 KiB
JavaScript

/**
* 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('net-position-direct', 'CHF 0');
this.setElementText('net-position-loan', 'CHF 0');
this.setElementText('roi-percentage-direct', '0%');
this.setElementText('roi-percentage-loan', '0%');
return;
}
// Separate direct and loan results
const directResults = enabledResults.filter(r => r.investmentModel === 'direct');
const loanResults = enabledResults.filter(r => r.investmentModel === 'loan');
// Calculate averages for direct investment
if (directResults.length > 0) {
const avgNetPositionDirect = directResults.reduce((sum, r) => sum + (r.netPosition || 0), 0) / directResults.length;
const avgROIDirect = directResults.reduce((sum, r) => sum + r.roi, 0) / directResults.length;
this.setElementText('net-position-direct', this.formatCurrency(avgNetPositionDirect));
this.setElementText('roi-percentage-direct', this.formatPercentage(avgROIDirect));
// Update styling for direct metrics
const netPositionDirectElement = document.getElementById('net-position-direct');
if (netPositionDirectElement) {
if (avgNetPositionDirect > 0) {
netPositionDirectElement.className = 'fw-bold text-success';
} else if (avgNetPositionDirect < 0) {
netPositionDirectElement.className = 'fw-bold text-danger';
} else {
netPositionDirectElement.className = 'fw-bold';
}
}
}
// Calculate averages for loan investment
if (loanResults.length > 0) {
const avgNetPositionLoan = loanResults.reduce((sum, r) => sum + (r.netPosition || 0), 0) / loanResults.length;
const avgROILoan = loanResults.reduce((sum, r) => sum + r.roi, 0) / loanResults.length;
this.setElementText('net-position-loan', this.formatCurrency(avgNetPositionLoan));
this.setElementText('roi-percentage-loan', this.formatPercentage(avgROILoan));
// Update styling for loan metrics
const netPositionLoanElement = document.getElementById('net-position-loan');
if (netPositionLoanElement) {
if (avgNetPositionLoan > 0) {
netPositionLoanElement.className = 'fw-bold text-success';
} else if (avgNetPositionLoan < 0) {
netPositionLoanElement.className = 'fw-bold text-danger';
} else {
netPositionLoanElement.className = 'fw-bold';
}
}
}
} 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 = '';
// Sort results by scenario first, then by model
const sortedResults = Object.values(this.calculator.results).sort((a, b) => {
const scenarioCompare = a.scenario.localeCompare(b.scenario);
if (scenarioCompare !== 0) return scenarioCompare;
return a.investmentModel.localeCompare(b.investmentModel);
});
sortedResults.forEach(result => {
const isDirect = result.investmentModel === 'direct';
const scenarioColor = this.getScenarioColor(result.scenario);
// Model badge with better styling
const modelBadge = isDirect ?
'<span class="fw-bold text-success">Direct</span>' :
'<span class="fw-bold text-warning">Loan</span>';
// Key features based on model
const keyFeatures = isDirect ?
`Grace period: ${result.effectiveGracePeriod || 6} months<br>` +
`Performance multiplier: ${result.performanceMultiplier ? result.performanceMultiplier.toFixed(2) : '1.0'}x` :
`Fixed ${this.formatPercentage(this.calculator.getInputValues().loanInterestRate * 100)} rate<br>` +
'Predictable returns';
const row = tbody.insertRow();
row.innerHTML = `
<td><span class="fw-bold" style="color: ${scenarioColor}">${result.scenario}</span></td>
<td>${modelBadge}</td>
<td>${result.finalInstances ? result.finalInstances.toLocaleString() : 'N/A'}</td>
<td class="fw-bold ${result.netPosition >= 0 ? 'text-success' : 'text-danger'}">
${this.formatCurrencyDetailed(result.netPosition || 0)}
</td>
<td class="fw-bold ${result.roi >= 15 ? 'text-success' : result.roi >= 5 ? 'text-warning' : result.roi < 0 ? 'text-danger' : ''}">
${this.formatPercentage(result.roi || 0)}
</td>
<td>${result.breakEvenMonth ? result.breakEvenMonth + ' months' : '<span class="text-muted">No break-even</span>'}</td>
<td><small class="text-muted">${keyFeatures}</small></td>
`;
});
} catch (error) {
console.error('Error updating comparison table:', error);
}
}
updateMonthlyBreakdown() {
try {
const tbody = document.getElementById('monthly-tbody');
if (!tbody) {
console.error('Monthly breakdown table body not found');
return;
}
tbody.innerHTML = '';
// Get filter settings
const filters = this.getMonthlyBreakdownFilters();
// Combine all monthly data and sort by month, then scenario, then model
const allData = [];
Object.keys(this.calculator.monthlyData).forEach(resultKey => {
this.calculator.monthlyData[resultKey].forEach(monthData => {
const model = resultKey.includes('_loan') ? 'loan' : 'direct';
const scenario = resultKey.replace('_direct', '').replace('_loan', '');
// Apply filters
if (!filters.models[model] || !filters.scenarios[scenario.toLowerCase()]) {
return; // Skip this entry if filtered out
}
allData.push({
...monthData,
model: model,
scenarioKey: scenario
});
});
});
// Sort by month first, then scenario, then model (direct first)
allData.sort((a, b) => {
const monthCompare = a.month - b.month;
if (monthCompare !== 0) return monthCompare;
const scenarioCompare = a.scenarioKey.localeCompare(b.scenarioKey);
if (scenarioCompare !== 0) return scenarioCompare;
return a.model === 'direct' ? -1 : 1; // Direct first
});
allData.forEach(data => {
const row = tbody.insertRow();
const scenarioColor = this.getScenarioColor(data.scenario);
const isDirect = data.model === 'direct';
// Model styling
const modelBadge = isDirect ?
'<span class="text-success fw-bold">Direct</span>' :
'<span class="text-warning fw-bold">Loan</span>';
// Net position styling
const netPositionClass = (data.netPosition || 0) >= 0 ? 'text-success' : 'text-danger';
row.innerHTML = `
<td class="text-center"><strong>${data.month}</strong></td>
<td><span style="color: ${scenarioColor}" class="fw-bold">${data.scenario}</span></td>
<td>${modelBadge}</td>
<td class="text-end">${data.totalInstances ? data.totalInstances.toLocaleString() : '0'}</td>
<td class="text-end">${this.formatCurrencyDetailed(data.serviceRevenue || data.monthlyRevenue || 0)}</td>
<td class="text-end">${this.formatCurrencyDetailed(data.coreRevenue || 0)}</td>
<td class="text-end fw-bold">${this.formatCurrencyDetailed(data.totalRevenue || data.monthlyRevenue || 0)}</td>
<td class="text-end fw-bold">${this.formatCurrencyDetailed(data.cspRevenue || 0)}</td>
<td class="text-end text-muted">${this.formatCurrencyDetailed(data.servalaRevenue || 0)}</td>
<td class="text-end fw-bold ${netPositionClass}">${this.formatCurrencyDetailed(data.netPosition || 0)}</td>
`;
});
} catch (error) {
console.error('Error updating monthly breakdown:', error);
}
}
setElementText(elementId, text) {
const element = document.getElementById(elementId);
if (element) {
element.textContent = text;
}
}
formatCurrency(amount, currency = null) {
try {
// Get current currency if not provided
if (!currency) {
const currencyElement = document.getElementById('currency');
currency = currencyElement ? currencyElement.value : 'CHF';
}
// Determine locale based on currency
const locale = currency === 'EUR' ? 'de-DE' : 'de-CH';
// Use compact notation for large numbers in metric cards
if (amount >= 1000000) {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency,
notation: 'compact',
minimumFractionDigits: 0,
maximumFractionDigits: 1
}).format(amount);
} else {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
}
} catch (error) {
console.error('Error formatting currency:', error);
return `${currency || 'CHF'} ${amount.toFixed(0)}`;
}
}
formatCurrencyDetailed(amount, currency = null) {
try {
// Get current currency if not provided
if (!currency) {
const currencyElement = document.getElementById('currency');
currency = currencyElement ? currencyElement.value : 'CHF';
}
// Determine locale based on currency
const locale = currency === 'EUR' ? 'de-DE' : 'de-CH';
// Use full formatting for detailed views (tables, exports)
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(amount);
} catch (error) {
console.error('Error formatting detailed currency:', error);
return `${currency || '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)}%`;
}
}
getScenarioColor(scenarioName) {
switch(scenarioName.toLowerCase()) {
case 'conservative': return '#28a745';
case 'moderate': return '#ffc107';
case 'aggressive': return '#dc3545';
default: return '#6c757d';
}
}
getMonthlyBreakdownFilters() {
try {
return {
models: {
direct: document.getElementById('breakdown-direct-enabled')?.checked ?? true,
loan: document.getElementById('breakdown-loan-enabled')?.checked ?? true
},
scenarios: {
conservative: document.getElementById('breakdown-conservative-enabled')?.checked ?? true,
moderate: document.getElementById('breakdown-moderate-enabled')?.checked ?? true,
aggressive: document.getElementById('breakdown-aggressive-enabled')?.checked ?? true
}
};
} catch (error) {
console.error('Error getting monthly breakdown filters:', error);
// Return default filters if there's an error
return {
models: { direct: true, loan: true },
scenarios: { conservative: true, moderate: true, aggressive: true }
};
}
}
}