UI: rediseño responsive desktop + fix categorías + plan actualizado
- Sidebar fija en todas las pantallas (md+): logo, nav, logout - index.html: grid 3/5 + 2/5 en desktop (movimientos | donut chart) - add.html: 2 columnas desktop (form | monto+numpad), botones mobile/desktop unificados - historial.html: max-w-3xl, header con botón Nuevo integrado - dashboard.js: formato de moneda con toLocaleString, skeleton loading, mes dinámico - add.js: lógica simplificada con doSave(), teclado ignora inputs de texto - auth.php + login.php: cookie con httponly y SameSite=Lax - docs/plan/00_PLAN.md: estado actualizado, CLI marcado como fase futura Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
ead1bf8ffc
commit
7cb6107cb4
+42
-53
@@ -1,89 +1,78 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
let currentMonto = "0";
|
||||
let selectedTipo = "gasto"; // default
|
||||
let selectedTipo = "gasto";
|
||||
|
||||
const montoEl = document.getElementById('ui-monto');
|
||||
const btnGasto = document.getElementById('btn-gasto');
|
||||
// Hay dos displays de monto (desktop / mobile) y dos botones guardar
|
||||
const montoEls = ['ui-monto', 'ui-monto-mobile'].map(id => document.getElementById(id)).filter(Boolean);
|
||||
const btnGasto = document.getElementById('btn-gasto');
|
||||
const btnIngreso = document.getElementById('btn-ingreso');
|
||||
const numpad = document.getElementById('ui-numpad');
|
||||
const btnSave = document.getElementById('btn-save');
|
||||
const iptFecha = document.getElementById('ui-fecha');
|
||||
const numpad = document.getElementById('ui-numpad');
|
||||
const iptFecha = document.getElementById('ui-fecha');
|
||||
const saveButtons = ['btn-save', 'btn-save-mobile'].map(id => document.getElementById(id)).filter(Boolean);
|
||||
|
||||
// Default a hoy
|
||||
iptFecha.valueAsDate = new Date();
|
||||
|
||||
// Cargar categorías desde la API
|
||||
apiFetch('api/movimientos.php')
|
||||
.then(r => r.json())
|
||||
.then(r => r ? r.json() : Promise.reject('no-auth'))
|
||||
.then(data => {
|
||||
if (data.categorias) {
|
||||
const sel = document.getElementById('ui-categoria');
|
||||
sel.innerHTML = data.categorias.map(c =>
|
||||
`<option value="${c.id}">${c.nombre}</option>`
|
||||
).join('');
|
||||
}
|
||||
if (!data || !data.categorias) return;
|
||||
const sel = document.getElementById('ui-categoria');
|
||||
sel.innerHTML = data.categorias.map(c =>
|
||||
`<option value="${c.id}">${c.nombre}</option>`
|
||||
).join('');
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Tipo selector
|
||||
const updateTipoUI = () => {
|
||||
if (selectedTipo === 'gasto') {
|
||||
btnGasto.classList.replace('text-on-surface-variant', 'text-primary');
|
||||
btnGasto.classList.add('bg-surface-container-lowest');
|
||||
btnIngreso.classList.replace('bg-surface-container-lowest', 'bg-transparent');
|
||||
btnIngreso.classList.replace('text-primary', 'text-on-surface-variant');
|
||||
} else {
|
||||
btnIngreso.classList.replace('text-on-surface-variant', 'text-primary');
|
||||
btnIngreso.classList.add('bg-surface-container-lowest');
|
||||
btnGasto.classList.replace('bg-surface-container-lowest', 'bg-transparent');
|
||||
btnGasto.classList.replace('text-primary', 'text-on-surface-variant');
|
||||
}
|
||||
const isGasto = selectedTipo === 'gasto';
|
||||
btnGasto.classList.toggle('bg-surface-container-lowest', isGasto);
|
||||
btnGasto.classList.toggle('text-primary', isGasto);
|
||||
btnGasto.classList.toggle('shadow-sm', isGasto);
|
||||
btnGasto.classList.toggle('text-on-surface-variant', !isGasto);
|
||||
btnIngreso.classList.toggle('bg-surface-container-lowest', !isGasto);
|
||||
btnIngreso.classList.toggle('text-primary', !isGasto);
|
||||
btnIngreso.classList.toggle('shadow-sm', !isGasto);
|
||||
btnIngreso.classList.toggle('text-on-surface-variant', isGasto);
|
||||
};
|
||||
|
||||
btnGasto.addEventListener('click', () => { selectedTipo = 'gasto'; updateTipoUI(); });
|
||||
btnGasto.addEventListener('click', () => { selectedTipo = 'gasto'; updateTipoUI(); });
|
||||
btnIngreso.addEventListener('click', () => { selectedTipo = 'ingreso'; updateTipoUI(); });
|
||||
|
||||
// Numpad
|
||||
function applyInput(val) {
|
||||
if (val === 'backspace') {
|
||||
currentMonto = currentMonto.slice(0, -1);
|
||||
if (currentMonto === '') currentMonto = "0";
|
||||
currentMonto = currentMonto.slice(0, -1) || "0";
|
||||
} else if (/^[0-9]$/.test(val)) {
|
||||
if (currentMonto === "0") {
|
||||
currentMonto = val;
|
||||
} else {
|
||||
currentMonto += val;
|
||||
}
|
||||
currentMonto = currentMonto === "0" ? val : currentMonto + val;
|
||||
} else if (val === '.') {
|
||||
if (!currentMonto.includes('.')) currentMonto += '.';
|
||||
}
|
||||
montoEl.textContent = currentMonto;
|
||||
montoEls.forEach(el => el.textContent = currentMonto);
|
||||
}
|
||||
|
||||
// Numpad táctil
|
||||
numpad.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('button');
|
||||
if (!btn) return;
|
||||
if (btn.querySelector('[data-icon="backspace"]')) {
|
||||
applyInput('backspace');
|
||||
} else {
|
||||
applyInput(btn.textContent.trim());
|
||||
}
|
||||
applyInput(btn.querySelector('[data-icon="backspace"]') ? 'backspace' : btn.textContent.trim());
|
||||
});
|
||||
|
||||
// Teclado físico (desktop)
|
||||
// Teclado físico
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (document.activeElement.tagName === 'INPUT') return;
|
||||
if (e.key >= '0' && e.key <= '9') applyInput(e.key);
|
||||
else if (e.key === '.' || e.key === ',') applyInput('.');
|
||||
else if (e.key === 'Backspace') applyInput('backspace');
|
||||
else if (e.key === 'Enter') btnSave.click();
|
||||
else if (e.key === 'Enter') doSave();
|
||||
});
|
||||
|
||||
// Guardar Movimiento
|
||||
btnSave.addEventListener('click', async () => {
|
||||
// Guardar
|
||||
async function doSave() {
|
||||
const payload = {
|
||||
tipo: selectedTipo,
|
||||
monto: parseFloat(currentMonto),
|
||||
fecha: document.getElementById('ui-fecha').value,
|
||||
fecha: iptFecha.value,
|
||||
notas: document.getElementById('ui-notas').value,
|
||||
id_categoria: document.getElementById('ui-categoria').value
|
||||
};
|
||||
@@ -93,29 +82,29 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const btnOriginal = btnSave.innerHTML;
|
||||
btnSave.innerHTML = 'Guardando...';
|
||||
btnSave.disabled = true;
|
||||
saveButtons.forEach(b => { b.disabled = true; b.textContent = 'Guardando…'; });
|
||||
|
||||
try {
|
||||
const r = await apiFetch('api/movimientos.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!r) return;
|
||||
const data = await r.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
window.location.href = 'index.html';
|
||||
} else {
|
||||
await rmModal.alert(data.message, 'Error al guardar');
|
||||
btnSave.innerHTML = btnOriginal;
|
||||
btnSave.disabled = false;
|
||||
saveButtons.forEach(b => { b.disabled = false; b.textContent = 'Guardar Movimiento'; });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
await rmModal.alert('No se pudo conectar con el servidor.', 'Error de conexión');
|
||||
btnSave.disabled = false;
|
||||
saveButtons.forEach(b => { b.disabled = false; b.textContent = 'Guardar Movimiento'; });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
saveButtons.forEach(btn => btn.addEventListener('click', doSave));
|
||||
});
|
||||
|
||||
+76
-68
@@ -1,80 +1,88 @@
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
const response = await apiFetch('api/movimientos.php');
|
||||
if (!response) return;
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
// Update balance
|
||||
document.getElementById('ui-balance').textContent = `$${data.balance.toFixed(2)}`;
|
||||
|
||||
// Render movements
|
||||
const uiMovimientos = document.getElementById('ui-movimientos');
|
||||
uiMovimientos.innerHTML = '';
|
||||
|
||||
data.movimientos.forEach(mov => {
|
||||
const isExpense = mov.tipo === 'gasto';
|
||||
const sign = isExpense ? '-' : '+';
|
||||
const amountColor = isExpense ? 'text-error' : 'text-primary-container';
|
||||
|
||||
uiMovimientos.innerHTML += `
|
||||
<div class="flex items-center justify-between mt-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-2xl bg-surface-container-highest flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-primary">${mov.categoria_icono || 'payments'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-on-surface">${mov.notas || mov.categoria_nombre}</p>
|
||||
<p class="text-xs text-on-surface-variant">${mov.fecha}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="font-bold ${amountColor}">${sign}$${parseFloat(mov.monto).toFixed(2)}</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
if (data.status !== 'success') return;
|
||||
|
||||
// Dynamize Donut Chart
|
||||
if (data.grafico && data.grafico.length > 0) {
|
||||
const totalGastos = data.grafico.reduce((sum, item) => sum + parseFloat(item.total), 0);
|
||||
|
||||
let svgHtml = `<svg class="w-full h-full transform -rotate-90" viewbox="0 0 36 36">`;
|
||||
let legendHtml = '';
|
||||
let currentOffset = 0;
|
||||
// Balance
|
||||
const balance = data.balance ?? 0;
|
||||
document.getElementById('ui-balance').textContent = formatMoney(balance);
|
||||
document.getElementById('ui-balance-resumen').textContent =
|
||||
balance >= 0 ? 'Saldo disponible' : 'Saldo negativo';
|
||||
|
||||
data.grafico.forEach(cat => {
|
||||
const pct = (parseFloat(cat.total) / totalGastos) * 100;
|
||||
|
||||
// Add SVG segment
|
||||
svgHtml += `<circle cx="18" cy="18" fill="none" r="16" stroke="${cat.color_hex}" stroke-dasharray="${pct}, 100" stroke-dashoffset="-${currentOffset}" stroke-linecap="round" stroke-width="4"></circle>`;
|
||||
|
||||
// Add Legend entry
|
||||
legendHtml += `
|
||||
<div class="flex items-center justify-between p-3 bg-surface-container-lowest rounded-xl">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-3 h-3 rounded-full" style="background-color: ${cat.color_hex}"></div>
|
||||
<span class="text-sm font-semibold">${cat.nombre}</span>
|
||||
</div>
|
||||
<span class="text-sm text-on-surface-variant">${pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
currentOffset += pct;
|
||||
});
|
||||
|
||||
svgHtml += `</svg>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span class="text-2xl font-bold">$${totalGastos.toFixed(0)}</span>
|
||||
<span class="text-[10px] uppercase tracking-widest text-on-surface-variant font-bold">Gastos</span>
|
||||
// Movimientos recientes
|
||||
const container = document.getElementById('ui-movimientos');
|
||||
if (!data.movimientos.length) {
|
||||
container.innerHTML = `
|
||||
<div class="flex flex-col items-center py-10 gap-2 text-on-surface-variant">
|
||||
<span class="material-symbols-outlined text-4xl opacity-30">receipt_long</span>
|
||||
<p class="text-sm">Sin movimientos aún</p>
|
||||
</div>`;
|
||||
} else {
|
||||
container.innerHTML = data.movimientos.map(mov => {
|
||||
const isGasto = mov.tipo === 'gasto';
|
||||
const colorAmt = isGasto ? 'text-[#ba1a1a]' : 'text-[#005050]';
|
||||
const signo = isGasto ? '-' : '+';
|
||||
const icono = mov.categoria_icono || 'payments';
|
||||
const titulo = mov.notas?.trim() ? mov.notas : (mov.categoria_nombre || '—');
|
||||
const fecha = new Date(mov.fecha + 'T12:00:00')
|
||||
.toLocaleDateString('es-AR', { day:'2-digit', month:'short' });
|
||||
return `
|
||||
<div class="flex items-center gap-4 px-5 py-4">
|
||||
<div class="w-11 h-11 rounded-xl bg-surface-container-high flex items-center justify-center flex-shrink-0">
|
||||
<span class="material-symbols-outlined text-primary text-[20px]">${icono}</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-semibold text-on-surface text-sm truncate">${titulo}</p>
|
||||
<p class="text-xs text-on-surface-variant">${fecha} · ${mov.categoria_nombre || '—'}</p>
|
||||
</div>
|
||||
<p class="font-bold ${colorAmt} text-sm flex-shrink-0">${signo}${formatMoney(mov.monto)}</p>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
document.getElementById('ui-donut-chart').innerHTML = svgHtml;
|
||||
document.getElementById('ui-donut-legend').innerHTML = legendHtml;
|
||||
} else {
|
||||
document.getElementById('ui-donut-chart').innerHTML = `<div class="flex h-full items-center justify-center text-sm text-on-surface-variant">Sin gastos registrados</div>`;
|
||||
document.getElementById('ui-donut-legend').innerHTML = '';
|
||||
}
|
||||
|
||||
// Donut
|
||||
if (data.grafico?.length) {
|
||||
const total = data.grafico.reduce((s, c) => s + parseFloat(c.total), 0);
|
||||
let svgCircles = '', legend = '', offset = 0;
|
||||
data.grafico.forEach(cat => {
|
||||
const pct = (parseFloat(cat.total) / total) * 100;
|
||||
svgCircles += `<circle cx="18" cy="18" fill="none" r="16"
|
||||
stroke="${cat.color_hex}" stroke-dasharray="${pct} ${100 - pct}"
|
||||
stroke-dashoffset="${-offset}" stroke-linecap="round" stroke-width="4"/>`;
|
||||
legend += `
|
||||
<div class="flex items-center justify-between py-2 px-3 bg-surface-container-lowest rounded-xl">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0" style="background:${cat.color_hex}"></div>
|
||||
<span class="text-sm font-semibold truncate">${cat.nombre}</span>
|
||||
</div>
|
||||
<span class="text-xs text-on-surface-variant font-medium ml-2">${pct.toFixed(1)}%</span>
|
||||
</div>`;
|
||||
offset += pct;
|
||||
});
|
||||
document.getElementById('ui-donut-chart').innerHTML = `
|
||||
<svg class="w-full h-full -rotate-90" viewBox="0 0 36 36">${svgCircles}</svg>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span class="text-xl font-bold">${formatMoney(total)}</span>
|
||||
<span class="text-[9px] uppercase tracking-widest text-on-surface-variant font-bold">Gastos</span>
|
||||
</div>`;
|
||||
document.getElementById('ui-donut-legend').innerHTML = `<div class="space-y-1.5">${legend}</div>`;
|
||||
} else {
|
||||
document.getElementById('ui-donut-chart').innerHTML = `
|
||||
<svg class="w-full h-full -rotate-90" viewBox="0 0 36 36">
|
||||
<circle cx="18" cy="18" fill="none" r="16" stroke="#e1e3e2" stroke-dasharray="100,0" stroke-width="4"/>
|
||||
</svg>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span class="text-xs text-on-surface-variant text-center px-4">Sin gastos</span>
|
||||
</div>`;
|
||||
document.getElementById('ui-donut-legend').innerHTML = '';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error cargando el dashboard:', e);
|
||||
console.error('Dashboard error:', e);
|
||||
}
|
||||
});
|
||||
|
||||
function formatMoney(n) {
|
||||
return '$' + parseFloat(n).toLocaleString('es-AR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user