Initial commit
This commit is contained in:
260
resources/js/account-balances-chart.js
Normal file
260
resources/js/account-balances-chart.js
Normal file
@@ -0,0 +1,260 @@
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
window.initAccountBalancesChart = function(timelineData, decimalFormat) {
|
||||
const chartCanvas = document.getElementById('accountBalancesChart');
|
||||
if (!chartCanvas || !timelineData || timelineData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = chartCanvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Destroy existing chart if it exists
|
||||
if (chartCanvas.chart) {
|
||||
chartCanvas.chart.destroy();
|
||||
}
|
||||
|
||||
// Format minutes as either HH:MM or decimal hours
|
||||
function formatMinutes(minutes) {
|
||||
if (decimalFormat) {
|
||||
const isNegative = minutes < 0;
|
||||
const absMinutes = Math.abs(minutes);
|
||||
const decimal = (absMinutes / 60).toFixed(2).replace('.', ',');
|
||||
return (isNegative ? '-' : '') + decimal + ' h.';
|
||||
}
|
||||
const isNegative = minutes < 0;
|
||||
const absMinutes = Math.abs(minutes);
|
||||
const wholeHours = Math.floor(absMinutes / 60);
|
||||
const restMinutes = String(absMinutes % 60).padStart(2, '0');
|
||||
return 'H ' + (isNegative ? '-' : '') + wholeHours + ':' + restMinutes;
|
||||
}
|
||||
|
||||
try {
|
||||
// Prepare datasets for each account
|
||||
const datasets = [];
|
||||
const allAccounts = new Map();
|
||||
|
||||
timelineData.forEach(month => {
|
||||
if (month.accounts && Array.isArray(month.accounts)) {
|
||||
month.accounts.forEach(account => {
|
||||
if (!allAccounts.has(account.account_id)) {
|
||||
allAccounts.set(account.account_id, {
|
||||
name: account.account_name,
|
||||
id: account.account_id
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444'];
|
||||
let colorIndex = 0;
|
||||
|
||||
allAccounts.forEach((accountInfo, accountId) => {
|
||||
const accountData = timelineData.map(month => {
|
||||
const accountBalance = month.accounts?.find(acc => acc.account_id === accountId);
|
||||
const minutes = accountBalance ? parseInt(accountBalance.balance) : 0;
|
||||
// Convert minutes to decimal hours for chart display
|
||||
return minutes / 60;
|
||||
});
|
||||
|
||||
datasets.push({
|
||||
label: accountInfo.name,
|
||||
data: accountData,
|
||||
borderColor: colors[colorIndex % colors.length],
|
||||
backgroundColor: colors[colorIndex % colors.length] + '33', // 0.2 transparency (33 in hex = 20% opacity)
|
||||
borderWidth: 3,
|
||||
tension: 0.4, // More curved line
|
||||
fill: true, // Fill below the line
|
||||
pointRadius: 6,
|
||||
pointHoverRadius: 8,
|
||||
pointBackgroundColor: colors[colorIndex % colors.length],
|
||||
pointBorderColor: '#ffffff',
|
||||
pointBorderWidth: 2,
|
||||
});
|
||||
|
||||
colorIndex++;
|
||||
});
|
||||
|
||||
if (datasets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chartLabels = timelineData.map(item => item.label);
|
||||
const chartType = chartLabels.length === 1 ? 'bar' : 'line';
|
||||
|
||||
// Get translated labels from the first data point
|
||||
const translations = timelineData[0]?.translations || {
|
||||
period: 'Period',
|
||||
balance: 'Balance'
|
||||
};
|
||||
|
||||
const config = {
|
||||
type: chartType,
|
||||
data: {
|
||||
labels: chartLabels,
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
align: 'end',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
color: '#1F2937'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const minutes = Math.round(context.parsed.y * 60);
|
||||
return context.dataset.label + ': ' + formatMinutes(minutes);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: translations.period,
|
||||
color: '#6B7280'
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: '#6B7280'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: translations.balance,
|
||||
color: '#6B7280'
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: '#6B7280',
|
||||
callback: function(value) {
|
||||
const minutes = Math.round(value * 60);
|
||||
return formatMinutes(minutes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
chartCanvas.chart = new Chart(ctx, config);
|
||||
|
||||
// Store chart instance globally for PDF export
|
||||
window.accountBalancesChart = chartCanvas.chart;
|
||||
|
||||
} catch (error) {
|
||||
}
|
||||
};
|
||||
|
||||
// EXACT COPY of return ratio chart initialization pattern
|
||||
function initializeChart() {
|
||||
// Find chart container
|
||||
const chartContainer = document.querySelector('[data-balance-chart-data]');
|
||||
if (!chartContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get chart data
|
||||
const timelineDataAttr = chartContainer.getAttribute('data-balance-chart-data');
|
||||
|
||||
if (!timelineDataAttr) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const timelineData = JSON.parse(timelineDataAttr);
|
||||
const decimalFormat = chartContainer.getAttribute('data-decimal-format') === '1';
|
||||
|
||||
// Initialize the chart
|
||||
if (typeof window.initAccountBalancesChart === 'function') {
|
||||
window.initAccountBalancesChart(timelineData, decimalFormat);
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize chart on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(initializeChart, 100);
|
||||
});
|
||||
|
||||
// Listen for Livewire updates - debounced to prevent rapid re-initialization
|
||||
document.addEventListener('livewire:init', () => {
|
||||
let morphTimer = null;
|
||||
Livewire.hook('morph.updated', () => {
|
||||
clearTimeout(morphTimer);
|
||||
morphTimer = setTimeout(() => {
|
||||
initializeChart();
|
||||
}, 300);
|
||||
});
|
||||
});
|
||||
|
||||
// Function to export Account Balances chart as base64 image for PDF
|
||||
window.exportAccountBalancesChartForPdf = async function() {
|
||||
|
||||
if (!window.accountBalancesChart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const originalChart = window.accountBalancesChart;
|
||||
|
||||
// Render into an offscreen canvas to avoid touching the live DOM canvas
|
||||
// (which can be detached by Livewire morphs mid-export)
|
||||
const pdfWidth = 900;
|
||||
const pdfHeight = 400;
|
||||
|
||||
const offscreen = document.createElement('canvas');
|
||||
offscreen.width = pdfWidth;
|
||||
offscreen.height = pdfHeight;
|
||||
const ctx = offscreen.getContext('2d');
|
||||
|
||||
// Clone config from existing chart
|
||||
const offscreenChart = new Chart(ctx, {
|
||||
type: originalChart.config.type,
|
||||
data: JSON.parse(JSON.stringify(originalChart.config.data)),
|
||||
options: {
|
||||
...JSON.parse(JSON.stringify(originalChart.config.options || {})),
|
||||
responsive: false,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for render
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
const chartImage = offscreen.toDataURL('image/png', 1.0);
|
||||
|
||||
offscreenChart.destroy();
|
||||
|
||||
return chartImage;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user