Compare commits
2
Commits
5a54fdcfde
...
1244235820
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1244235820 | ||
|
|
1db200343d |
@@ -1,2 +1,5 @@
|
||||
# Credenciales locales — nunca commitear
|
||||
pagos/api/config.php
|
||||
|
||||
# Assets generados (se descargan al hacer docker compose build)
|
||||
pagos/js/tailwind.cdn.js
|
||||
|
||||
+10
-2
@@ -1,6 +1,14 @@
|
||||
FROM php:8.2-fpm-alpine
|
||||
|
||||
RUN apk add --no-cache sqlite sqlite-dev \
|
||||
&& docker-php-ext-install pdo pdo_sqlite
|
||||
RUN apk add --no-cache sqlite sqlite-dev curl \
|
||||
&& docker-php-ext-install pdo pdo_sqlite \
|
||||
&& curl -sL "https://cdn.tailwindcss.com?plugins=forms,container-queries" \
|
||||
-o /tmp/tailwind.cdn.js
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["php-fpm"]
|
||||
|
||||
+1
-5
@@ -3,7 +3,6 @@ services:
|
||||
build: .
|
||||
volumes:
|
||||
- ./pagos:/var/www/html
|
||||
- db_data:/var/www/html/db
|
||||
networks:
|
||||
- pagos_net
|
||||
|
||||
@@ -14,14 +13,11 @@ services:
|
||||
volumes:
|
||||
- ./pagos:/var/www/html
|
||||
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf
|
||||
- db_data:/var/www/html/db
|
||||
- ./docker/htpasswd:/etc/nginx/.htpasswd:ro
|
||||
depends_on:
|
||||
- app
|
||||
networks:
|
||||
- pagos_net
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
|
||||
networks:
|
||||
pagos_net:
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
# Copiar tailwind.cdn.js al directorio servido si no existe
|
||||
if [ ! -f /var/www/html/js/tailwind.cdn.js ]; then
|
||||
cp /tmp/tailwind.cdn.js /var/www/html/js/tailwind.cdn.js
|
||||
fi
|
||||
exec "$@"
|
||||
@@ -0,0 +1 @@
|
||||
root:$apr1$69HFcTHn$9hHiieirPfFe3M.lo87O21
|
||||
@@ -5,6 +5,9 @@ server {
|
||||
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
|
||||
auth_basic "Quiet Wealth";
|
||||
auth_basic_user_file /etc/nginx/.htpasswd;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
@@ -15,6 +18,8 @@ server {
|
||||
fastcgi_index index.php;
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
fastcgi_param PHP_AUTH_USER $remote_user;
|
||||
fastcgi_param PHP_AUTH_PW $http_authorization;
|
||||
}
|
||||
|
||||
location ~ /db/ {
|
||||
|
||||
+7
-13
@@ -4,10 +4,9 @@
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Nuevo Movimiento - Sanctuary</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script src="js/tailwind.cdn.js"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
@@ -98,6 +97,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background text-on-background min-h-screen flex flex-col">
|
||||
<div class="max-w-md mx-auto w-full flex flex-col flex-1 relative">
|
||||
<!-- Top AppBar - Transactional Context (Shell Suppressed per mandate) -->
|
||||
<header class="bg-[#f8faf9] dark:bg-[#191c1c] w-full top-0 px-6 py-4 flex justify-between items-center bg-transparent">
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -128,12 +128,7 @@
|
||||
<div class="space-y-2">
|
||||
<label class="text-[11px] uppercase tracking-widest font-bold text-on-surface-variant px-1">Categoría</label>
|
||||
<select id="ui-categoria" class="bg-surface-container-lowest p-4 rounded-xl w-full font-bold text-on-surface focus:outline-none">
|
||||
<option value="1">Comida</option>
|
||||
<option value="2">Vivienda</option>
|
||||
<option value="3">Transporte</option>
|
||||
<option value="4">Servicios</option>
|
||||
<option value="5">Entretenimiento</option>
|
||||
<option value="6">Ingresos</option>
|
||||
<option value="">Cargando...</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Date & Notes Grid -->
|
||||
@@ -179,8 +174,7 @@
|
||||
<span class="material-symbols-outlined" data-icon="check_circle">check_circle</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Background Decorative Image (Sanctuary Aesthetic) -->
|
||||
<div class="fixed top-0 right-0 -z-10 opacity-10">
|
||||
<img alt="Calm plant leaves" class="w-64 h-64 object-cover rounded-full blur-xl translate-x-20 -translate-y-20" data-alt="Close-up of minimalist green tropical leaves with soft natural light and subtle shadows on a neutral background" src="https://lh3.googleusercontent.com/aida-public/AB6AXuD2h_FlCe-zmCdWpoDHshmlKT5cXI45RMNZ5-AgxCqZYUPAm3glpQE_wwgbqJ2nxoYSqTenbalJ4bQQBYGIiBfP5wtSkQt6hOJ-Gtl_cifGTAktK43DvjfdfiHDniAMFOJUG0cJmjTrd3nISnFPn-xNsg3CSTGPMIIW_n5H7HSUQcYBeDxWje7Wu8zDxapTTpMfci3Y4IeXwn-Wq94lmbuXPYPbpNtL19_R_6JNyujOMerXEhtUQm5nUIjIEG27anBV5TGHfaZabw"/>
|
||||
</div>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/add.js"></script>
|
||||
</body></html>
|
||||
+43
-14
@@ -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);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>Historial - Quiet Wealth</title>
|
||||
<script src="js/tailwind.cdn.js"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"background": "#f8faf9",
|
||||
"primary-container": "#006a6a",
|
||||
"surface-container-lowest": "#ffffff",
|
||||
"on-primary-fixed": "#002020",
|
||||
"on-background": "#191c1c",
|
||||
"secondary-fixed": "#d0e8d9",
|
||||
"surface-container-high": "#e6e9e8",
|
||||
"primary-fixed-dim": "#84d4d3",
|
||||
"on-error-container": "#93000a",
|
||||
"secondary-container": "#cde6d6",
|
||||
"surface-container-highest": "#e1e3e2",
|
||||
"inverse-surface": "#2e3131",
|
||||
"surface-container-low": "#f2f4f3",
|
||||
"on-tertiary-fixed": "#001e2b",
|
||||
"tertiary-fixed": "#c2e8fd",
|
||||
"on-primary-fixed-variant": "#004f4f",
|
||||
"on-secondary-container": "#51685b",
|
||||
"on-primary": "#ffffff",
|
||||
"tertiary-fixed-dim": "#a7cce1",
|
||||
"inverse-primary": "#84d4d3",
|
||||
"tertiary": "#264b5c",
|
||||
"on-surface": "#191c1c",
|
||||
"on-tertiary-container": "#b9def3",
|
||||
"on-surface-variant": "#3e4948",
|
||||
"surface-dim": "#d8dada",
|
||||
"outline-variant": "#bec9c8",
|
||||
"inverse-on-surface": "#eff1f0",
|
||||
"on-error": "#ffffff",
|
||||
"on-tertiary-fixed-variant": "#264b5c",
|
||||
"on-tertiary": "#ffffff",
|
||||
"tertiary-container": "#3f6375",
|
||||
"on-secondary-fixed-variant": "#364b40",
|
||||
"secondary-fixed-dim": "#b4ccbd",
|
||||
"surface-variant": "#e1e3e2",
|
||||
"outline": "#6e7979",
|
||||
"surface-container": "#eceeed",
|
||||
"surface-bright": "#f8faf9",
|
||||
"error": "#ba1a1a",
|
||||
"surface-tint": "#006a6a",
|
||||
"secondary": "#4d6357",
|
||||
"surface": "#f8faf9",
|
||||
"on-secondary-fixed": "#0a1f16",
|
||||
"error-container": "#ffdad6",
|
||||
"primary": "#005050",
|
||||
"on-primary-container": "#97e7e6",
|
||||
"on-secondary": "#ffffff",
|
||||
"primary-fixed": "#a0f0f0"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.25rem",
|
||||
"lg": "0.5rem",
|
||||
"xl": "0.75rem",
|
||||
"full": "9999px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"headline": ["Manrope"],
|
||||
"body": ["Manrope"],
|
||||
"label": ["Manrope"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { font-family: 'Manrope', sans-serif; min-height: max(884px, 100dvh); }
|
||||
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background text-on-background min-h-screen pb-24">
|
||||
<div class="max-w-md mx-auto relative">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="bg-[#f8faf9] sticky top-0 z-40 px-6 py-4 flex items-center gap-4">
|
||||
<button class="p-2 -ml-2 hover:bg-surface-container rounded-full transition-colors" onclick="window.location.href='index.html'">
|
||||
<span class="material-symbols-outlined text-primary">arrow_back</span>
|
||||
</button>
|
||||
<h1 class="text-xl font-bold text-[#006a6a]">Historial</h1>
|
||||
</header>
|
||||
|
||||
<main class="px-6 mt-4 space-y-2">
|
||||
<!-- Skeleton / estado inicial -->
|
||||
<div id="ui-loading" class="py-16 flex flex-col items-center gap-3 text-on-surface-variant">
|
||||
<span class="material-symbols-outlined text-4xl opacity-40">hourglass_top</span>
|
||||
<p class="text-sm">Cargando movimientos...</p>
|
||||
</div>
|
||||
<div id="ui-empty" class="hidden py-16 flex flex-col items-center gap-3 text-on-surface-variant">
|
||||
<span class="material-symbols-outlined text-5xl opacity-30">receipt_long</span>
|
||||
<p class="text-sm font-medium">Sin movimientos registrados</p>
|
||||
<button onclick="window.location.href='add.html'" class="mt-2 px-5 py-2.5 bg-primary text-white rounded-xl text-sm font-bold">
|
||||
Agregar primero
|
||||
</button>
|
||||
</div>
|
||||
<div id="ui-lista" class="space-y-1 hidden"></div>
|
||||
</main>
|
||||
|
||||
<!-- FAB agregar -->
|
||||
<div class="fixed bottom-6 right-6 z-50 md:right-[calc(50%-176px)]">
|
||||
<button onclick="window.location.href='add.html'"
|
||||
class="w-14 h-14 bg-primary text-white rounded-2xl shadow-lg shadow-primary/30 flex items-center justify-center active:scale-90 transition-transform">
|
||||
<span class="material-symbols-outlined">add</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/historial.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+13
-13
@@ -3,10 +3,9 @@
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script src="js/tailwind.cdn.js"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
@@ -89,6 +88,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background text-on-background min-h-screen pb-32">
|
||||
<div class="max-w-md mx-auto relative">
|
||||
<!-- TopAppBar -->
|
||||
<header class="bg-[#f8faf9] dark:bg-slate-950 docked full-width top-0 sticky z-40">
|
||||
<div class="flex justify-between items-center w-full px-6 py-4">
|
||||
@@ -172,7 +172,7 @@
|
||||
<section class="space-y-4">
|
||||
<div class="flex justify-between items-center px-1">
|
||||
<h4 class="headline-sm text-on-surface-variant font-semibold">Movimientos Recientes</h4>
|
||||
<button class="text-primary text-sm font-bold">Ver todo</button>
|
||||
<button class="text-primary text-sm font-bold" onclick="window.location.href='historial.html'">Ver todo</button>
|
||||
</div>
|
||||
<div id="ui-movimientos" class="space-y-6">
|
||||
<!-- Transaction 1 -->
|
||||
@@ -218,18 +218,18 @@
|
||||
</section>
|
||||
</main>
|
||||
<!-- BottomNavBar -->
|
||||
<nav class="fixed bottom-0 left-0 w-full z-50 flex justify-around items-center px-8 pb-8 pt-4 bg-[#ffffff]/70 dark:bg-slate-900/70 backdrop-blur-xl shadow-[0px_-12px_32px_rgba(0,80,80,0.06)] rounded-t-[2rem]">
|
||||
<nav class="fixed bottom-0 left-0 right-0 max-w-md mx-auto z-50 flex justify-around items-center px-8 pb-8 pt-4 bg-[#ffffff]/70 backdrop-blur-xl shadow-[0px_-12px_32px_rgba(0,80,80,0.06)] rounded-t-[2rem]">
|
||||
<button class="flex items-center justify-center bg-[#006a6a] text-white rounded-2xl p-3 shadow-lg shadow-[#006a6a]/20 active:scale-90 transition-transform duration-300">
|
||||
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">home</span>
|
||||
</button>
|
||||
<button class="flex items-center justify-center text-[#3e4948] dark:text-slate-400 p-3 hover:text-[#006a6a] dark:hover:text-teal-300 active:scale-90 transition-transform duration-300">
|
||||
<span class="material-symbols-outlined">analytics</span>
|
||||
<button class="flex items-center justify-center text-[#3e4948] p-3 hover:text-[#006a6a] active:scale-90 transition-transform duration-300" onclick="window.location.href='historial.html'">
|
||||
<span class="material-symbols-outlined">history</span>
|
||||
</button>
|
||||
<button class="flex items-center justify-center text-[#3e4948] dark:text-slate-400 p-3 hover:text-[#006a6a] dark:hover:text-teal-300 active:scale-90 transition-transform duration-300">
|
||||
<span class="material-symbols-outlined">credit_card</span>
|
||||
</button>
|
||||
<button class="flex items-center justify-center text-[#3e4948] dark:text-slate-400 p-3 hover:text-[#006a6a] dark:hover:text-teal-300 active:scale-90 transition-transform duration-300">
|
||||
<span class="material-symbols-outlined">person</span>
|
||||
<button class="flex items-center justify-center text-[#3e4948] p-3 hover:text-[#006a6a] active:scale-90 transition-transform duration-300" onclick="window.location.href='add.html'">
|
||||
<span class="material-symbols-outlined">add_circle</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/dashboard.js"></script>
|
||||
</body></html>
|
||||
+45
-18
@@ -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') {
|
||||
@@ -30,25 +43,39 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
btnGasto.addEventListener('click', () => { selectedTipo = 'gasto'; updateTipoUI(); });
|
||||
btnIngreso.addEventListener('click', () => { selectedTipo = 'ingreso'; updateTipoUI(); });
|
||||
|
||||
// Numpad logic
|
||||
function applyInput(val) {
|
||||
if (val === 'backspace') {
|
||||
currentMonto = currentMonto.slice(0, -1);
|
||||
if (currentMonto === '') currentMonto = "0";
|
||||
} else if (/^[0-9]$/.test(val)) {
|
||||
if (currentMonto === "0") {
|
||||
currentMonto = val;
|
||||
} else {
|
||||
currentMonto += val;
|
||||
}
|
||||
} else if (val === '.') {
|
||||
if (!currentMonto.includes('.')) currentMonto += '.';
|
||||
}
|
||||
montoEl.textContent = currentMonto;
|
||||
}
|
||||
|
||||
// Numpad táctil
|
||||
numpad.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('button');
|
||||
if (!btn) return;
|
||||
|
||||
const val = btn.textContent.trim() || btn.innerText.trim();
|
||||
|
||||
if (val === 'backspace' || btn.querySelector('[data-icon="backspace"]')) {
|
||||
currentMonto = currentMonto.slice(0, -1);
|
||||
if (currentMonto === '') currentMonto = "0";
|
||||
if (btn.querySelector('[data-icon="backspace"]')) {
|
||||
applyInput('backspace');
|
||||
} else {
|
||||
if (currentMonto === "0" && val !== ".") {
|
||||
currentMonto = val;
|
||||
} else {
|
||||
if (val === "." && currentMonto.includes(".")) return;
|
||||
currentMonto += val;
|
||||
}
|
||||
applyInput(btn.textContent.trim());
|
||||
}
|
||||
montoEl.textContent = currentMonto;
|
||||
});
|
||||
|
||||
// Teclado físico (desktop)
|
||||
document.addEventListener('keydown', (e) => {
|
||||
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();
|
||||
});
|
||||
|
||||
// Guardar Movimiento
|
||||
@@ -62,7 +89,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 +106,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