Feature: Historial, DELETE de movimientos y sistema de modales
- Nueva pantalla historial.html con movimientos agrupados por mes - Botón eliminar en cada movimiento con modal de confirmación - js/ui.js: sistema de modales y toasts reutilizables (reemplaza alert/confirm nativos) - API: endpoint GET ?view=historial y método DELETE en movimientos.php - add.html: categorías cargadas dinámicamente desde la API - index.html: "Ver todo" e ícono de nav apuntan a historial.html - Docker: eliminado volumen nombrado db_data para evitar problemas de permisos Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
5a54fdcfde
commit
1db200343d
+17
-4
@@ -12,6 +12,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Default a hoy
|
||||
iptFecha.valueAsDate = new Date();
|
||||
|
||||
// Cargar categorías desde la API
|
||||
fetch('api/movimientos.php')
|
||||
.then(r => r.json())
|
||||
.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('');
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Tipo selector
|
||||
const updateTipoUI = () => {
|
||||
if (selectedTipo === 'gasto') {
|
||||
@@ -62,7 +75,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
};
|
||||
|
||||
if (isNaN(payload.monto) || payload.monto <= 0) {
|
||||
alert('Por favor ingrese un monto mayor a 0');
|
||||
rmModal.alert('Ingresá un monto mayor a 0.', 'Monto inválido');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -79,15 +92,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const data = await r.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
window.location.href = 'index.html'; // Redirige al inicio tras guardar
|
||||
window.location.href = 'index.html';
|
||||
} else {
|
||||
alert('Error al guardar: ' + data.message);
|
||||
await rmModal.alert(data.message, 'Error al guardar');
|
||||
btnSave.innerHTML = btnOriginal;
|
||||
btnSave.disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Fallo en la comunicación');
|
||||
await rmModal.alert('No se pudo conectar con el servidor.', 'Error de conexión');
|
||||
btnSave.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await cargarHistorial();
|
||||
});
|
||||
|
||||
async function cargarHistorial() {
|
||||
const loading = document.getElementById('ui-loading');
|
||||
const empty = document.getElementById('ui-empty');
|
||||
const lista = document.getElementById('ui-lista');
|
||||
|
||||
try {
|
||||
const r = await fetch('api/movimientos.php?view=historial');
|
||||
const data = await r.json();
|
||||
|
||||
loading.classList.add('hidden');
|
||||
|
||||
if (!data.movimientos || data.movimientos.length === 0) {
|
||||
empty.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
lista.classList.remove('hidden');
|
||||
lista.innerHTML = '';
|
||||
|
||||
// Agrupar por mes
|
||||
const grupos = {};
|
||||
data.movimientos.forEach(mov => {
|
||||
const key = mov.fecha.substring(0, 7); // "2024-03"
|
||||
if (!grupos[key]) grupos[key] = [];
|
||||
grupos[key].push(mov);
|
||||
});
|
||||
|
||||
Object.entries(grupos).forEach(([mes, movs]) => {
|
||||
const [anio, nroMes] = mes.split('-');
|
||||
const nombreMes = new Date(anio, nroMes - 1).toLocaleString('es-AR', { month: 'long', year: 'numeric' });
|
||||
|
||||
lista.innerHTML += `
|
||||
<p class="text-xs uppercase tracking-widest font-bold text-on-surface-variant px-1 pt-5 pb-2">
|
||||
${nombreMes}
|
||||
</p>
|
||||
<div class="bg-surface-container-lowest rounded-[1.5rem] overflow-hidden divide-y divide-surface-container-low">
|
||||
${movs.map(mov => renderMovimiento(mov)).join('')}
|
||||
</div>`;
|
||||
});
|
||||
|
||||
// Asignar eventos de delete después de renderizar
|
||||
lista.querySelectorAll('[data-delete-id]').forEach(btn => {
|
||||
btn.addEventListener('click', () => eliminar(btn.dataset.deleteId, btn.dataset.deleteTxt));
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
loading.classList.add('hidden');
|
||||
rmModal.alert('No se pudo cargar el historial.', 'Error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderMovimiento(mov) {
|
||||
const isGasto = mov.tipo === 'gasto';
|
||||
const signo = isGasto ? '-' : '+';
|
||||
const colorAmt = isGasto ? 'text-[#ba1a1a]' : 'text-[#005050]';
|
||||
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-4 py-3">
|
||||
<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}$${parseFloat(mov.monto).toFixed(2)}</p>
|
||||
<button data-delete-id="${mov.id}" data-delete-txt="${titulo}"
|
||||
class="ml-1 p-2 rounded-xl text-outline hover:text-[#ba1a1a] hover:bg-[#ffdad6] transition-colors flex-shrink-0 active:scale-90">
|
||||
<span class="material-symbols-outlined text-[18px]">delete</span>
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function eliminar(id, titulo) {
|
||||
const ok = await rmModal.confirm(`¿Eliminar "${titulo}"?\nEsta acción no se puede deshacer.`, 'Eliminar movimiento');
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
const r = await fetch('api/movimientos.php', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: parseInt(id) })
|
||||
});
|
||||
const data = await r.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
rmModal.toast('Movimiento eliminado');
|
||||
await cargarHistorial();
|
||||
} else {
|
||||
rmModal.toast(data.message || 'Error al eliminar', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
rmModal.toast('Error de conexión', 'error');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
(function () {
|
||||
const MODAL_HTML = `
|
||||
<div id="rm-modal-overlay" class="fixed inset-0 z-[100] flex items-end justify-center p-6 bg-black/50 backdrop-blur-sm hidden">
|
||||
<div class="w-full max-w-sm bg-white rounded-[2rem] p-6 shadow-2xl space-y-3">
|
||||
<h3 id="rm-modal-title" class="text-base font-bold text-on-surface"></h3>
|
||||
<p id="rm-modal-message" class="text-sm text-on-surface-variant leading-relaxed"></p>
|
||||
<div id="rm-modal-actions" class="flex gap-3 pt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="rm-toast" class="fixed bottom-28 left-1/2 -translate-x-1/2 z-[200] px-5 py-3 rounded-full text-sm font-semibold shadow-xl opacity-0 transition-all duration-300 pointer-events-none whitespace-nowrap"></div>
|
||||
`;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.body.insertAdjacentHTML('beforeend', MODAL_HTML);
|
||||
});
|
||||
|
||||
function show(title, message, buttons) {
|
||||
document.getElementById('rm-modal-title').textContent = title;
|
||||
document.getElementById('rm-modal-message').textContent = message;
|
||||
const actions = document.getElementById('rm-modal-actions');
|
||||
actions.innerHTML = '';
|
||||
buttons.forEach(({ label, style, action }) => {
|
||||
const b = document.createElement('button');
|
||||
b.textContent = label;
|
||||
const styles = {
|
||||
primary: 'flex-1 py-3 bg-primary text-white rounded-xl font-bold active:scale-95 transition-transform',
|
||||
danger: 'flex-1 py-3 bg-[#ba1a1a] text-white rounded-xl font-bold active:scale-95 transition-transform',
|
||||
secondary: 'flex-1 py-3 bg-[#eceeed] text-[#191c1c] rounded-xl font-semibold active:scale-95 transition-transform',
|
||||
};
|
||||
b.className = styles[style] || styles.secondary;
|
||||
b.onclick = action;
|
||||
actions.appendChild(b);
|
||||
});
|
||||
document.getElementById('rm-modal-overlay').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hide() {
|
||||
document.getElementById('rm-modal-overlay').classList.add('hidden');
|
||||
}
|
||||
|
||||
window.rmModal = {
|
||||
alert(message, title = 'Aviso') {
|
||||
return new Promise(resolve => {
|
||||
show(title, message, [
|
||||
{ label: 'Aceptar', style: 'primary', action: () => { hide(); resolve(); } }
|
||||
]);
|
||||
});
|
||||
},
|
||||
confirm(message, title = '¿Confirmar?') {
|
||||
return new Promise(resolve => {
|
||||
show(title, message, [
|
||||
{ label: 'Cancelar', style: 'secondary', action: () => { hide(); resolve(false); } },
|
||||
{ label: 'Eliminar', style: 'danger', action: () => { hide(); resolve(true); } }
|
||||
]);
|
||||
});
|
||||
},
|
||||
toast(message, type = 'success') {
|
||||
const el = document.getElementById('rm-toast');
|
||||
el.textContent = message;
|
||||
el.className = `fixed bottom-28 left-1/2 -translate-x-1/2 z-[200] px-5 py-3 rounded-full text-sm font-semibold shadow-xl transition-all duration-300 pointer-events-none whitespace-nowrap ${
|
||||
type === 'error' ? 'bg-[#ba1a1a] text-white' : 'bg-[#005050] text-white'
|
||||
}`;
|
||||
el.style.opacity = '1';
|
||||
setTimeout(() => { el.style.opacity = '0'; }, 2500);
|
||||
}
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user