Add income vs expense chart with selectable modes
This commit is contained in:
parent
27703bd47c
commit
3eb36a1226
@ -560,6 +560,37 @@ button[type="submit"]:hover,
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
/* Cabecera común de las tarjetas de gráfico: el título a la izquierda y, si lo
|
||||
lleva, su control a la derecha. El min-height reserva el alto del selector
|
||||
también en las tarjetas que no lo tienen, así que los dos ejes arrancan a la
|
||||
misma altura por construcción y no por casualidad. Por eso no se compensa
|
||||
tocando el alto del canvas: eso cuadraría solo a un ancho concreto.
|
||||
Los márgenes son los que traía el h2, para no mover el ritmo vertical. */
|
||||
.chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
min-height: 2.5rem;
|
||||
margin: 1.5rem 0 0.75rem;
|
||||
}
|
||||
|
||||
/* Sin esto el margen del h2 se sumaría al de la cabecera y el min-height
|
||||
dejaría de mandar. */
|
||||
.chart-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* El alto del control se fija por debajo del min-height de la cabecera. Así la
|
||||
cabecera mide siempre el min-height en las dos tarjetas y la alineación no
|
||||
depende de cuánto mida un <select> en cada navegador, que es justo lo que
|
||||
haría que cuadrara en uno y no en otro. */
|
||||
.chart-header select {
|
||||
height: 2.25rem;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.chart-box {
|
||||
position: relative;
|
||||
height: 320px;
|
||||
|
||||
@ -13,6 +13,15 @@
|
||||
tickColor: styles.getPropertyValue('--color-text-muted').trim() || '#6b6560',
|
||||
primaryColor: styles.getPropertyValue('--color-primary').trim() || '#0f766e',
|
||||
primaryFill: styles.getPropertyValue('--chart-fill-color').trim() || 'rgba(15, 118, 110, 0.2)',
|
||||
// Verde ingreso / rojo gasto, los mismos acentos que usan los
|
||||
// importes de las tablas, para que el gráfico no invente un código
|
||||
// de color propio.
|
||||
successColor: styles.getPropertyValue('--color-success-accent').trim() || '#1e7e34',
|
||||
dangerColor: styles.getPropertyValue('--color-danger-accent').trim() || '#b91c1c',
|
||||
// Para aros y contornos que tienen que leerse encima de otra serie.
|
||||
// Cambia con el tema, así que el punto se recorta igual en claro
|
||||
// que en oscuro.
|
||||
surfaceColor: styles.getPropertyValue('--color-surface').trim() || '#ffffff',
|
||||
palette: ['#ff6384', '#36a2eb', '#cc65fe', '#ffce56', '#4bc0c0', '#ffa1b5']
|
||||
};
|
||||
}
|
||||
@ -57,6 +66,17 @@
|
||||
var registeredCharts = [];
|
||||
|
||||
function registerThemedChart(chart, applyColors) {
|
||||
// Un gráfico que se recrea (destroy() + new Chart, que es lo que exige
|
||||
// cambiar de tipo) vuelve a pasar por aquí. El registro solo acumulaba,
|
||||
// así que la entrada vieja se quedaba apuntando a un gráfico destruido y
|
||||
// refreshThemedCharts() petaba al llamarle update(), dejando sin tema a
|
||||
// todos los gráficos registrados por detrás. Se descartan las entradas
|
||||
// del mismo canvas y las de gráficos ya destruidos: destroy() deja
|
||||
// chart.canvas en null.
|
||||
registeredCharts = registeredCharts.filter(function (entry) {
|
||||
return entry.chart.canvas && entry.chart.canvas !== chart.canvas;
|
||||
});
|
||||
|
||||
registeredCharts.push({ chart: chart, applyColors: applyColors });
|
||||
}
|
||||
|
||||
|
||||
@ -200,14 +200,35 @@
|
||||
|
||||
<div class="charts-container">
|
||||
<section>
|
||||
<h2>Evolución de Gastos ({% if chart_type == 'day' %}Por día {% else %} Por Meses{% endif %})</h2>
|
||||
<div class="chart-header">
|
||||
<h2>Evolución de Gastos ({% if chart_type == 'day' %}Por día {% else %} Por Meses{% endif %})</h2>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<canvas id="mainChart"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Distribución por Categorías</h2>
|
||||
<div class="chart-header">
|
||||
<h2>Ingresos vs gastos ({% if chart_type == 'day' %}Por día{% else %}Por meses{% endif %})</h2>
|
||||
<div class="filters-main">
|
||||
<label class="sr-only" for="balanceChartMode">Modo del gráfico</label>
|
||||
<select id="balanceChartMode">
|
||||
<option value="bars">Barras agrupadas</option>
|
||||
<option value="lines">Dos líneas</option>
|
||||
<option value="net">Barras + neto</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<canvas id="balanceChart"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="chart-header">
|
||||
<h2>Distribución por Categorías</h2>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<canvas id="categoryChart"></canvas>
|
||||
</div>
|
||||
@ -335,6 +356,187 @@
|
||||
applyCartesianColors(chart, colors);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Ingresos vs gastos: tres modos, todos en cliente
|
||||
// ---------------------------------------------------------------
|
||||
(function () {
|
||||
const incomeData = {{ chart_income_data|safe }};
|
||||
const expenseData = data;
|
||||
// El neto se resta aquí: las dos series vienen del servidor una sola
|
||||
// vez y cambiar de modo no le pide nada.
|
||||
const netData = incomeData.map(function (value, i) {
|
||||
return value - (expenseData[i] || 0);
|
||||
});
|
||||
|
||||
const canvas = document.getElementById('balanceChart');
|
||||
const modeSelect = document.getElementById('balanceChartMode');
|
||||
const STORAGE_KEY = 'dashboardBalanceChartMode';
|
||||
const MODES = ['bars', 'lines', 'net'];
|
||||
let balanceChart = null;
|
||||
|
||||
function storedMode() {
|
||||
// localStorage puede fallar (modo privado, cookies bloqueadas):
|
||||
// si no se puede leer, se asume el modo por defecto.
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
return MODES.indexOf(saved) !== -1 ? saved : 'bars';
|
||||
} catch (e) {
|
||||
return 'bars';
|
||||
}
|
||||
}
|
||||
|
||||
function rememberMode(mode) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, mode);
|
||||
} catch (e) {
|
||||
// Sin persistencia, pero el gráfico ya ha cambiado.
|
||||
}
|
||||
}
|
||||
|
||||
// Chart.js dibuja los datasets ordenados por `order` y recorriéndolos
|
||||
// del revés, así que el de `order` MÁS BAJO se pinta ÚLTIMO y queda
|
||||
// encima. El orden del array no manda: sin order explícito la línea
|
||||
// del neto se quedaba por detrás de las barras.
|
||||
const ORDER_NET = 0;
|
||||
const ORDER_INCOMES = 1;
|
||||
const ORDER_EXPENSES = 2;
|
||||
|
||||
function datasetsFor(mode, colors) {
|
||||
const incomes = {
|
||||
label: 'Ingresos (€)',
|
||||
data: incomeData,
|
||||
backgroundColor: colors.successColor,
|
||||
borderColor: colors.successColor,
|
||||
borderWidth: 1,
|
||||
order: ORDER_INCOMES
|
||||
};
|
||||
const expenses = {
|
||||
label: 'Gastos (€)',
|
||||
data: expenseData,
|
||||
backgroundColor: colors.dangerColor,
|
||||
borderColor: colors.dangerColor,
|
||||
borderWidth: 1,
|
||||
order: ORDER_EXPENSES
|
||||
};
|
||||
|
||||
if (mode === 'lines') {
|
||||
incomes.tension = 0.3;
|
||||
expenses.tension = 0.3;
|
||||
incomes.fill = false;
|
||||
expenses.fill = false;
|
||||
return [incomes, expenses];
|
||||
}
|
||||
|
||||
if (mode === 'net') {
|
||||
return [incomes, expenses, {
|
||||
label: 'Neto (€)',
|
||||
data: netData,
|
||||
type: 'line',
|
||||
order: ORDER_NET,
|
||||
borderColor: colors.primaryColor,
|
||||
backgroundColor: colors.primaryColor,
|
||||
borderWidth: 3,
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
// Puntos con aro del color del fondo: así se recortan
|
||||
// sobre las barras en vez de diluirse en ellas.
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
pointBackgroundColor: colors.primaryColor,
|
||||
pointBorderColor: colors.surfaceColor,
|
||||
pointBorderWidth: 2
|
||||
}];
|
||||
}
|
||||
|
||||
return [incomes, expenses];
|
||||
}
|
||||
|
||||
function applyBalanceColors(chart, colors) {
|
||||
chart.data.datasets.forEach(function (dataset) {
|
||||
if (dataset.label === 'Ingresos (€)') {
|
||||
dataset.backgroundColor = colors.successColor;
|
||||
dataset.borderColor = colors.successColor;
|
||||
} else if (dataset.label === 'Gastos (€)') {
|
||||
dataset.backgroundColor = colors.dangerColor;
|
||||
dataset.borderColor = colors.dangerColor;
|
||||
} else {
|
||||
dataset.backgroundColor = colors.primaryColor;
|
||||
dataset.borderColor = colors.primaryColor;
|
||||
// El aro del punto va del color del fondo, que cambia
|
||||
// con el tema: sin esto se queda con el color anterior
|
||||
// y el punto deja de recortarse.
|
||||
dataset.pointBackgroundColor = colors.primaryColor;
|
||||
dataset.pointBorderColor = colors.surfaceColor;
|
||||
}
|
||||
});
|
||||
applyCartesianColors(chart, colors);
|
||||
}
|
||||
|
||||
function render(mode) {
|
||||
const colors = getChartColors();
|
||||
|
||||
// Cambiar de modo obliga a destruir y crear de nuevo. Mutar
|
||||
// chart.config.type o chart.options.scales sobre un gráfico ya
|
||||
// creado es lo que provocaba "t.startsWith is not a function".
|
||||
if (balanceChart) {
|
||||
balanceChart.destroy();
|
||||
}
|
||||
|
||||
balanceChart = new Chart(canvas, {
|
||||
type: mode === 'lines' ? 'line' : 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: datasetsFor(mode, colors)
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: colors.gridColor },
|
||||
ticks: { color: colors.tickColor }
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: colors.gridColor },
|
||||
ticks: { color: colors.tickColor }
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: colors.tickColor,
|
||||
// `order` también ordena la leyenda, y con el
|
||||
// neto en 0 saldría listado primero. Se fija
|
||||
// por índice del dataset para que se lea
|
||||
// Ingresos, Gastos y el neto al final, que es
|
||||
// el derivado. Explícito y no por defecto:
|
||||
// así no depende de cómo ordene Chart.js.
|
||||
sort: function (a, b) {
|
||||
return a.datasetIndex - b.datasetIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Imprescindible tras recrear: si no se vuelve a registrar, el
|
||||
// gráfico queda fuera del tema y el modo oscuro deja de
|
||||
// aplicársele en cuanto el usuario cambia de modo.
|
||||
registerThemedChart(balanceChart, applyBalanceColors);
|
||||
}
|
||||
|
||||
const initialMode = storedMode();
|
||||
modeSelect.value = initialMode;
|
||||
render(initialMode);
|
||||
|
||||
modeSelect.addEventListener('change', function () {
|
||||
rememberMode(modeSelect.value);
|
||||
render(modeSelect.value);
|
||||
});
|
||||
})();
|
||||
|
||||
const allMonths = ["Ene", "Feb", "Mar", "Abr", "May", "Jun", "Jul", "Ago", "Sep", "Oct", "Nov", "Dic"];
|
||||
|
||||
{% for acc in accounts_charts %}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user