diff --git a/docker-compose.yml b/docker-compose.yml index 0242812..5bb0267 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,6 @@ services: build: . volumes: - ./pagos:/var/www/html - - db_data:/var/www/html/db networks: - pagos_net @@ -14,14 +13,10 @@ services: volumes: - ./pagos:/var/www/html - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf - - db_data:/var/www/html/db depends_on: - app networks: - pagos_net -volumes: - db_data: - networks: pagos_net: diff --git a/pagos/add.html b/pagos/add.html index 7049dd5..0ab1a60 100644 --- a/pagos/add.html +++ b/pagos/add.html @@ -128,12 +128,7 @@
@@ -182,5 +177,6 @@
Calm plant leaves + \ No newline at end of file diff --git a/pagos/api/movimientos.php b/pagos/api/movimientos.php index bc77435..7ecc292 100644 --- a/pagos/api/movimientos.php +++ b/pagos/api/movimientos.php @@ -8,32 +8,41 @@ $pdo = getDB(); $method = $_SERVER['REQUEST_METHOD']; if ($method === 'GET') { - // Retornar balance, datos para gráfico y últimos movimientos + $view = $_GET['view'] ?? 'dashboard'; + try { - // 1. Balance total - $stmtBalance = $pdo->query("SELECT - SUM(CASE WHEN tipo = 'ingreso' THEN monto ELSE -monto END) as balance + if ($view === 'historial') { + $stmt = $pdo->query("SELECT m.*, c.nombre as categoria_nombre, c.icono as categoria_icono + FROM movimientos m + LEFT JOIN categorias c ON m.id_categoria = c.id + ORDER BY m.fecha DESC, m.id DESC"); + echo json_encode([ + 'status' => 'success', + 'movimientos' => $stmt->fetchAll(PDO::FETCH_ASSOC) + ]); + exit; + } + + // Dashboard view + $stmtBalance = $pdo->query("SELECT + SUM(CASE WHEN tipo = 'ingreso' THEN monto ELSE -monto END) as balance FROM movimientos"); $balance = $stmtBalance->fetch(PDO::FETCH_ASSOC)['balance'] ?? 0; - // 2. Últimos movimientos - $stmtMovi = $pdo->query("SELECT m.*, c.nombre as categoria_nombre, c.icono as categoria_icono - FROM movimientos m - LEFT JOIN categorias c ON m.id_categoria = c.id + $stmtMovi = $pdo->query("SELECT m.*, c.nombre as categoria_nombre, c.icono as categoria_icono + FROM movimientos m + LEFT JOIN categorias c ON m.id_categoria = c.id ORDER BY m.fecha DESC, m.id DESC LIMIT 5"); $movimientos = $stmtMovi->fetchAll(PDO::FETCH_ASSOC); - // 3. Agrupación por categoría para gráfico donut (sólo gastos de este mes/año) - // Por simplicidad traemos todos los gastos $stmtGraph = $pdo->query("SELECT c.nombre, c.color_hex, SUM(m.monto) as total - FROM movimientos m - JOIN categorias c ON m.id_categoria = c.id + FROM movimientos m + JOIN categorias c ON m.id_categoria = c.id WHERE m.tipo = 'gasto' GROUP BY c.id ORDER BY total DESC"); $grafico = $stmtGraph->fetchAll(PDO::FETCH_ASSOC); - - // 4. Categorías para el modal de nuevo registro + $stmtCat = $pdo->query("SELECT * FROM categorias"); $categorias = $stmtCat->fetchAll(PDO::FETCH_ASSOC); @@ -49,6 +58,26 @@ if ($method === 'GET') { echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); } +} elseif ($method === 'DELETE') { + $data = json_decode(file_get_contents('php://input'), true); + if (!isset($data['id'])) { + http_response_code(400); + die(json_encode(['status' => 'error', 'message' => 'Falta el ID'])); + } + try { + $stmt = $pdo->prepare("DELETE FROM movimientos WHERE id = ?"); + $stmt->execute([(int) $data['id']]); + if ($stmt->rowCount() === 0) { + http_response_code(404); + echo json_encode(['status' => 'error', 'message' => 'Movimiento no encontrado']); + } else { + echo json_encode(['status' => 'success']); + } + } catch (Exception $e) { + http_response_code(500); + echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); + } + } elseif ($method === 'POST') { // Insertar nuevo movimiento $data = json_decode(file_get_contents('php://input'), true); diff --git a/pagos/historial.html b/pagos/historial.html new file mode 100644 index 0000000..dce6633 --- /dev/null +++ b/pagos/historial.html @@ -0,0 +1,121 @@ + + + + + +Historial - Quiet Wealth + + + + + + + + + +
+ +

Historial

+
+ +
+ +
+ hourglass_top +

Cargando movimientos...

+
+ + +
+ + +
+ +
+ + + + + diff --git a/pagos/index.html b/pagos/index.html index 27a9644..c6ff7c5 100644 --- a/pagos/index.html +++ b/pagos/index.html @@ -172,7 +172,7 @@

Movimientos Recientes

- +
@@ -222,8 +222,8 @@ - + \ No newline at end of file diff --git a/pagos/js/add.js b/pagos/js/add.js index 224e2f4..aeefa6e 100644 --- a/pagos/js/add.js +++ b/pagos/js/add.js @@ -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 => + `` + ).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; } }); diff --git a/pagos/js/historial.js b/pagos/js/historial.js new file mode 100644 index 0000000..0816b55 --- /dev/null +++ b/pagos/js/historial.js @@ -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 += ` +

+ ${nombreMes} +

+
+ ${movs.map(mov => renderMovimiento(mov)).join('')} +
`; + }); + + // 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 ` +
+
+ ${icono} +
+
+

${titulo}

+

${fecha} · ${mov.categoria_nombre}

+
+

${signo}$${parseFloat(mov.monto).toFixed(2)}

+ +
`; +} + +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'); + } +} diff --git a/pagos/js/ui.js b/pagos/js/ui.js new file mode 100644 index 0000000..c8a0029 --- /dev/null +++ b/pagos/js/ui.js @@ -0,0 +1,67 @@ +(function () { + const MODAL_HTML = ` + +
+ `; + + 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); + } + }; +})();