Gráfico ingresos vs gastos #42
@ -560,6 +560,37 @@ button[type="submit"]:hover,
|
|||||||
min-width: 280px;
|
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 {
|
.chart-box {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 320px;
|
height: 320px;
|
||||||
|
|||||||
@ -13,6 +13,15 @@
|
|||||||
tickColor: styles.getPropertyValue('--color-text-muted').trim() || '#6b6560',
|
tickColor: styles.getPropertyValue('--color-text-muted').trim() || '#6b6560',
|
||||||
primaryColor: styles.getPropertyValue('--color-primary').trim() || '#0f766e',
|
primaryColor: styles.getPropertyValue('--color-primary').trim() || '#0f766e',
|
||||||
primaryFill: styles.getPropertyValue('--chart-fill-color').trim() || 'rgba(15, 118, 110, 0.2)',
|
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']
|
palette: ['#ff6384', '#36a2eb', '#cc65fe', '#ffce56', '#4bc0c0', '#ffa1b5']
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -57,6 +66,17 @@
|
|||||||
var registeredCharts = [];
|
var registeredCharts = [];
|
||||||
|
|
||||||
function registerThemedChart(chart, applyColors) {
|
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 });
|
registeredCharts.push({ chart: chart, applyColors: applyColors });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -200,14 +200,35 @@
|
|||||||
|
|
||||||
<div class="charts-container">
|
<div class="charts-container">
|
||||||
<section>
|
<section>
|
||||||
|
<div class="chart-header">
|
||||||
<h2>Evolución de Gastos ({% if chart_type == 'day' %}Por día {% else %} Por Meses{% endif %})</h2>
|
<h2>Evolución de Gastos ({% if chart_type == 'day' %}Por día {% else %} Por Meses{% endif %})</h2>
|
||||||
|
</div>
|
||||||
<div class="chart-box">
|
<div class="chart-box">
|
||||||
<canvas id="mainChart"></canvas>
|
<canvas id="mainChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
<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>
|
<h2>Distribución por Categorías</h2>
|
||||||
|
</div>
|
||||||
<div class="chart-box">
|
<div class="chart-box">
|
||||||
<canvas id="categoryChart"></canvas>
|
<canvas id="categoryChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
@ -335,6 +356,187 @@
|
|||||||
applyCartesianColors(chart, colors);
|
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"];
|
const allMonths = ["Ene", "Feb", "Mar", "Abr", "May", "Jun", "Jul", "Ago", "Sep", "Oct", "Nov", "Dic"];
|
||||||
|
|
||||||
{% for acc in accounts_charts %}
|
{% for acc in accounts_charts %}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user