309 lines
No EOL
14 KiB
JavaScript
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 }
|
|
};
|
|
}
|
|
}
|
|
} |