Compare commits
7
Commits
5a54fdcfde
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b78bd3ecdd | ||
|
|
d2aa3d92c4 | ||
|
|
e98bfa0afb | ||
|
|
7cb6107cb4 | ||
|
|
ead1bf8ffc | ||
|
|
1244235820 | ||
|
|
1db200343d |
@@ -1,2 +1,5 @@
|
|||||||
# Credenciales locales — nunca commitear
|
# Credenciales locales — nunca commitear
|
||||||
pagos/api/config.php
|
pagos/api/config.php
|
||||||
|
|
||||||
|
# Assets generados (se descargan al hacer docker compose build)
|
||||||
|
pagos/js/tailwind.cdn.js
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# rm-PAGOs — Sistema de Gestión de Gastos y Pagos
|
||||||
|
|
||||||
|
## Repositorio Git
|
||||||
|
|
||||||
|
**Remote actual:** Gitea DIIAA — `http://10.0.10.205:3000/rmonla/rm-PAGOs.git`
|
||||||
|
|
||||||
|
> ⚠️ La carpeta local está en `~/Documentos/GitHub/rm-PAGOs/` por convención de nombre de directorio,
|
||||||
|
> pero el repositorio **ya fue migrado a Gitea** y NO apunta a GitHub.
|
||||||
|
> Verificar siempre con `git remote -v` antes de operar.
|
||||||
|
|
||||||
|
## Stack Técnico
|
||||||
|
|
||||||
|
| Capa | Tecnología |
|
||||||
|
| :--- | :--- |
|
||||||
|
| **Frontend** | HTML + CSS Vainilla + JS (diseño Lumina Finance / Quiet Wealth) |
|
||||||
|
| **Backend** | PHP 8.2 |
|
||||||
|
| **Base de Datos** | SQLite3 |
|
||||||
|
| **Despliegue** | LXC `srvv-nginx-rm` (Debian 12, NGINX 1.22.1, HTTPS) |
|
||||||
|
|
||||||
|
## Estructura
|
||||||
|
|
||||||
|
```
|
||||||
|
pagos/ → Aplicación desplegable (index.html, css/, api/, db/)
|
||||||
|
docs/plan/ → Plan de desarrollo (00_PLAN.md)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plan de Desarrollo
|
||||||
|
|
||||||
|
Ver [`docs/plan/00_PLAN.md`](docs/plan/00_PLAN.md) — fases: Frontend estático → Backend PHP/SQLite → Integración → Despliegue.
|
||||||
+10
-2
@@ -1,6 +1,14 @@
|
|||||||
FROM php:8.2-fpm-alpine
|
FROM php:8.2-fpm-alpine
|
||||||
|
|
||||||
RUN apk add --no-cache sqlite sqlite-dev \
|
RUN apk add --no-cache sqlite sqlite-dev curl \
|
||||||
&& docker-php-ext-install pdo pdo_sqlite
|
&& 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
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
CMD ["php-fpm"]
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
volumes:
|
volumes:
|
||||||
- ./pagos:/var/www/html
|
- ./pagos:/var/www/html
|
||||||
- db_data:/var/www/html/db
|
|
||||||
networks:
|
networks:
|
||||||
- pagos_net
|
- pagos_net
|
||||||
|
|
||||||
@@ -14,14 +13,10 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./pagos:/var/www/html
|
- ./pagos:/var/www/html
|
||||||
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf
|
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf
|
||||||
- db_data:/var/www/html/db
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- app
|
||||||
networks:
|
networks:
|
||||||
- pagos_net
|
- pagos_net
|
||||||
|
|
||||||
volumes:
|
|
||||||
db_data:
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
pagos_net:
|
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 "$@"
|
||||||
+72
-37
@@ -1,48 +1,83 @@
|
|||||||
# Plan de Desarrollo: Sistema de Gestión de Gastos y Pagos (rm-PAGOs)
|
# Plan de Desarrollo — rm-PAGOs (Quiet Wealth)
|
||||||
|
|
||||||
## Contexto y Arquitectura
|
## Stack
|
||||||
- **Frontend**: Diseños generados por Stitch (HTML, CSS Vainilla, JS). Diseño moderno "Quiet Wealth / Financial Sanctuary".
|
| Capa | Tecnología |
|
||||||
- **Backend**: PHP 8.2 (procesamiento ligero, para máxima compatibilidad con el servidor de destino).
|
|------|-----------|
|
||||||
- **Base de Datos**: SQLite3 (liviana, portátil y soportada nativamente por PHP, sin necesidad de un servidor de BD separado).
|
| Frontend | HTML + Tailwind CSS (CDN local) + Vanilla JS |
|
||||||
- **Despliegue**: Servidor Proxmox LXC `srvv-nginx-rm` (Debian 12, NGINX 1.22.1, HTTPS activo).
|
| Backend | PHP 8.2 (FastCGI) |
|
||||||
|
| Base de datos | SQLite3 (PDO) |
|
||||||
|
| Auth | Sesión PHP nativa |
|
||||||
|
| Servidor local | Docker Compose: NGINX + PHP-FPM |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Fases de Ejecución
|
## Estado actual por pantalla
|
||||||
|
|
||||||
### Fase 1: Estructuración y Maquetación (Frontend Static)
|
### ✅ login.html — Completa
|
||||||
**Objetivo:** Tener todo el diseño web unificado y navegable estáticamente.
|
- Formulario propio con diseño Quiet Wealth (sin popup del browser)
|
||||||
1. Crear la estructura de la aplicación DENTRO de la carpeta `pagos/` (que es la que se usará para el despliegue). `pagos/index.html`, `pagos/css/`, `pagos/api/`, `pagos/db/`.
|
- Validación de credenciales contra `config.php` (gitignoreado)
|
||||||
2. Exportar las pantallas generadas en Stitch (Dashboard, Nuevo Gasto, Historial) y acomodarlas como plantillas base (HTML).
|
- Redirección automática si ya hay sesión activa
|
||||||
3. Configurar el archivo CSS general con las variables del sistema de diseño (Lumina Finance).
|
|
||||||
4. Enlazar la navegación básica con Javascript o anclas entre pantallas.
|
|
||||||
|
|
||||||
### Fase 2: Motor de Base de Datos y Backend (PHP/SQLite)
|
### ✅ index.html — Dashboard
|
||||||
**Objetivo:** Persistir la información y crear la lógica detrás de las pantallas.
|
- Balance total dinámico desde SQLite
|
||||||
1. Diseñar el esquema de base de datos SQLite (tablas básicas como `movimientos` y `categorias`).
|
- Gráfico donut de distribución de gastos por categoría (SVG puro)
|
||||||
2. Crear un archivo clase base en PHP (`db.php`) para conexión y operaciones CRUD.
|
- Últimos 5 movimientos con ícono, descripción y monto
|
||||||
3. Crear los endpoints/controladores PHP (`api/movimientos.php`) que manejen operaciones GET, POST, PUT y DELETE.
|
- Navegación inferior funcional
|
||||||
4. Ajustar los scripts de inicialización de la BD.
|
|
||||||
|
|
||||||
### Fase 3: Integración Frontend ↔ Backend (Dinámica)
|
### ✅ add.html — Nuevo movimiento
|
||||||
**Objetivo:** Darle vida a las pantallas usando Javascript interactuando con PHP.
|
- Selector Gasto / Ingreso
|
||||||
1. **Nuevo Movimiento**: Enlazar el formulario "Agregar" usando Fetch/AJAX (Vanilla JS) para enviarle los datos al backend de forma transparente sin refrescar.
|
- Numpad táctil + soporte teclado físico (desktop)
|
||||||
2. **Dashboard**:
|
- Categorías cargadas dinámicamente desde la API
|
||||||
- Calcular saldo dinámico, sumatorias de gastos del mes, etc., usando SQL.
|
- Campo fecha (default: hoy) y notas opcionales
|
||||||
- Enlazar librería de gráficos (por ejemplo Chart.js) para pintar la dona de categorías leyendo desde la base de datos.
|
- POST a la API con redirect al dashboard al guardar
|
||||||
3. **Historial**: Renderizar la lista de últimos movimientos desde SQLite y añadir sistema de borrado.
|
|
||||||
|
|
||||||
### Fase 4: Despliegue Configurado (Producción)
|
### ✅ historial.html — Historial completo
|
||||||
**Objetivo:** Llevar el sistema al servidor `srvv-nginx-rm` para uso real.
|
- Lista de todos los movimientos agrupados por mes
|
||||||
1. **Seguridad Básica:** Añadir una pantalla de Login o protección sencilla (ya que son datos financieros personales) utilizando las sesiones nativas de PHP o Basic Auth en NGINX.
|
- Botón eliminar con modal de confirmación
|
||||||
2. **Configuración del Servidor:**
|
- Toast de confirmación tras eliminar
|
||||||
- Crear un subdirectorio o alias en NGINX (ejemplo `rmonla.duckdns.org/pagos`).
|
|
||||||
- Clonar o actualizar por deploy key en `/var/www/pagos`.
|
### ✅ API (movimientos.php)
|
||||||
- Asegurar permisos de escritura a la carpeta `db/` para que PHP (usuario `www-data`) pueda modifciar el archivo `.sqlite`.
|
- GET dashboard: balance + últimos 5 + donut + categorías
|
||||||
3. Pruebas End-to-End directas desde el teléfono.
|
- GET ?view=historial: todos los movimientos
|
||||||
|
- POST: nuevo movimiento
|
||||||
|
- DELETE: eliminar por ID
|
||||||
|
|
||||||
|
### ✅ Infraestructura Docker
|
||||||
|
- NGINX + PHP-FPM en contenedores separados
|
||||||
|
- Tailwind CDN descargado en build y servido localmente
|
||||||
|
- Auth por sesión PHP (sin Basic Auth del browser)
|
||||||
|
- Script `_app --status / --start / --stop`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Roadmap de Próximos Pasos (De Inmediato)
|
## Pendientes / En progreso
|
||||||
1. Iniciar con la **Fase 1**, configurando estructura base (`index.html`, `style.css`) volcando el HTML de Stitch en un index.
|
|
||||||
2. Crear un script rápido de inicialización de SQLite.
|
### ✅ UI — Responsive desktop
|
||||||
|
- [x] Sidebar de navegación fijo en pantallas ≥768px (con logout)
|
||||||
|
- [x] Dashboard en 2 columnas en desktop (balance+movimientos | donut)
|
||||||
|
- [x] add.html: formulario + numpad en 2 columnas en desktop
|
||||||
|
- [x] historial.html: ancho extendido en desktop, header con botón Nuevo
|
||||||
|
- [x] Soporte teclado físico en numpad (0-9, punto, Backspace, Enter)
|
||||||
|
|
||||||
|
### 🔶 UI — Polish
|
||||||
|
- [ ] Fecha en formato legible en movimientos recientes del dashboard
|
||||||
|
- [ ] Indicador de mes actual dinámico (hoy hardcodeado como "Octubre 2023")
|
||||||
|
- [ ] El "+12.4% este mes" del balance card → calculado real
|
||||||
|
- [ ] Estado vacío más visual en historial (primer uso)
|
||||||
|
|
||||||
|
### 🔶 Funcionalidades faltantes
|
||||||
|
- [ ] Editar movimiento existente
|
||||||
|
- [ ] Filtro por categoría / mes en historial
|
||||||
|
- [ ] Resumen mensual (ingresos vs gastos del mes)
|
||||||
|
|
||||||
|
### ⏳ Fase futura — CLI
|
||||||
|
- Interfaz de línea de comandos que replique las operaciones de la GUI
|
||||||
|
- Comandos: `pagos add`, `pagos list`, `pagos delete <id>`, `pagos balance`
|
||||||
|
- Mismo SQLite como backend → datos compartidos con la web
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notas de arquitectura
|
||||||
|
- Credenciales en `pagos/api/config.php` (gitignoreado, ver `config.example.php`)
|
||||||
|
- SQLite en `pagos/db/finanzas.sqlite` (gitignoreado)
|
||||||
|
- Tailwind CDN se descarga en `docker build` y se copia al volumen en el entrypoint
|
||||||
|
|||||||
+176
-175
@@ -1,186 +1,187 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
<html class="light" lang="es"><head>
|
<head>
|
||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
<title>Nuevo Movimiento - Sanctuary</title>
|
<title>Nuevo Movimiento — Quiet Wealth</title>
|
||||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
<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=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"/>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
<script>
|
||||||
<script id="tailwind-config">
|
tailwind.config = {
|
||||||
tailwind.config = {
|
|
||||||
darkMode: "class",
|
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
"colors": {
|
colors: {
|
||||||
"surface-container-high": "#e6e9e8",
|
"background":"#f8faf9","primary-container":"#006a6a","surface-container-lowest":"#ffffff",
|
||||||
"on-secondary": "#ffffff",
|
"on-background":"#191c1c","surface-container-high":"#e6e9e8","secondary-container":"#cde6d6",
|
||||||
"on-primary-fixed-variant": "#004f4f",
|
"surface-container-highest":"#e1e3e2","surface-container-low":"#f2f4f3","on-primary":"#ffffff",
|
||||||
"on-primary-container": "#97e7e6",
|
"tertiary":"#264b5c","on-surface":"#191c1c","on-surface-variant":"#3e4948",
|
||||||
"on-background": "#191c1c",
|
"outline-variant":"#bec9c8","on-error":"#ffffff","secondary-fixed-dim":"#b4ccbd",
|
||||||
"secondary-fixed-dim": "#b4ccbd",
|
"surface-variant":"#e1e3e2","outline":"#6e7979","surface-container":"#eceeed",
|
||||||
"inverse-surface": "#2e3131",
|
"error":"#ba1a1a","secondary":"#4d6357","surface":"#f8faf9","error-container":"#ffdad6",
|
||||||
"surface-container": "#eceeed",
|
"primary":"#005050","on-secondary":"#ffffff","primary-fixed":"#a0f0f0"
|
||||||
"surface-tint": "#006a6a",
|
|
||||||
"outline-variant": "#bec9c8",
|
|
||||||
"on-surface": "#191c1c",
|
|
||||||
"on-primary": "#ffffff",
|
|
||||||
"tertiary": "#264b5c",
|
|
||||||
"primary-fixed-dim": "#84d4d3",
|
|
||||||
"error-container": "#ffdad6",
|
|
||||||
"surface-bright": "#f8faf9",
|
|
||||||
"tertiary-fixed-dim": "#a7cce1",
|
|
||||||
"on-error": "#ffffff",
|
|
||||||
"outline": "#6e7979",
|
|
||||||
"background": "#f8faf9",
|
|
||||||
"secondary-fixed": "#d0e8d9",
|
|
||||||
"primary-fixed": "#a0f0f0",
|
|
||||||
"on-secondary-container": "#51685b",
|
|
||||||
"primary": "#005050",
|
|
||||||
"on-primary-fixed": "#002020",
|
|
||||||
"surface-container-highest": "#e1e3e2",
|
|
||||||
"surface": "#f8faf9",
|
|
||||||
"on-tertiary-container": "#b9def3",
|
|
||||||
"inverse-primary": "#84d4d3",
|
|
||||||
"on-tertiary-fixed": "#001e2b",
|
|
||||||
"on-surface-variant": "#3e4948",
|
|
||||||
"error": "#ba1a1a",
|
|
||||||
"surface-variant": "#e1e3e2",
|
|
||||||
"on-error-container": "#93000a",
|
|
||||||
"inverse-on-surface": "#eff1f0",
|
|
||||||
"tertiary-fixed": "#c2e8fd",
|
|
||||||
"tertiary-container": "#3f6375",
|
|
||||||
"secondary": "#4d6357",
|
|
||||||
"on-secondary-fixed-variant": "#364b40",
|
|
||||||
"on-secondary-fixed": "#0a1f16",
|
|
||||||
"secondary-container": "#cde6d6",
|
|
||||||
"on-tertiary": "#ffffff",
|
|
||||||
"surface-dim": "#d8dada",
|
|
||||||
"primary-container": "#006a6a",
|
|
||||||
"on-tertiary-fixed-variant": "#264b5c",
|
|
||||||
"surface-container-lowest": "#ffffff",
|
|
||||||
"surface-container-low": "#f2f4f3"
|
|
||||||
},
|
},
|
||||||
"borderRadius": {
|
fontFamily: { headline:["Manrope"], body:["Manrope"] }
|
||||||
"DEFAULT": "0.25rem",
|
|
||||||
"lg": "0.5rem",
|
|
||||||
"xl": "0.75rem",
|
|
||||||
"full": "9999px"
|
|
||||||
},
|
|
||||||
"fontFamily": {
|
|
||||||
"headline": ["Manrope"],
|
|
||||||
"body": ["Manrope"],
|
|
||||||
"label": ["Manrope"]
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
}
|
||||||
|
</script>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: 'Manrope', sans-serif; }
|
body { font-family:'Manrope',sans-serif; }
|
||||||
.material-symbols-outlined {
|
.material-symbols-outlined { font-variation-settings:'FILL' 0,'wght' 400,'GRAD' 0,'opsz' 24; }
|
||||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
.numpad-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:0.75rem; }
|
||||||
}
|
</style>
|
||||||
.numpad-grid {
|
</head>
|
||||||
display: grid;
|
<body class="bg-background text-on-background min-h-screen">
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 1rem;
|
<!-- ═══════════ SIDEBAR (desktop) ═══════════ -->
|
||||||
}
|
<aside class="hidden md:flex flex-col fixed left-0 top-0 h-screen w-56 bg-surface-container-lowest border-r border-outline-variant z-40 py-7 px-4 gap-1">
|
||||||
.prosperity-glow {
|
<div class="flex items-center gap-2 px-2 mb-7">
|
||||||
background: radial-gradient(circle at center, rgba(160, 240, 240, 0.15) 0%, transparent 70%);
|
<span class="material-symbols-outlined text-[#006a6a] text-2xl" style="font-variation-settings:'FILL' 1;">account_balance_wallet</span>
|
||||||
}
|
<span class="font-extrabold text-[#006a6a] tracking-tight text-lg">Quiet Wealth</span>
|
||||||
</style>
|
</div>
|
||||||
<style>
|
<a href="index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors">
|
||||||
body {
|
<span class="material-symbols-outlined text-[20px]">home</span>Dashboard
|
||||||
min-height: max(884px, 100dvh);
|
</a>
|
||||||
}
|
<a href="historial.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors">
|
||||||
</style>
|
<span class="material-symbols-outlined text-[20px]">history</span>Historial
|
||||||
</head>
|
</a>
|
||||||
<body class="bg-background text-on-background min-h-screen flex flex-col">
|
<a href="add.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl bg-[#005050]/10 text-primary font-bold text-sm">
|
||||||
<!-- Top AppBar - Transactional Context (Shell Suppressed per mandate) -->
|
<span class="material-symbols-outlined text-[20px]" style="font-variation-settings:'FILL' 1;">add_circle</span>Nuevo movimiento
|
||||||
<header class="bg-[#f8faf9] dark:bg-[#191c1c] w-full top-0 px-6 py-4 flex justify-between items-center bg-transparent">
|
</a>
|
||||||
<div class="flex items-center gap-4">
|
<a href="categorias.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors">
|
||||||
<button class="hover:opacity-80 transition-opacity p-2 -ml-2" onclick="window.location.href='index.html'">
|
<span class="material-symbols-outlined text-[20px]">category</span>Categorías
|
||||||
<span class="material-symbols-outlined text-primary dark:text-[#a0f0f0]" data-icon="chevron_left">chevron_left</span>
|
</a>
|
||||||
</button>
|
<div class="mt-auto">
|
||||||
<h1 class="font-['Manrope'] headline-sm text-[#191c1c] dark:text-[#f8faf9] font-bold">Nuevo Movimiento</h1>
|
<button onclick="fetch('api/login.php?action=logout').then(()=>location.href='login.html')"
|
||||||
|
class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors w-full">
|
||||||
|
<span class="material-symbols-outlined text-[20px]">logout</span>Salir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ═══════════ CONTENIDO ═══════════ -->
|
||||||
|
<div class="md:ml-56 min-h-screen flex flex-col">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="bg-background sticky top-0 z-30 border-b border-outline-variant/30 px-6 md:px-8 py-4 flex items-center gap-4">
|
||||||
|
<button onclick="window.location.href='index.html'" class="p-2 -ml-2 hover:bg-surface-container rounded-full transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-primary">arrow_back</span>
|
||||||
|
</button>
|
||||||
|
<h1 class="text-xl font-bold text-on-surface">Nuevo Movimiento</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Form: 2 columnas en desktop -->
|
||||||
|
<main class="flex-1 px-6 md:px-8 py-6 pb-28 md:pb-10">
|
||||||
|
<div class="max-w-4xl mx-auto md:grid md:grid-cols-2 md:gap-10 md:items-start space-y-6 md:space-y-0">
|
||||||
|
|
||||||
|
<!-- COLUMNA IZQUIERDA: campos del formulario -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
|
||||||
|
<!-- Tipo selector -->
|
||||||
|
<div class="p-1.5 bg-surface-container-low rounded-xl flex items-center" id="ui-tipo-selector">
|
||||||
|
<button id="btn-gasto" data-tipo="gasto"
|
||||||
|
class="flex-1 py-2.5 text-sm font-bold bg-surface-container-lowest text-primary rounded-lg shadow-sm transition-all">
|
||||||
|
Gasto
|
||||||
|
</button>
|
||||||
|
<button id="btn-ingreso" data-tipo="ingreso"
|
||||||
|
class="flex-1 py-2.5 text-sm font-semibold text-on-surface-variant hover:opacity-80 transition-opacity">
|
||||||
|
Ingreso
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Monto (visible en mobile aquí; en desktop muestra en col derecha) -->
|
||||||
|
<div class="md:hidden relative text-center py-8 bg-surface-container-low rounded-[2rem]">
|
||||||
|
<label class="block text-xs uppercase tracking-[0.15em] font-bold text-on-surface-variant mb-2">Monto</label>
|
||||||
|
<div class="flex items-baseline justify-center gap-1">
|
||||||
|
<span class="text-3xl font-light text-on-surface-variant">$</span>
|
||||||
|
<span id="ui-monto-mobile" class="text-6xl font-extrabold tracking-tight text-primary">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Categoría -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-[11px] uppercase tracking-widest font-bold text-on-surface-variant">Categoría</label>
|
||||||
|
<div class="relative">
|
||||||
|
<select id="ui-categoria" class="appearance-none bg-surface-container-lowest px-4 py-3.5 pr-10 rounded-xl w-full font-semibold text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm">
|
||||||
|
<option value="">Cargando…</option>
|
||||||
|
</select>
|
||||||
|
<span class="material-symbols-outlined absolute right-3 top-1/2 -translate-y-1/2 text-outline pointer-events-none text-[18px]">expand_more</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fecha y Notas -->
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-[11px] uppercase tracking-widest font-bold text-on-surface-variant">Fecha</label>
|
||||||
|
<div class="bg-surface-container-lowest px-4 py-3 rounded-xl flex items-center gap-2">
|
||||||
|
<span class="material-symbols-outlined text-primary text-[18px]">calendar_today</span>
|
||||||
|
<input type="date" id="ui-fecha" class="bg-transparent text-sm font-semibold w-full focus:outline-none"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-[11px] uppercase tracking-widest font-bold text-on-surface-variant">Notas</label>
|
||||||
|
<div class="bg-surface-container-lowest px-4 py-3 rounded-xl flex items-center gap-2">
|
||||||
|
<span class="material-symbols-outlined text-outline text-[18px]">edit_note</span>
|
||||||
|
<input type="text" id="ui-notas" placeholder="Opcional…" class="bg-transparent text-sm font-medium w-full focus:outline-none placeholder-outline"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Guardar (desktop: dentro de la columna izquierda) -->
|
||||||
|
<button id="btn-save"
|
||||||
|
class="hidden md:flex w-full h-14 bg-gradient-to-br from-primary to-primary-container text-white rounded-xl font-bold text-base shadow-lg shadow-primary/20 active:scale-[0.98] transition-transform items-center justify-center gap-2">
|
||||||
|
<span>Guardar Movimiento</span>
|
||||||
|
<span class="material-symbols-outlined text-[20px]" style="font-variation-settings:'FILL' 1;">check_circle</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- COLUMNA DERECHA: monto + numpad -->
|
||||||
|
<div class="space-y-5">
|
||||||
|
|
||||||
|
<!-- Monto display (desktop) -->
|
||||||
|
<div class="hidden md:block text-center py-8 bg-surface-container-low rounded-[2rem]">
|
||||||
|
<label class="block text-xs uppercase tracking-[0.15em] font-bold text-on-surface-variant mb-2">Monto</label>
|
||||||
|
<div class="flex items-baseline justify-center gap-1">
|
||||||
|
<span class="text-3xl font-light text-on-surface-variant">$</span>
|
||||||
|
<span id="ui-monto" class="text-6xl font-extrabold tracking-tight text-primary">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Numpad -->
|
||||||
|
<div id="ui-numpad" class="numpad-grid bg-surface-container-lowest rounded-[2rem] p-4">
|
||||||
|
<button class="h-14 rounded-xl flex items-center justify-center text-2xl font-semibold text-on-surface hover:bg-surface-container-high transition-colors">1</button>
|
||||||
|
<button class="h-14 rounded-xl flex items-center justify-center text-2xl font-semibold text-on-surface hover:bg-surface-container-high transition-colors">2</button>
|
||||||
|
<button class="h-14 rounded-xl flex items-center justify-center text-2xl font-semibold text-on-surface hover:bg-surface-container-high transition-colors">3</button>
|
||||||
|
<button class="h-14 rounded-xl flex items-center justify-center text-2xl font-semibold text-on-surface hover:bg-surface-container-high transition-colors">4</button>
|
||||||
|
<button class="h-14 rounded-xl flex items-center justify-center text-2xl font-semibold text-on-surface hover:bg-surface-container-high transition-colors">5</button>
|
||||||
|
<button class="h-14 rounded-xl flex items-center justify-center text-2xl font-semibold text-on-surface hover:bg-surface-container-high transition-colors">6</button>
|
||||||
|
<button class="h-14 rounded-xl flex items-center justify-center text-2xl font-semibold text-on-surface hover:bg-surface-container-high transition-colors">7</button>
|
||||||
|
<button class="h-14 rounded-xl flex items-center justify-center text-2xl font-semibold text-on-surface hover:bg-surface-container-high transition-colors">8</button>
|
||||||
|
<button class="h-14 rounded-xl flex items-center justify-center text-2xl font-semibold text-on-surface hover:bg-surface-container-high transition-colors">9</button>
|
||||||
|
<button class="h-14 rounded-xl flex items-center justify-center text-2xl font-semibold text-on-surface hover:bg-surface-container-high transition-colors">.</button>
|
||||||
|
<button class="h-14 rounded-xl flex items-center justify-center text-2xl font-semibold text-on-surface hover:bg-surface-container-high transition-colors">0</button>
|
||||||
|
<button class="h-14 rounded-xl flex items-center justify-center text-on-surface hover:bg-surface-container-high transition-colors">
|
||||||
|
<span class="material-symbols-outlined" data-icon="backspace">backspace</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Guardar (mobile: fixed bottom) -->
|
||||||
|
<div class="md:hidden fixed bottom-0 inset-x-0 p-4 bg-gradient-to-t from-background via-background/95 to-transparent">
|
||||||
|
<button id="btn-save-mobile"
|
||||||
|
class="w-full h-14 bg-gradient-to-br from-primary to-primary-container text-white rounded-xl font-bold text-base shadow-lg shadow-primary/20 active:scale-[0.98] transition-transform flex items-center justify-center gap-2">
|
||||||
|
<span>Guardar Movimiento</span>
|
||||||
|
<span class="material-symbols-outlined text-[20px]" style="font-variation-settings:'FILL' 1;">check_circle</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="w-10"></div> <!-- Spacer for center-ish balance -->
|
|
||||||
</header>
|
<script src="js/ui.js"></script>
|
||||||
<main class="flex-1 px-6 pb-32 max-w-md mx-auto w-full overflow-y-auto">
|
|
||||||
<!-- Type Selector (Segmented Control) -->
|
|
||||||
<div class="mt-4 p-1.5 bg-surface-container-low rounded-xl flex items-center mb-8" id="ui-tipo-selector">
|
|
||||||
<button id="btn-gasto" data-tipo="gasto" class="flex-1 py-2.5 text-sm font-bold bg-surface-container-lowest text-primary rounded-lg shadow-sm">Gasto</button>
|
|
||||||
<button id="btn-ingreso" data-tipo="ingreso" class="flex-1 py-2.5 text-sm font-semibold text-on-surface-variant hover:opacity-80 transition-opacity">Ingreso</button>
|
|
||||||
</div>
|
|
||||||
<!-- Hero Amount Input -->
|
|
||||||
<div class="relative text-center mb-10 prosperity-glow py-8 rounded-[2rem]">
|
|
||||||
<label class="block text-xs uppercase tracking-[0.15em] font-bold text-on-surface-variant mb-2">Monto del Movimiento</label>
|
|
||||||
<div class="flex items-baseline justify-center gap-1">
|
|
||||||
<span class="text-3xl font-light text-on-surface-variant">$</span>
|
|
||||||
<span id="ui-monto" class="text-6xl font-extrabold tracking-tight text-primary">0</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Modern Form Fields Grid -->
|
|
||||||
<div class="space-y-6">
|
|
||||||
<!-- Category Dropdown -->
|
|
||||||
<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>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<!-- Date & Notes Grid -->
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<label class="text-[11px] uppercase tracking-widest font-bold text-on-surface-variant px-1">Fecha</label>
|
|
||||||
<div class="bg-surface-container-highest/40 p-4 rounded-xl flex items-center gap-3">
|
|
||||||
<span class="material-symbols-outlined text-primary text-sm" data-icon="calendar_today">calendar_today</span>
|
|
||||||
<input type="date" id="ui-fecha" class="bg-transparent text-sm font-bold w-full focus:outline-none"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<label class="text-[11px] uppercase tracking-widest font-bold text-on-surface-variant px-1">Notas</label>
|
|
||||||
<div class="bg-surface-container-highest/40 p-4 rounded-xl flex items-center gap-3">
|
|
||||||
<span class="material-symbols-outlined text-outline text-sm" data-icon="edit_note">edit_note</span>
|
|
||||||
<input type="text" id="ui-notas" placeholder="Opcional..." class="bg-transparent text-sm font-medium w-full focus:outline-none placeholder-outline"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Integrated Custom Numpad -->
|
|
||||||
<div id="ui-numpad" class="mt-12 numpad-grid">
|
|
||||||
<button class="h-16 rounded-xl flex items-center justify-center text-2xl font-semibold text-on-surface hover:bg-surface-container-high transition-colors">1</button>
|
|
||||||
<button class="h-16 rounded-xl flex items-center justify-center text-2xl font-semibold text-on-surface hover:bg-surface-container-high transition-colors">2</button>
|
|
||||||
<button class="h-16 rounded-xl flex items-center justify-center text-2xl font-semibold text-on-surface hover:bg-surface-container-high transition-colors">3</button>
|
|
||||||
<button class="h-16 rounded-xl flex items-center justify-center text-2xl font-semibold text-on-surface hover:bg-surface-container-high transition-colors">4</button>
|
|
||||||
<button class="h-16 rounded-xl flex items-center justify-center text-2xl font-semibold text-on-surface hover:bg-surface-container-high transition-colors">5</button>
|
|
||||||
<button class="h-16 rounded-xl flex items-center justify-center text-2xl font-semibold text-on-surface hover:bg-surface-container-high transition-colors">6</button>
|
|
||||||
<button class="h-16 rounded-xl flex items-center justify-center text-2xl font-semibold text-on-surface hover:bg-surface-container-high transition-colors">7</button>
|
|
||||||
<button class="h-16 rounded-xl flex items-center justify-center text-2xl font-semibold text-on-surface hover:bg-surface-container-high transition-colors">8</button>
|
|
||||||
<button class="h-16 rounded-xl flex items-center justify-center text-2xl font-semibold text-on-surface hover:bg-surface-container-high transition-colors">9</button>
|
|
||||||
<button class="h-16 rounded-xl flex items-center justify-center text-2xl font-semibold text-on-surface hover:bg-surface-container-high transition-colors">.</button>
|
|
||||||
<button class="h-16 rounded-xl flex items-center justify-center text-2xl font-semibold text-on-surface hover:bg-surface-container-high transition-colors">0</button>
|
|
||||||
<button class="h-16 rounded-xl flex items-center justify-center text-on-surface hover:bg-surface-container-high transition-colors">
|
|
||||||
<span class="material-symbols-outlined" data-icon="backspace">backspace</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<!-- Bottom Action Area -->
|
|
||||||
<div class="fixed bottom-0 left-0 w-full p-6 bg-gradient-to-t from-background via-background/95 to-transparent">
|
|
||||||
<button id="btn-save" class="w-full h-16 bg-gradient-to-br from-[#005050] to-[#006a6a] text-white rounded-xl font-bold text-lg shadow-[0px_12px_32px_rgba(0,80,80,0.2)] active:scale-[0.98] transition-transform flex items-center justify-center gap-3">
|
|
||||||
<span>Guardar Movimiento</span>
|
|
||||||
<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"/>
|
|
||||||
<script src="js/add.js"></script>
|
<script src="js/add.js"></script>
|
||||||
</body></html>
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
<?php
|
||||||
|
// Auth y categorías server-side — elimina dependencia del fetch asíncrono
|
||||||
|
ini_set('session.cookie_httponly', '1');
|
||||||
|
ini_set('session.cookie_samesite', 'Lax');
|
||||||
|
session_save_path(__DIR__ . '/db/sessions');
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
if (!isset($_SESSION['qw_auth'])) {
|
||||||
|
header('Location: login.html');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/api/db.php';
|
||||||
|
$pdo = getDB();
|
||||||
|
$cats = $pdo->query("SELECT id, nombre FROM categorias ORDER BY nombre")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
|
<title>Nuevo Movimiento — Quiet Wealth</title>
|
||||||
|
<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>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
"background":"#f8faf9","primary-container":"#006a6a","surface-container-lowest":"#ffffff",
|
||||||
|
"on-background":"#191c1c","surface-container-high":"#e6e9e8","surface-container-highest":"#e1e3e2",
|
||||||
|
"surface-container-low":"#f2f4f3","on-primary":"#ffffff","on-surface":"#191c1c",
|
||||||
|
"on-surface-variant":"#3e4948","outline-variant":"#bec9c8","outline":"#6e7979",
|
||||||
|
"surface-container":"#eceeed","error":"#ba1a1a","secondary":"#4d6357",
|
||||||
|
"surface":"#f8faf9","primary":"#005050","on-secondary":"#ffffff"
|
||||||
|
},
|
||||||
|
fontFamily: { headline:["Manrope"], body:["Manrope"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body { font-family:'Manrope',sans-serif; }
|
||||||
|
.material-symbols-outlined { font-variation-settings:'FILL' 0,'wght' 400,'GRAD' 0,'opsz' 24; }
|
||||||
|
.numpad-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:0.75rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-background text-on-background min-h-screen">
|
||||||
|
|
||||||
|
<!-- SIDEBAR (desktop) -->
|
||||||
|
<aside class="hidden md:flex flex-col fixed left-0 top-0 h-screen w-56 bg-surface-container-lowest border-r border-outline-variant z-40 py-7 px-4 gap-1">
|
||||||
|
<div class="flex items-center gap-2 px-2 mb-7">
|
||||||
|
<span class="material-symbols-outlined text-[#006a6a] text-2xl" style="font-variation-settings:'FILL' 1;">account_balance_wallet</span>
|
||||||
|
<span class="font-extrabold text-[#006a6a] tracking-tight text-lg">Quiet Wealth</span>
|
||||||
|
</div>
|
||||||
|
<a href="index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-[20px]">home</span>Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="historial.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-[20px]">history</span>Historial
|
||||||
|
</a>
|
||||||
|
<a href="add.php" class="flex items-center gap-3 px-3 py-2.5 rounded-xl bg-[#005050]/10 text-primary font-bold text-sm">
|
||||||
|
<span class="material-symbols-outlined text-[20px]" style="font-variation-settings:'FILL' 1;">add_circle</span>Nuevo movimiento
|
||||||
|
</a>
|
||||||
|
<a href="categorias.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-[20px]">category</span>Categorías
|
||||||
|
</a>
|
||||||
|
<div class="mt-auto">
|
||||||
|
<button onclick="fetch('api/login.php?action=logout').then(()=>location.href='login.html')"
|
||||||
|
class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors w-full">
|
||||||
|
<span class="material-symbols-outlined text-[20px]">logout</span>Salir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- CONTENIDO -->
|
||||||
|
<div class="md:ml-56 min-h-screen flex flex-col">
|
||||||
|
|
||||||
|
<header class="bg-background sticky top-0 z-30 border-b border-outline-variant/30 px-6 md:px-8 py-4 flex items-center gap-4">
|
||||||
|
<button onclick="window.location.href='index.html'" class="p-2 -ml-2 hover:bg-surface-container rounded-full transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-primary">arrow_back</span>
|
||||||
|
</button>
|
||||||
|
<h1 class="text-xl font-bold text-on-surface">Nuevo Movimiento</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-1 px-6 md:px-8 py-6 pb-28 md:pb-10">
|
||||||
|
<div class="max-w-4xl mx-auto md:grid md:grid-cols-2 md:gap-10 md:items-start space-y-6 md:space-y-0">
|
||||||
|
|
||||||
|
<!-- COLUMNA IZQUIERDA -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
|
||||||
|
<!-- Tipo -->
|
||||||
|
<div class="p-1.5 bg-surface-container-low rounded-xl flex items-center" id="ui-tipo-selector">
|
||||||
|
<button id="btn-gasto" data-tipo="gasto"
|
||||||
|
class="flex-1 py-2.5 text-sm font-bold bg-surface-container-lowest text-primary rounded-lg shadow-sm transition-all">Gasto</button>
|
||||||
|
<button id="btn-ingreso" data-tipo="ingreso"
|
||||||
|
class="flex-1 py-2.5 text-sm font-semibold text-on-surface-variant hover:opacity-80 transition-opacity">Ingreso</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Monto mobile -->
|
||||||
|
<div class="md:hidden relative text-center py-8 bg-surface-container-low rounded-[2rem]">
|
||||||
|
<label class="block text-xs uppercase tracking-[0.15em] font-bold text-on-surface-variant mb-2">Monto</label>
|
||||||
|
<div class="flex items-baseline justify-center gap-1">
|
||||||
|
<span class="text-3xl font-light text-on-surface-variant">$</span>
|
||||||
|
<span id="ui-monto-mobile" class="text-6xl font-extrabold tracking-tight text-primary">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Categoría: opciones renderizadas por PHP -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-[11px] uppercase tracking-widest font-bold text-on-surface-variant">Categoría</label>
|
||||||
|
<div class="relative">
|
||||||
|
<select id="ui-categoria" class="appearance-none bg-surface-container-lowest px-4 py-3.5 pr-10 rounded-xl w-full font-semibold text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm">
|
||||||
|
<?php foreach ($cats as $c): ?>
|
||||||
|
<option value="<?= $c['id'] ?>"><?= htmlspecialchars($c['nombre']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<span class="material-symbols-outlined absolute right-3 top-1/2 -translate-y-1/2 text-outline pointer-events-none text-[18px]">expand_more</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fecha y Notas -->
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-[11px] uppercase tracking-widest font-bold text-on-surface-variant">Fecha</label>
|
||||||
|
<div class="bg-surface-container-lowest px-4 py-3 rounded-xl flex items-center gap-2">
|
||||||
|
<span class="material-symbols-outlined text-primary text-[18px]">calendar_today</span>
|
||||||
|
<input type="date" id="ui-fecha" class="bg-transparent text-sm font-semibold w-full focus:outline-none"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-[11px] uppercase tracking-widest font-bold text-on-surface-variant">Notas</label>
|
||||||
|
<div class="bg-surface-container-lowest px-4 py-3 rounded-xl flex items-center gap-2">
|
||||||
|
<span class="material-symbols-outlined text-outline text-[18px]">edit_note</span>
|
||||||
|
<input type="text" id="ui-notas" placeholder="Opcional…" class="bg-transparent text-sm font-medium w-full focus:outline-none placeholder-outline"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Guardar desktop -->
|
||||||
|
<button id="btn-save"
|
||||||
|
class="hidden md:flex w-full h-14 bg-gradient-to-br from-primary to-primary-container text-white rounded-xl font-bold text-base shadow-lg shadow-primary/20 active:scale-[0.98] transition-transform items-center justify-center gap-2">
|
||||||
|
<span>Guardar Movimiento</span>
|
||||||
|
<span class="material-symbols-outlined text-[20px]" style="font-variation-settings:'FILL' 1;">check_circle</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- COLUMNA DERECHA: monto + numpad -->
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div class="hidden md:block text-center py-8 bg-surface-container-low rounded-[2rem]">
|
||||||
|
<label class="block text-xs uppercase tracking-[0.15em] font-bold text-on-surface-variant mb-2">Monto</label>
|
||||||
|
<div class="flex items-baseline justify-center gap-1">
|
||||||
|
<span class="text-3xl font-light text-on-surface-variant">$</span>
|
||||||
|
<span id="ui-monto" class="text-6xl font-extrabold tracking-tight text-primary">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ui-numpad" class="numpad-grid bg-surface-container-lowest rounded-[2rem] p-4">
|
||||||
|
<?php foreach (['1','2','3','4','5','6','7','8','9','.','0','⌫'] as $k): ?>
|
||||||
|
<button class="h-14 rounded-xl flex items-center justify-center text-2xl font-semibold text-on-surface hover:bg-surface-container-high transition-colors"
|
||||||
|
<?= $k === '⌫' ? '' : '' ?>>
|
||||||
|
<?php if ($k === '⌫'): ?>
|
||||||
|
<span class="material-symbols-outlined" data-icon="backspace">backspace</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<?= $k ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</button>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Guardar mobile -->
|
||||||
|
<div class="md:hidden fixed bottom-0 inset-x-0 p-4 bg-gradient-to-t from-background via-background/95 to-transparent">
|
||||||
|
<button id="btn-save-mobile"
|
||||||
|
class="w-full h-14 bg-gradient-to-br from-primary to-primary-container text-white rounded-xl font-bold text-base shadow-lg shadow-primary/20 active:scale-[0.98] transition-transform flex items-center justify-center gap-2">
|
||||||
|
<span>Guardar Movimiento</span>
|
||||||
|
<span class="material-symbols-outlined text-[20px]" style="font-variation-settings:'FILL' 1;">check_circle</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="js/ui.js"></script>
|
||||||
|
<script src="js/add.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+9
-7
@@ -1,11 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
// pagos/api/auth.php
|
|
||||||
// Las credenciales viven en config.php (gitignoreado). Ver config.example.php.
|
|
||||||
require_once __DIR__ . '/config.php';
|
require_once __DIR__ . '/config.php';
|
||||||
|
|
||||||
if (!isset($_SERVER['PHP_AUTH_USER']) || $_SERVER['PHP_AUTH_USER'] !== AUTH_USER || $_SERVER['PHP_AUTH_PW'] !== AUTH_PASS) {
|
ini_set('session.cookie_httponly', '1');
|
||||||
header('WWW-Authenticate: Basic realm="Sanctuary Finanzas"');
|
ini_set('session.cookie_samesite', 'Lax');
|
||||||
header('HTTP/1.0 401 Unauthorized');
|
session_save_path(__DIR__ . '/../db/sessions');
|
||||||
die("Acceso denegado.");
|
session_start();
|
||||||
|
|
||||||
|
if (!isset($_SESSION['qw_auth'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
die(json_encode(['status' => 'error', 'message' => 'No autenticado']));
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
require_once 'auth.php';
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require_once 'db.php';
|
||||||
|
|
||||||
|
$pdo = getDB();
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$stmt = $pdo->query("
|
||||||
|
SELECT c.*, COUNT(m.id) as total_movimientos
|
||||||
|
FROM categorias c
|
||||||
|
LEFT JOIN movimientos m ON m.id_categoria = c.id
|
||||||
|
GROUP BY c.id ORDER BY c.nombre
|
||||||
|
");
|
||||||
|
echo json_encode(['status' => 'success', 'categorias' => $stmt->fetchAll(PDO::FETCH_ASSOC)]);
|
||||||
|
|
||||||
|
} elseif ($method === 'POST') {
|
||||||
|
$d = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (empty($d['nombre']) || empty($d['icono'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
die(json_encode(['status' => 'error', 'message' => 'Nombre e ícono son obligatorios']));
|
||||||
|
}
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO categorias (nombre, icono, color_hex) VALUES (?, ?, ?)");
|
||||||
|
$stmt->execute([trim($d['nombre']), trim($d['icono']), $d['color_hex'] ?? '#005050']);
|
||||||
|
echo json_encode(['status' => 'success', 'id' => $pdo->lastInsertId()]);
|
||||||
|
|
||||||
|
} elseif ($method === 'PUT') {
|
||||||
|
$d = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (empty($d['id']) || empty($d['nombre']) || empty($d['icono'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
die(json_encode(['status' => 'error', 'message' => 'id, nombre e ícono son obligatorios']));
|
||||||
|
}
|
||||||
|
$stmt = $pdo->prepare("UPDATE categorias SET nombre=?, icono=?, color_hex=? WHERE id=?");
|
||||||
|
$stmt->execute([trim($d['nombre']), trim($d['icono']), $d['color_hex'] ?? '#005050', (int)$d['id']]);
|
||||||
|
echo json_encode(['status' => 'success']);
|
||||||
|
|
||||||
|
} elseif ($method === 'DELETE') {
|
||||||
|
$d = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (empty($d['id'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
die(json_encode(['status' => 'error', 'message' => 'Falta el ID']));
|
||||||
|
}
|
||||||
|
$count = $pdo->prepare("SELECT COUNT(*) FROM movimientos WHERE id_categoria = ?");
|
||||||
|
$count->execute([(int)$d['id']]);
|
||||||
|
if ($count->fetchColumn() > 0) {
|
||||||
|
http_response_code(409);
|
||||||
|
die(json_encode(['status' => 'error', 'message' => 'No se puede eliminar: tiene movimientos asociados']));
|
||||||
|
}
|
||||||
|
$pdo->prepare("DELETE FROM categorias WHERE id = ?")->execute([(int)$d['id']]);
|
||||||
|
echo json_encode(['status' => 'success']);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Método no permitido']);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
ini_set('session.cookie_httponly', '1');
|
||||||
|
ini_set('session.cookie_samesite', 'Lax');
|
||||||
|
session_save_path(__DIR__ . '/../db/sessions');
|
||||||
|
session_start();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (isset($data['user'], $data['pass'])
|
||||||
|
&& $data['user'] === AUTH_USER
|
||||||
|
&& $data['pass'] === AUTH_PASS) {
|
||||||
|
$_SESSION['qw_auth'] = true;
|
||||||
|
echo json_encode(['status' => 'success']);
|
||||||
|
} else {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Usuario o contraseña incorrectos']);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($_GET['action'] ?? '') === 'logout') {
|
||||||
|
session_destroy();
|
||||||
|
echo json_encode(['status' => 'success']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['status' => 'error', 'message' => 'Método no permitido']);
|
||||||
@@ -8,23 +8,33 @@ $pdo = getDB();
|
|||||||
$method = $_SERVER['REQUEST_METHOD'];
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
|
||||||
if ($method === 'GET') {
|
if ($method === 'GET') {
|
||||||
// Retornar balance, datos para gráfico y últimos movimientos
|
$view = $_GET['view'] ?? 'dashboard';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Balance total
|
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
|
$stmtBalance = $pdo->query("SELECT
|
||||||
SUM(CASE WHEN tipo = 'ingreso' THEN monto ELSE -monto END) as balance
|
SUM(CASE WHEN tipo = 'ingreso' THEN monto ELSE -monto END) as balance
|
||||||
FROM movimientos");
|
FROM movimientos");
|
||||||
$balance = $stmtBalance->fetch(PDO::FETCH_ASSOC)['balance'] ?? 0;
|
$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
|
$stmtMovi = $pdo->query("SELECT m.*, c.nombre as categoria_nombre, c.icono as categoria_icono
|
||||||
FROM movimientos m
|
FROM movimientos m
|
||||||
LEFT JOIN categorias c ON m.id_categoria = c.id
|
LEFT JOIN categorias c ON m.id_categoria = c.id
|
||||||
ORDER BY m.fecha DESC, m.id DESC LIMIT 5");
|
ORDER BY m.fecha DESC, m.id DESC LIMIT 5");
|
||||||
$movimientos = $stmtMovi->fetchAll(PDO::FETCH_ASSOC);
|
$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
|
$stmtGraph = $pdo->query("SELECT c.nombre, c.color_hex, SUM(m.monto) as total
|
||||||
FROM movimientos m
|
FROM movimientos m
|
||||||
JOIN categorias c ON m.id_categoria = c.id
|
JOIN categorias c ON m.id_categoria = c.id
|
||||||
@@ -33,7 +43,6 @@ if ($method === 'GET') {
|
|||||||
ORDER BY total DESC");
|
ORDER BY total DESC");
|
||||||
$grafico = $stmtGraph->fetchAll(PDO::FETCH_ASSOC);
|
$grafico = $stmtGraph->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
// 4. Categorías para el modal de nuevo registro
|
|
||||||
$stmtCat = $pdo->query("SELECT * FROM categorias");
|
$stmtCat = $pdo->query("SELECT * FROM categorias");
|
||||||
$categorias = $stmtCat->fetchAll(PDO::FETCH_ASSOC);
|
$categorias = $stmtCat->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
@@ -49,6 +58,26 @@ if ($method === 'GET') {
|
|||||||
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
|
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') {
|
} elseif ($method === 'POST') {
|
||||||
// Insertar nuevo movimiento
|
// Insertar nuevo movimiento
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
|
<title>Categorías — 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>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
"background":"#f8faf9","primary-container":"#006a6a","surface-container-lowest":"#ffffff",
|
||||||
|
"on-background":"#191c1c","surface-container-high":"#e6e9e8","surface-container-highest":"#e1e3e2",
|
||||||
|
"surface-container-low":"#f2f4f3","on-primary":"#ffffff","on-surface":"#191c1c",
|
||||||
|
"on-surface-variant":"#3e4948","outline-variant":"#bec9c8","on-error":"#ffffff",
|
||||||
|
"surface-variant":"#e1e3e2","outline":"#6e7979","surface-container":"#eceeed",
|
||||||
|
"error":"#ba1a1a","secondary":"#4d6357","surface":"#f8faf9",
|
||||||
|
"primary":"#005050","on-secondary":"#ffffff"
|
||||||
|
},
|
||||||
|
fontFamily: { headline:["Manrope"], body:["Manrope"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body { font-family:'Manrope',sans-serif; }
|
||||||
|
.material-symbols-outlined { font-variation-settings:'FILL' 0,'wght' 400,'GRAD' 0,'opsz' 24; }
|
||||||
|
#drawer { transition: transform 0.3s ease; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-background text-on-background min-h-screen">
|
||||||
|
|
||||||
|
<!-- ═══════════ SIDEBAR ═══════════ -->
|
||||||
|
<aside class="hidden md:flex flex-col fixed left-0 top-0 h-screen w-56 bg-surface-container-lowest border-r border-outline-variant z-40 py-7 px-4 gap-1">
|
||||||
|
<div class="flex items-center gap-2 px-2 mb-7">
|
||||||
|
<span class="material-symbols-outlined text-[#006a6a] text-2xl" style="font-variation-settings:'FILL' 1;">account_balance_wallet</span>
|
||||||
|
<span class="font-extrabold text-[#006a6a] tracking-tight text-lg">Quiet Wealth</span>
|
||||||
|
</div>
|
||||||
|
<a href="index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-[20px]">home</span>Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="historial.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-[20px]">history</span>Historial
|
||||||
|
</a>
|
||||||
|
<a href="add.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-[20px]">add_circle</span>Nuevo movimiento
|
||||||
|
</a>
|
||||||
|
<a href="categorias.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl bg-[#005050]/10 text-primary font-bold text-sm">
|
||||||
|
<span class="material-symbols-outlined text-[20px]" style="font-variation-settings:'FILL' 1;">category</span>Categorías
|
||||||
|
</a>
|
||||||
|
<div class="mt-auto">
|
||||||
|
<button onclick="fetch('api/login.php?action=logout').then(()=>location.href='login.html')"
|
||||||
|
class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors w-full">
|
||||||
|
<span class="material-symbols-outlined text-[20px]">logout</span>Salir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ═══════════ CONTENIDO ═══════════ -->
|
||||||
|
<div class="md:ml-56 min-h-screen flex flex-col">
|
||||||
|
|
||||||
|
<header class="bg-background sticky top-0 z-30 border-b border-outline-variant/30 px-6 md:px-8 py-4 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button onclick="window.location.href='index.html'" class="md:hidden p-2 -ml-2 hover:bg-surface-container rounded-full transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-primary">arrow_back</span>
|
||||||
|
</button>
|
||||||
|
<h1 class="text-xl font-bold text-on-surface">Categorías</h1>
|
||||||
|
</div>
|
||||||
|
<button onclick="abrirDrawer(null)"
|
||||||
|
class="flex items-center gap-1.5 px-4 py-2 bg-primary text-white rounded-xl font-bold text-sm hover:bg-primary-container transition-colors shadow-sm">
|
||||||
|
<span class="material-symbols-outlined text-[18px]">add</span>Nueva
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-1 px-6 md:px-8 py-6 pb-8">
|
||||||
|
<div class="max-w-2xl mx-auto md:mx-0">
|
||||||
|
<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 categorías…</p>
|
||||||
|
</div>
|
||||||
|
<div id="ui-lista" class="hidden bg-surface-container-lowest rounded-[1.5rem] overflow-hidden divide-y divide-surface-container-low"></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════ DRAWER (agregar / editar) ═══════════ -->
|
||||||
|
<div id="drawer-overlay" class="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 hidden" onclick="cerrarDrawer()"></div>
|
||||||
|
|
||||||
|
<div id="drawer" class="fixed bottom-0 inset-x-0 md:inset-x-auto md:right-0 md:top-0 md:w-96 bg-surface-container-lowest z-50 rounded-t-[2rem] md:rounded-none md:rounded-l-[2rem] shadow-2xl translate-y-full md:translate-y-0 md:translate-x-full flex flex-col">
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between px-6 py-5 border-b border-outline-variant/30">
|
||||||
|
<h2 id="drawer-title" class="text-lg font-bold text-on-surface">Nueva categoría</h2>
|
||||||
|
<button onclick="cerrarDrawer()" class="p-2 hover:bg-surface-container rounded-full transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-on-surface-variant">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto px-6 py-5 space-y-5">
|
||||||
|
|
||||||
|
<!-- Nombre -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-[11px] uppercase tracking-widest font-bold text-on-surface-variant">Nombre</label>
|
||||||
|
<input id="drawer-nombre" type="text" placeholder="Ej: Salud, Gimnasio…"
|
||||||
|
class="w-full bg-surface-container-low px-4 py-3 rounded-xl text-sm font-semibold focus:outline-none focus:ring-2 focus:ring-primary/30"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview -->
|
||||||
|
<div class="flex items-center gap-4 p-4 bg-surface-container-low rounded-xl">
|
||||||
|
<div id="preview-icono" class="w-12 h-12 rounded-xl flex items-center justify-center" style="background:#005050">
|
||||||
|
<span id="preview-icono-sym" class="material-symbols-outlined text-white text-2xl" style="font-variation-settings:'FILL' 1;">category</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p id="preview-nombre" class="font-bold text-on-surface text-sm">Vista previa</p>
|
||||||
|
<p class="text-xs text-on-surface-variant">Así se verá en los movimientos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Íconos -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-[11px] uppercase tracking-widest font-bold text-on-surface-variant">Ícono</label>
|
||||||
|
<div id="icon-grid" class="grid grid-cols-6 gap-2">
|
||||||
|
<!-- renderizado por JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Colores -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-[11px] uppercase tracking-widest font-bold text-on-surface-variant">Color</label>
|
||||||
|
<div id="color-grid" class="flex flex-wrap gap-2">
|
||||||
|
<!-- renderizado por JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 py-4 border-t border-outline-variant/30 flex gap-3">
|
||||||
|
<button onclick="cerrarDrawer()" class="flex-1 py-3 bg-surface-container text-on-surface rounded-xl font-semibold text-sm">
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button id="drawer-save" class="flex-1 py-3 bg-primary text-white rounded-xl font-bold text-sm active:scale-95 transition-transform">
|
||||||
|
Guardar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="js/ui.js"></script>
|
||||||
|
<script src="js/categorias.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite-*
|
*.sqlite-*
|
||||||
|
sessions/
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<!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>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
"background":"#f8faf9","primary-container":"#006a6a","surface-container-lowest":"#ffffff",
|
||||||
|
"on-background":"#191c1c","surface-container-high":"#e6e9e8","surface-container-highest":"#e1e3e2",
|
||||||
|
"surface-container-low":"#f2f4f3","on-primary":"#ffffff","on-surface":"#191c1c",
|
||||||
|
"on-surface-variant":"#3e4948","outline-variant":"#bec9c8","on-error":"#ffffff",
|
||||||
|
"surface-variant":"#e1e3e2","outline":"#6e7979","surface-container":"#eceeed",
|
||||||
|
"error":"#ba1a1a","secondary":"#4d6357","surface":"#f8faf9","error-container":"#ffdad6",
|
||||||
|
"primary":"#005050","on-secondary":"#ffffff"
|
||||||
|
},
|
||||||
|
fontFamily: { headline:["Manrope"], body:["Manrope"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body { font-family:'Manrope',sans-serif; }
|
||||||
|
.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">
|
||||||
|
|
||||||
|
<!-- ═══════════ SIDEBAR (desktop) ═══════════ -->
|
||||||
|
<aside class="hidden md:flex flex-col fixed left-0 top-0 h-screen w-56 bg-surface-container-lowest border-r border-outline-variant z-40 py-7 px-4 gap-1">
|
||||||
|
<div class="flex items-center gap-2 px-2 mb-7">
|
||||||
|
<span class="material-symbols-outlined text-[#006a6a] text-2xl" style="font-variation-settings:'FILL' 1;">account_balance_wallet</span>
|
||||||
|
<span class="font-extrabold text-[#006a6a] tracking-tight text-lg">Quiet Wealth</span>
|
||||||
|
</div>
|
||||||
|
<a href="index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-[20px]">home</span>Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="historial.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl bg-[#005050]/10 text-primary font-bold text-sm">
|
||||||
|
<span class="material-symbols-outlined text-[20px]" style="font-variation-settings:'FILL' 1;">history</span>Historial
|
||||||
|
</a>
|
||||||
|
<a href="add.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-[20px]">add_circle</span>Nuevo movimiento
|
||||||
|
</a>
|
||||||
|
<a href="categorias.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-[20px]">category</span>Categorías
|
||||||
|
</a>
|
||||||
|
<div class="mt-auto">
|
||||||
|
<button onclick="fetch('api/login.php?action=logout').then(()=>location.href='login.html')"
|
||||||
|
class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors w-full">
|
||||||
|
<span class="material-symbols-outlined text-[20px]">logout</span>Salir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ═══════════ CONTENIDO ═══════════ -->
|
||||||
|
<div class="md:ml-56 min-h-screen flex flex-col">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="bg-background sticky top-0 z-30 border-b border-outline-variant/30 px-6 md:px-8 py-4 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button onclick="window.location.href='index.html'" class="md:hidden p-2 -ml-2 hover:bg-surface-container rounded-full transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-primary">arrow_back</span>
|
||||||
|
</button>
|
||||||
|
<h1 class="text-xl font-bold text-on-surface">Historial</h1>
|
||||||
|
</div>
|
||||||
|
<a href="add.html" class="flex items-center gap-1.5 px-4 py-2 bg-primary text-white rounded-xl font-bold text-sm hover:bg-primary-container transition-colors shadow-sm">
|
||||||
|
<span class="material-symbols-outlined text-[18px]">add</span>
|
||||||
|
<span class="hidden sm:inline">Nuevo</span>
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Lista -->
|
||||||
|
<main class="flex-1 px-6 md:px-8 py-6 pb-24 md:pb-8">
|
||||||
|
<div class="max-w-3xl mx-auto md:mx-0">
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Vacío -->
|
||||||
|
<div id="ui-empty" class="hidden py-16 flex flex-col items-center gap-4 text-on-surface-variant">
|
||||||
|
<span class="material-symbols-outlined text-5xl opacity-30">receipt_long</span>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="font-semibold text-on-surface">Sin movimientos aún</p>
|
||||||
|
<p class="text-sm mt-1">Registrá tu primer movimiento para empezar</p>
|
||||||
|
</div>
|
||||||
|
<a href="add.html" class="mt-2 px-6 py-2.5 bg-primary text-white rounded-xl text-sm font-bold shadow-sm">
|
||||||
|
Agregar ahora
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lista de movimientos -->
|
||||||
|
<div id="ui-lista" class="hidden space-y-6"></div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="js/ui.js"></script>
|
||||||
|
<script src="js/historial.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+175
-224
@@ -1,235 +1,186 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
<html lang="en"><head>
|
<head>
|
||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
<title>Quiet Wealth — Dashboard</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
|
<script src="js/tailwind.cdn.js"></script>
|
||||||
<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=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 id="tailwind-config">
|
<script>
|
||||||
tailwind.config = {
|
tailwind.config = {
|
||||||
darkMode: "class",
|
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
"colors": {
|
colors: {
|
||||||
"background": "#f8faf9",
|
"background":"#f8faf9","primary-container":"#006a6a","surface-container-lowest":"#ffffff",
|
||||||
"primary-container": "#006a6a",
|
"on-background":"#191c1c","surface-container-high":"#e6e9e8","secondary-container":"#cde6d6",
|
||||||
"surface-container-lowest": "#ffffff",
|
"surface-container-highest":"#e1e3e2","surface-container-low":"#f2f4f3","on-primary":"#ffffff",
|
||||||
"on-primary-fixed": "#002020",
|
"tertiary":"#264b5c","on-surface":"#191c1c","on-surface-variant":"#3e4948","surface-dim":"#d8dada",
|
||||||
"on-background": "#191c1c",
|
"outline-variant":"#bec9c8","on-error":"#ffffff","tertiary-container":"#3f6375",
|
||||||
"secondary-fixed": "#d0e8d9",
|
"secondary-fixed-dim":"#b4ccbd","surface-variant":"#e1e3e2","outline":"#6e7979",
|
||||||
"surface-container-high": "#e6e9e8",
|
"surface-container":"#eceeed","surface-bright":"#f8faf9","error":"#ba1a1a",
|
||||||
"primary-fixed-dim": "#84d4d3",
|
"secondary":"#4d6357","surface":"#f8faf9","error-container":"#ffdad6",
|
||||||
"on-error-container": "#93000a",
|
"primary":"#005050","on-primary-container":"#97e7e6","on-secondary":"#ffffff","primary-fixed":"#a0f0f0"
|
||||||
"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": {
|
fontFamily: { headline:["Manrope"], body:["Manrope"] }
|
||||||
"DEFAULT": "0.25rem",
|
|
||||||
"lg": "0.5rem",
|
|
||||||
"xl": "0.75rem",
|
|
||||||
"full": "9999px"
|
|
||||||
},
|
|
||||||
"fontFamily": {
|
|
||||||
"headline": ["Manrope"],
|
|
||||||
"body": ["Manrope"],
|
|
||||||
"label": ["Manrope"]
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
}
|
||||||
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.material-symbols-outlined {
|
body { font-family:'Manrope',sans-serif; }
|
||||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
.material-symbols-outlined { font-variation-settings:'FILL' 0,'wght' 400,'GRAD' 0,'opsz' 24; }
|
||||||
}
|
</style>
|
||||||
body { font-family: 'Manrope', sans-serif; }
|
</head>
|
||||||
</style>
|
<body class="bg-background text-on-background min-h-screen">
|
||||||
<style>
|
|
||||||
body {
|
<!-- ═══════════ SIDEBAR (desktop) ═══════════ -->
|
||||||
min-height: max(884px, 100dvh);
|
<aside class="hidden md:flex flex-col fixed left-0 top-0 h-screen w-56 bg-surface-container-lowest border-r border-outline-variant z-40 py-7 px-4 gap-1">
|
||||||
}
|
<div class="flex items-center gap-2 px-2 mb-7">
|
||||||
</style>
|
<span class="material-symbols-outlined text-[#006a6a] text-2xl" style="font-variation-settings:'FILL' 1;">account_balance_wallet</span>
|
||||||
</head>
|
<span class="font-extrabold text-[#006a6a] tracking-tight text-lg">Quiet Wealth</span>
|
||||||
<body class="bg-background text-on-background min-h-screen pb-32">
|
</div>
|
||||||
<!-- TopAppBar -->
|
<a href="index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl bg-[#005050]/10 text-primary font-bold text-sm">
|
||||||
<header class="bg-[#f8faf9] dark:bg-slate-950 docked full-width top-0 sticky z-40">
|
<span class="material-symbols-outlined text-[20px]" style="font-variation-settings:'FILL' 1;">home</span>Dashboard
|
||||||
<div class="flex justify-between items-center w-full px-6 py-4">
|
</a>
|
||||||
<div class="flex items-center gap-3">
|
<a href="historial.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors">
|
||||||
<span class="material-symbols-outlined text-[#006a6a] dark:text-teal-400">account_balance_wallet</span>
|
<span class="material-symbols-outlined text-[20px]">history</span>Historial
|
||||||
<h1 class="font-['Manrope'] headline-sm tracking-tight text-xl font-bold text-[#006a6a] dark:text-teal-500">Quiet Wealth</h1>
|
</a>
|
||||||
|
<a href="add.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-[20px]">add_circle</span>Nuevo movimiento
|
||||||
|
</a>
|
||||||
|
<a href="categorias.html" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-[20px]">category</span>Categorías
|
||||||
|
</a>
|
||||||
|
<div class="mt-auto">
|
||||||
|
<button onclick="logout()" class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-on-surface-variant hover:bg-surface-container text-sm font-medium transition-colors w-full">
|
||||||
|
<span class="material-symbols-outlined text-[20px]">logout</span>Salir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ═══════════ CONTENIDO PRINCIPAL ═══════════ -->
|
||||||
|
<div class="md:ml-56 min-h-screen flex flex-col">
|
||||||
|
|
||||||
|
<!-- Header mobile -->
|
||||||
|
<header class="md:hidden bg-background sticky top-0 z-30 border-b border-outline-variant/30">
|
||||||
|
<div class="flex justify-between items-center px-6 py-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="material-symbols-outlined text-[#006a6a]" style="font-variation-settings:'FILL' 1;">account_balance_wallet</span>
|
||||||
|
<h1 class="text-xl font-extrabold text-[#006a6a] tracking-tight">Quiet Wealth</h1>
|
||||||
|
</div>
|
||||||
|
<button onclick="window.location.href='add.html'" class="p-2 hover:bg-surface-container-low rounded-full transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-on-surface-variant">add_circle</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Header desktop -->
|
||||||
|
<div class="hidden md:flex items-center justify-between px-8 pt-8 pb-2">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-3xl font-extrabold tracking-tight text-on-surface">Dashboard</h2>
|
||||||
|
<p id="ui-mes" class="text-sm text-on-surface-variant mt-0.5 capitalize"></p>
|
||||||
|
</div>
|
||||||
|
<a href="add.html" class="flex items-center gap-2 px-5 py-2.5 bg-primary text-white rounded-xl font-bold text-sm hover:bg-primary-container transition-colors shadow-sm">
|
||||||
|
<span class="material-symbols-outlined text-[18px]">add</span>Nuevo movimiento
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contenido principal: grid en desktop -->
|
||||||
|
<main class="px-6 md:px-8 py-6 pb-28 md:pb-8 flex-1">
|
||||||
|
<div class="md:grid md:grid-cols-5 md:gap-7 md:items-start space-y-6 md:space-y-0">
|
||||||
|
|
||||||
|
<!-- COLUMNA IZQUIERDA (3/5) -->
|
||||||
|
<div class="md:col-span-3 space-y-6">
|
||||||
|
|
||||||
|
<!-- Balance card -->
|
||||||
|
<div class="relative overflow-hidden rounded-[2rem] p-8 bg-gradient-to-br from-primary to-primary-container text-on-primary shadow-2xl shadow-primary/20">
|
||||||
|
<div class="absolute -right-10 -top-10 w-40 h-40 bg-white opacity-5 blur-3xl rounded-full"></div>
|
||||||
|
<div class="absolute -left-10 -bottom-10 w-40 h-40 bg-white opacity-5 blur-3xl rounded-full"></div>
|
||||||
|
<div class="relative z-10">
|
||||||
|
<p class="text-white/70 tracking-widest uppercase text-xs mb-2 font-semibold">Balance Total</p>
|
||||||
|
<h3 id="ui-balance" class="text-5xl font-extrabold tracking-tighter mb-6">$0.00</h3>
|
||||||
|
<div id="ui-balance-badge" class="flex items-center gap-2 bg-white/10 w-fit px-4 py-2 rounded-full">
|
||||||
|
<span class="material-symbols-outlined text-sm" style="font-variation-settings:'FILL' 1;">payments</span>
|
||||||
|
<span id="ui-balance-resumen" class="text-sm font-medium">Cargando...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Movimientos recientes -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h4 class="text-on-surface-variant font-semibold text-sm uppercase tracking-wider">Movimientos Recientes</h4>
|
||||||
|
<button onclick="window.location.href='historial.html'" class="text-primary text-sm font-bold hover:underline">Ver todo</button>
|
||||||
|
</div>
|
||||||
|
<div id="ui-movimientos" class="bg-surface-container-lowest rounded-[1.5rem] overflow-hidden divide-y divide-surface-container-low">
|
||||||
|
<!-- cargado por JS -->
|
||||||
|
<div class="flex items-center gap-4 px-5 py-4 animate-pulse">
|
||||||
|
<div class="w-11 h-11 rounded-xl bg-surface-container-high"></div>
|
||||||
|
<div class="flex-1 space-y-2">
|
||||||
|
<div class="h-3 bg-surface-container-high rounded w-2/3"></div>
|
||||||
|
<div class="h-2 bg-surface-container-high rounded w-1/3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- COLUMNA DERECHA (2/5) -->
|
||||||
|
<div class="md:col-span-2 space-y-6">
|
||||||
|
|
||||||
|
<!-- Donut chart -->
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h4 class="text-on-surface-variant font-semibold text-sm uppercase tracking-wider">Distribución de Gastos</h4>
|
||||||
|
<div class="bg-surface-container-low rounded-[2rem] p-7 flex flex-col items-center">
|
||||||
|
<div id="ui-donut-chart" class="relative w-44 h-44 mb-6">
|
||||||
|
<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-xl font-bold text-on-surface-variant">—</span>
|
||||||
|
<span class="text-[9px] uppercase tracking-widest text-on-surface-variant font-bold">Gastos</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="ui-donut-legend" class="w-full space-y-2">
|
||||||
|
<p class="text-xs text-on-surface-variant text-center">Sin datos aún</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button class="p-2 hover:bg-[#f2f4f3] dark:hover:bg-slate-800 transition-colors rounded-full" onclick="window.location.href='add.html'">
|
<!-- ═══════════ BOTTOM NAV (mobile) ═══════════ -->
|
||||||
<span class="material-symbols-outlined text-[#3e4948] dark:text-slate-400">add_circle</span>
|
<nav class="md:hidden fixed bottom-0 inset-x-0 z-50 flex justify-around items-center px-4 pb-6 pt-3 bg-white/80 backdrop-blur-xl shadow-[0_-8px_24px_rgba(0,80,80,0.08)] border-t border-outline-variant/30">
|
||||||
</button>
|
<button class="flex flex-col items-center gap-0.5 text-primary px-4 py-1">
|
||||||
</div>
|
<span class="material-symbols-outlined" style="font-variation-settings:'FILL' 1;">home</span>
|
||||||
</div>
|
<span class="text-[10px] font-bold">Inicio</span>
|
||||||
</header>
|
</button>
|
||||||
<main class="px-6 space-y-8 mt-4">
|
<button onclick="window.location.href='historial.html'" class="flex flex-col items-center gap-0.5 text-on-surface-variant px-4 py-1">
|
||||||
<!-- Screen Title -->
|
<span class="material-symbols-outlined">history</span>
|
||||||
<div class="flex items-end justify-between">
|
<span class="text-[10px] font-medium">Historial</span>
|
||||||
<h2 class="text-3xl font-extrabold tracking-tight text-on-surface">Resumen</h2>
|
</button>
|
||||||
<p class="text-on-surface-variant label-sm tracking-widest uppercase pb-1">Octubre 2023</p>
|
<button onclick="window.location.href='add.html'" class="flex flex-col items-center gap-0.5 px-4 py-1">
|
||||||
</div>
|
<span class="w-12 h-12 -mt-6 rounded-2xl bg-primary text-white flex items-center justify-center shadow-lg shadow-primary/30">
|
||||||
<!-- Hero Balance Card: The Prosperity Spark -->
|
<span class="material-symbols-outlined">add</span>
|
||||||
<div class="relative overflow-hidden rounded-[2rem] p-8 bg-gradient-to-br from-primary to-primary-container text-on-primary shadow-2xl shadow-primary/20">
|
</span>
|
||||||
<!-- Subtle Animated Sparkle Background -->
|
</button>
|
||||||
<div class="absolute -right-10 -top-10 w-40 h-40 bg-tertiary-fixed opacity-10 blur-3xl rounded-full"></div>
|
</nav>
|
||||||
<div class="absolute -left-10 -bottom-10 w-40 h-40 bg-primary-fixed opacity-10 blur-3xl rounded-full"></div>
|
|
||||||
<div class="relative z-10">
|
<script src="js/ui.js"></script>
|
||||||
<p class="text-on-primary/70 font-label tracking-widest uppercase text-xs mb-2">Balance Total</p>
|
|
||||||
<h3 id="ui-balance" class="text-5xl font-extrabold tracking-tighter mb-6">$0.00</h3>
|
|
||||||
<div class="flex items-center gap-2 bg-on-primary/10 w-fit px-4 py-2 rounded-full backdrop-blur-md">
|
|
||||||
<span class="material-symbols-outlined text-sm" style="font-variation-settings: 'FILL' 1;">trending_up</span>
|
|
||||||
<span class="text-sm font-medium">+12.4% este mes</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Spending Insights: Donut Chart Section -->
|
|
||||||
<section class="space-y-4">
|
|
||||||
<h4 class="headline-sm text-on-surface-variant font-semibold px-1">Distribución de Gastos</h4>
|
|
||||||
<div class="bg-surface-container-low rounded-[2rem] p-8 flex flex-col items-center">
|
|
||||||
<!-- Visual Donut (CSS Representation) -->
|
|
||||||
<div id="ui-donut-chart" class="relative w-48 h-48 mb-8">
|
|
||||||
<svg class="w-full h-full transform -rotate-90" viewbox="0 0 36 36">
|
|
||||||
<!-- Comida (45%) -->
|
|
||||||
<circle class="stroke-primary" cx="18" cy="18" fill="none" r="16" stroke-dasharray="45, 100" stroke-linecap="round" stroke-width="4"></circle>
|
|
||||||
<!-- Vivienda (30%) -->
|
|
||||||
<circle class="stroke-secondary" cx="18" cy="18" fill="none" r="16" stroke-dasharray="30, 100" stroke-dashoffset="-45" stroke-linecap="round" stroke-width="4"></circle>
|
|
||||||
<!-- Transporte (15%) -->
|
|
||||||
<circle class="stroke-tertiary" cx="18" cy="18" fill="none" r="16" stroke-dasharray="15, 100" stroke-dashoffset="-75" stroke-linecap="round" stroke-width="4"></circle>
|
|
||||||
</svg>
|
|
||||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
|
||||||
<span class="text-2xl font-bold">$2,840</span>
|
|
||||||
<span class="text-[10px] uppercase tracking-widest text-on-surface-variant font-bold">Gastado</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Legend -->
|
|
||||||
<div id="ui-donut-legend" class="w-full grid grid-cols-1 gap-3">
|
|
||||||
<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 bg-primary"></div>
|
|
||||||
<span class="text-sm font-semibold">Comida</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm text-on-surface-variant">45%</span>
|
|
||||||
</div>
|
|
||||||
<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 bg-secondary"></div>
|
|
||||||
<span class="text-sm font-semibold">Vivienda</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm text-on-surface-variant">30%</span>
|
|
||||||
</div>
|
|
||||||
<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 bg-tertiary"></div>
|
|
||||||
<span class="text-sm font-semibold">Transporte</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm text-on-surface-variant">15%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<!-- Recent Movements -->
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<div id="ui-movimientos" class="space-y-6">
|
|
||||||
<!-- Transaction 1 -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<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">restaurant</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="font-bold text-on-surface">Restaurante La Paz</p>
|
|
||||||
<p class="text-xs text-on-surface-variant">24 Oct 2023</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="font-bold text-error">-$45.50</p>
|
|
||||||
</div>
|
|
||||||
<!-- Transaction 2 -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<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">shopping_bag</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="font-bold text-on-surface">Tienda Moderna</p>
|
|
||||||
<p class="text-xs text-on-surface-variant">22 Oct 2023</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="font-bold text-error">-$120.00</p>
|
|
||||||
</div>
|
|
||||||
<!-- Transaction 3 -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<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">payments</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="font-bold text-on-surface">Depósito de Nómina</p>
|
|
||||||
<p class="text-xs text-on-surface-variant">20 Oct 2023</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="font-bold text-primary-container">+$2,100.00</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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]">
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
<script src="js/dashboard.js"></script>
|
<script src="js/dashboard.js"></script>
|
||||||
</body></html>
|
<script>
|
||||||
|
// Mes actual en header
|
||||||
|
document.getElementById('ui-mes').textContent =
|
||||||
|
new Date().toLocaleString('es-AR', { month:'long', year:'numeric' });
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
fetch('api/login.php?action=logout').then(() => window.location.href = 'login.html');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
+53
-47
@@ -1,94 +1,100 @@
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
let currentMonto = "0";
|
let currentMonto = "0";
|
||||||
let selectedTipo = "gasto"; // default
|
let selectedTipo = "gasto";
|
||||||
|
|
||||||
const montoEl = document.getElementById('ui-monto');
|
// 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 btnGasto = document.getElementById('btn-gasto');
|
||||||
const btnIngreso = document.getElementById('btn-ingreso');
|
const btnIngreso = document.getElementById('btn-ingreso');
|
||||||
const numpad = document.getElementById('ui-numpad');
|
const numpad = document.getElementById('ui-numpad');
|
||||||
const btnSave = document.getElementById('btn-save');
|
|
||||||
const iptFecha = document.getElementById('ui-fecha');
|
const iptFecha = document.getElementById('ui-fecha');
|
||||||
|
const saveButtons = ['btn-save', 'btn-save-mobile'].map(id => document.getElementById(id)).filter(Boolean);
|
||||||
|
|
||||||
// Default a hoy
|
// Default a hoy
|
||||||
iptFecha.valueAsDate = new Date();
|
iptFecha.valueAsDate = new Date();
|
||||||
|
|
||||||
|
// Categorías ya vienen renderizadas por PHP (add.php)
|
||||||
|
|
||||||
// Tipo selector
|
// Tipo selector
|
||||||
const updateTipoUI = () => {
|
const updateTipoUI = () => {
|
||||||
if (selectedTipo === 'gasto') {
|
const isGasto = selectedTipo === 'gasto';
|
||||||
btnGasto.classList.replace('text-on-surface-variant', 'text-primary');
|
btnGasto.classList.toggle('bg-surface-container-lowest', isGasto);
|
||||||
btnGasto.classList.add('bg-surface-container-lowest');
|
btnGasto.classList.toggle('text-primary', isGasto);
|
||||||
btnIngreso.classList.replace('bg-surface-container-lowest', 'bg-transparent');
|
btnGasto.classList.toggle('shadow-sm', isGasto);
|
||||||
btnIngreso.classList.replace('text-primary', 'text-on-surface-variant');
|
btnGasto.classList.toggle('text-on-surface-variant', !isGasto);
|
||||||
} else {
|
btnIngreso.classList.toggle('bg-surface-container-lowest', !isGasto);
|
||||||
btnIngreso.classList.replace('text-on-surface-variant', 'text-primary');
|
btnIngreso.classList.toggle('text-primary', !isGasto);
|
||||||
btnIngreso.classList.add('bg-surface-container-lowest');
|
btnIngreso.classList.toggle('shadow-sm', !isGasto);
|
||||||
btnGasto.classList.replace('bg-surface-container-lowest', 'bg-transparent');
|
btnIngreso.classList.toggle('text-on-surface-variant', isGasto);
|
||||||
btnGasto.classList.replace('text-primary', 'text-on-surface-variant');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
btnGasto.addEventListener('click', () => { selectedTipo = 'gasto'; updateTipoUI(); });
|
btnGasto.addEventListener('click', () => { selectedTipo = 'gasto'; updateTipoUI(); });
|
||||||
btnIngreso.addEventListener('click', () => { selectedTipo = 'ingreso'; updateTipoUI(); });
|
btnIngreso.addEventListener('click', () => { selectedTipo = 'ingreso'; updateTipoUI(); });
|
||||||
|
|
||||||
// Numpad logic
|
// Numpad
|
||||||
|
function applyInput(val) {
|
||||||
|
if (val === 'backspace') {
|
||||||
|
currentMonto = currentMonto.slice(0, -1) || "0";
|
||||||
|
} else if (/^[0-9]$/.test(val)) {
|
||||||
|
currentMonto = currentMonto === "0" ? val : currentMonto + val;
|
||||||
|
} else if (val === '.') {
|
||||||
|
if (!currentMonto.includes('.')) currentMonto += '.';
|
||||||
|
}
|
||||||
|
montoEls.forEach(el => el.textContent = currentMonto);
|
||||||
|
}
|
||||||
|
|
||||||
numpad.addEventListener('click', (e) => {
|
numpad.addEventListener('click', (e) => {
|
||||||
const btn = e.target.closest('button');
|
const btn = e.target.closest('button');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
|
applyInput(btn.querySelector('[data-icon="backspace"]') ? 'backspace' : btn.textContent.trim());
|
||||||
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";
|
|
||||||
} else {
|
|
||||||
if (currentMonto === "0" && val !== ".") {
|
|
||||||
currentMonto = val;
|
|
||||||
} else {
|
|
||||||
if (val === "." && currentMonto.includes(".")) return;
|
|
||||||
currentMonto += val;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
montoEl.textContent = currentMonto;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Guardar Movimiento
|
// Teclado físico
|
||||||
btnSave.addEventListener('click', async () => {
|
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') doSave();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Guardar
|
||||||
|
async function doSave() {
|
||||||
const payload = {
|
const payload = {
|
||||||
tipo: selectedTipo,
|
tipo: selectedTipo,
|
||||||
monto: parseFloat(currentMonto),
|
monto: parseFloat(currentMonto),
|
||||||
fecha: document.getElementById('ui-fecha').value,
|
fecha: iptFecha.value,
|
||||||
notas: document.getElementById('ui-notas').value,
|
notas: document.getElementById('ui-notas').value,
|
||||||
id_categoria: document.getElementById('ui-categoria').value
|
id_categoria: document.getElementById('ui-categoria').value
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isNaN(payload.monto) || payload.monto <= 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
saveButtons.forEach(b => { b.disabled = true; b.textContent = 'Guardando…'; });
|
||||||
const btnOriginal = btnSave.innerHTML;
|
|
||||||
btnSave.innerHTML = 'Guardando...';
|
|
||||||
btnSave.disabled = true;
|
|
||||||
|
|
||||||
const r = await fetch('api/movimientos.php', {
|
try {
|
||||||
|
const r = await apiFetch('api/movimientos.php', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
if (!r) return;
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
|
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
window.location.href = 'index.html'; // Redirige al inicio tras guardar
|
window.location.href = 'index.html';
|
||||||
} else {
|
} else {
|
||||||
alert('Error al guardar: ' + data.message);
|
await rmModal.alert(data.message, 'Error al guardar');
|
||||||
btnSave.innerHTML = btnOriginal;
|
saveButtons.forEach(b => { b.disabled = false; b.textContent = 'Guardar Movimiento'; });
|
||||||
btnSave.disabled = false;
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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;
|
saveButtons.forEach(b => { b.disabled = false; b.textContent = 'Guardar Movimiento'; });
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
saveButtons.forEach(btn => btn.addEventListener('click', doSave));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
const ICONOS = [
|
||||||
|
'restaurant','home','directions_car','bolt','movie','shopping_bag',
|
||||||
|
'local_grocery_store','medical_services','school','flight','sports_esports',
|
||||||
|
'coffee','fitness_center','pets','phone_android','work','savings','credit_card',
|
||||||
|
'local_gas_station','music_note','sports_soccer','family_restroom',
|
||||||
|
'local_pharmacy','computer','child_care','celebration','beach_access',
|
||||||
|
'attach_money','bar_chart','payments'
|
||||||
|
];
|
||||||
|
|
||||||
|
const COLORES = [
|
||||||
|
'#005050','#4d6357','#264b5c','#3e4948','#006a6a','#2d6a4f',
|
||||||
|
'#c0392b','#e67e22','#8b6914','#2980b9','#6c3483','#555555'
|
||||||
|
];
|
||||||
|
|
||||||
|
let editandoId = null;
|
||||||
|
let iconoSelecto = ICONOS[0];
|
||||||
|
let colorSelecto = COLORES[0];
|
||||||
|
|
||||||
|
// ─── Inicialización ───────────────────────────────────────────────
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
renderIconGrid();
|
||||||
|
renderColorGrid();
|
||||||
|
document.getElementById('drawer-nombre').addEventListener('input', actualizarPreview);
|
||||||
|
document.getElementById('drawer-save').addEventListener('click', guardar);
|
||||||
|
cargarCategorias();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Cargar y renderizar lista ────────────────────────────────────
|
||||||
|
async function cargarCategorias() {
|
||||||
|
const r = await apiFetch('api/categorias.php');
|
||||||
|
if (!r) return;
|
||||||
|
const data = await r.json();
|
||||||
|
|
||||||
|
document.getElementById('ui-loading').classList.add('hidden');
|
||||||
|
const lista = document.getElementById('ui-lista');
|
||||||
|
|
||||||
|
if (!data.categorias.length) {
|
||||||
|
lista.classList.remove('hidden');
|
||||||
|
lista.innerHTML = `<div class="py-12 flex flex-col items-center gap-2 text-on-surface-variant">
|
||||||
|
<span class="material-symbols-outlined text-4xl opacity-30">category</span>
|
||||||
|
<p class="text-sm">Sin categorías</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lista.classList.remove('hidden');
|
||||||
|
lista.innerHTML = data.categorias.map(c => {
|
||||||
|
const tieneMovs = parseInt(c.total_movimientos) > 0;
|
||||||
|
return `
|
||||||
|
<div class="flex items-center gap-4 px-5 py-3.5">
|
||||||
|
<div class="w-11 h-11 rounded-xl flex items-center justify-center flex-shrink-0"
|
||||||
|
style="background:${c.color_hex}">
|
||||||
|
<span class="material-symbols-outlined text-white text-[20px]"
|
||||||
|
style="font-variation-settings:'FILL' 1;">${c.icono}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-semibold text-on-surface text-sm">${c.nombre}</p>
|
||||||
|
<p class="text-xs text-on-surface-variant">${c.total_movimientos} movimiento${c.total_movimientos != 1 ? 's' : ''}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button onclick="abrirDrawer(${JSON.stringify(c).replace(/"/g, '"')})"
|
||||||
|
class="p-2 rounded-xl hover:bg-surface-container text-on-surface-variant transition-colors">
|
||||||
|
<span class="material-symbols-outlined text-[18px]">edit</span>
|
||||||
|
</button>
|
||||||
|
<button onclick="eliminar(${c.id}, '${c.nombre.replace("'","\\\'")}')"
|
||||||
|
class="p-2 rounded-xl transition-colors ${tieneMovs ? 'text-outline cursor-not-allowed opacity-40' : 'hover:bg-[#ffdad6] hover:text-[#ba1a1a] text-on-surface-variant'}"
|
||||||
|
${tieneMovs ? 'disabled title="Tiene movimientos asociados"' : ''}>
|
||||||
|
<span class="material-symbols-outlined text-[18px]">delete</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Drawer ───────────────────────────────────────────────────────
|
||||||
|
function abrirDrawer(cat) {
|
||||||
|
editandoId = cat ? cat.id : null;
|
||||||
|
iconoSelecto = cat ? cat.icono : ICONOS[0];
|
||||||
|
colorSelecto = cat ? cat.color_hex : COLORES[0];
|
||||||
|
|
||||||
|
document.getElementById('drawer-title').textContent = cat ? 'Editar categoría' : 'Nueva categoría';
|
||||||
|
document.getElementById('drawer-nombre').value = cat ? cat.nombre : '';
|
||||||
|
|
||||||
|
actualizarSeleccionIcono();
|
||||||
|
actualizarSeleccionColor();
|
||||||
|
actualizarPreview();
|
||||||
|
|
||||||
|
document.getElementById('drawer-overlay').classList.remove('hidden');
|
||||||
|
const drawer = document.getElementById('drawer');
|
||||||
|
drawer.classList.remove('translate-y-full','md:translate-x-full');
|
||||||
|
setTimeout(() => document.getElementById('drawer-nombre').focus(), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cerrarDrawer() {
|
||||||
|
document.getElementById('drawer-overlay').classList.add('hidden');
|
||||||
|
const drawer = document.getElementById('drawer');
|
||||||
|
drawer.classList.add('translate-y-full','md:translate-x-full');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Íconos ───────────────────────────────────────────────────────
|
||||||
|
function renderIconGrid() {
|
||||||
|
document.getElementById('icon-grid').innerHTML = ICONOS.map(ic => `
|
||||||
|
<button onclick="seleccionarIcono('${ic}')" id="ico-${ic}"
|
||||||
|
class="h-10 rounded-xl flex items-center justify-center transition-all border-2 border-transparent hover:bg-surface-container">
|
||||||
|
<span class="material-symbols-outlined text-on-surface-variant text-[20px]"
|
||||||
|
style="font-variation-settings:'FILL' 1;">${ic}</span>
|
||||||
|
</button>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function seleccionarIcono(ic) {
|
||||||
|
iconoSelecto = ic;
|
||||||
|
actualizarSeleccionIcono();
|
||||||
|
actualizarPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function actualizarSeleccionIcono() {
|
||||||
|
document.querySelectorAll('#icon-grid button').forEach(b => {
|
||||||
|
const ic = b.id.replace('ico-', '');
|
||||||
|
b.classList.toggle('bg-primary/10', ic === iconoSelecto);
|
||||||
|
b.classList.toggle('border-primary', ic === iconoSelecto);
|
||||||
|
b.querySelector('span').style.color = ic === iconoSelecto ? '#005050' : '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Colores ──────────────────────────────────────────────────────
|
||||||
|
function renderColorGrid() {
|
||||||
|
document.getElementById('color-grid').innerHTML = COLORES.map(col => `
|
||||||
|
<button onclick="seleccionarColor('${col}')" id="col-${col.replace('#','')}"
|
||||||
|
class="w-9 h-9 rounded-xl transition-all ring-2 ring-transparent ring-offset-2"
|
||||||
|
style="background:${col}"></button>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function seleccionarColor(col) {
|
||||||
|
colorSelecto = col;
|
||||||
|
actualizarSeleccionColor();
|
||||||
|
actualizarPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function actualizarSeleccionColor() {
|
||||||
|
document.querySelectorAll('#color-grid button').forEach(b => {
|
||||||
|
const col = '#' + b.id.replace('col-', '');
|
||||||
|
b.style.outline = col === colorSelecto ? `3px solid ${col}` : 'none';
|
||||||
|
b.style.outlineOffset = '2px';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Preview ──────────────────────────────────────────────────────
|
||||||
|
function actualizarPreview() {
|
||||||
|
const nombre = document.getElementById('drawer-nombre').value.trim() || 'Vista previa';
|
||||||
|
document.getElementById('preview-nombre').textContent = nombre;
|
||||||
|
document.getElementById('preview-icono').style.background = colorSelecto;
|
||||||
|
document.getElementById('preview-icono-sym').textContent = iconoSelecto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Guardar ──────────────────────────────────────────────────────
|
||||||
|
async function guardar() {
|
||||||
|
const nombre = document.getElementById('drawer-nombre').value.trim();
|
||||||
|
if (!nombre) {
|
||||||
|
rmModal.alert('El nombre de la categoría no puede estar vacío.', 'Campo requerido');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.getElementById('drawer-save');
|
||||||
|
btn.disabled = true; btn.textContent = 'Guardando…';
|
||||||
|
|
||||||
|
const payload = { nombre, icono: iconoSelecto, color_hex: colorSelecto };
|
||||||
|
const method = editandoId ? 'PUT' : 'POST';
|
||||||
|
if (editandoId) payload.id = editandoId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await apiFetch('api/categorias.php', {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!r) return;
|
||||||
|
const data = await r.json();
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
cerrarDrawer();
|
||||||
|
rmModal.toast(editandoId ? 'Categoría actualizada' : 'Categoría creada');
|
||||||
|
await cargarCategorias();
|
||||||
|
} else {
|
||||||
|
await rmModal.alert(data.message, 'Error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await rmModal.alert('Error de conexión', 'Error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.textContent = 'Guardar';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Eliminar ─────────────────────────────────────────────────────
|
||||||
|
async function eliminar(id, nombre) {
|
||||||
|
const ok = await rmModal.confirm(`¿Eliminar la categoría "${nombre}"?`, 'Eliminar categoría');
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
const r = await apiFetch('api/categorias.php', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id })
|
||||||
|
});
|
||||||
|
if (!r) return;
|
||||||
|
const data = await r.json();
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
rmModal.toast('Categoría eliminada');
|
||||||
|
await cargarCategorias();
|
||||||
|
} else {
|
||||||
|
rmModal.toast(data.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
-68
@@ -1,80 +1,88 @@
|
|||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('api/movimientos.php');
|
const response = await apiFetch('api/movimientos.php');
|
||||||
|
if (!response) return;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
if (data.status !== 'success') return;
|
||||||
|
|
||||||
if (data.status === 'success') {
|
// Balance
|
||||||
// Update balance
|
const balance = data.balance ?? 0;
|
||||||
document.getElementById('ui-balance').textContent = `$${data.balance.toFixed(2)}`;
|
document.getElementById('ui-balance').textContent = formatMoney(balance);
|
||||||
|
document.getElementById('ui-balance-resumen').textContent =
|
||||||
|
balance >= 0 ? 'Saldo disponible' : 'Saldo negativo';
|
||||||
|
|
||||||
// Render movements
|
// Movimientos recientes
|
||||||
const uiMovimientos = document.getElementById('ui-movimientos');
|
const container = document.getElementById('ui-movimientos');
|
||||||
uiMovimientos.innerHTML = '';
|
if (!data.movimientos.length) {
|
||||||
|
container.innerHTML = `
|
||||||
data.movimientos.forEach(mov => {
|
<div class="flex flex-col items-center py-10 gap-2 text-on-surface-variant">
|
||||||
const isExpense = mov.tipo === 'gasto';
|
<span class="material-symbols-outlined text-4xl opacity-30">receipt_long</span>
|
||||||
const sign = isExpense ? '-' : '+';
|
<p class="text-sm">Sin movimientos aún</p>
|
||||||
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>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
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>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
document.getElementById('ui-donut-chart').innerHTML = svgHtml;
|
|
||||||
document.getElementById('ui-donut-legend').innerHTML = legendHtml;
|
|
||||||
} else {
|
} 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>`;
|
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('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = '';
|
document.getElementById('ui-donut-legend').innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} 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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 apiFetch('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 apiFetch('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,77 @@
|
|||||||
|
// Wrapper de fetch que redirige al login si la sesión expiró
|
||||||
|
window.apiFetch = async function (url, options = {}) {
|
||||||
|
const r = await fetch(url, options);
|
||||||
|
if (r.status === 401) {
|
||||||
|
window.location.href = 'login.html';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
|
||||||
|
(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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
|
<title>Quiet Wealth — Acceso</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",
|
||||||
|
"surface-container-lowest": "#ffffff",
|
||||||
|
"surface-container-low": "#f2f4f3",
|
||||||
|
"surface-container": "#eceeed",
|
||||||
|
"on-surface": "#191c1c",
|
||||||
|
"on-surface-variant": "#3e4948",
|
||||||
|
"primary": "#005050",
|
||||||
|
"primary-container": "#006a6a",
|
||||||
|
"on-primary": "#ffffff",
|
||||||
|
"error": "#ba1a1a",
|
||||||
|
"outline": "#6e7979",
|
||||||
|
"outline-variant": "#bec9c8",
|
||||||
|
},
|
||||||
|
fontFamily: { headline: ["Manrope"], body: ["Manrope"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Manrope', sans-serif; }
|
||||||
|
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-background min-h-screen flex items-center justify-center px-6">
|
||||||
|
|
||||||
|
<div class="w-full max-w-sm space-y-8">
|
||||||
|
<!-- Logo / título -->
|
||||||
|
<div class="text-center space-y-3">
|
||||||
|
<div class="inline-flex items-center justify-center w-16 h-16 rounded-[1.5rem] bg-gradient-to-br from-primary to-primary-container shadow-lg shadow-primary/20">
|
||||||
|
<span class="material-symbols-outlined text-white text-3xl" style="font-variation-settings:'FILL' 1;">account_balance_wallet</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-extrabold text-primary tracking-tight">Quiet Wealth</h1>
|
||||||
|
<p class="text-sm text-on-surface-variant mt-1">Ingresá para continuar</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulario -->
|
||||||
|
<div class="bg-surface-container-lowest rounded-[2rem] p-7 shadow-sm space-y-5">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-[11px] uppercase tracking-widest font-bold text-on-surface-variant">Usuario</label>
|
||||||
|
<div class="flex items-center gap-3 bg-surface-container-low px-4 py-3.5 rounded-xl">
|
||||||
|
<span class="material-symbols-outlined text-outline text-[20px]">person</span>
|
||||||
|
<input id="inp-user" type="text" autocomplete="username"
|
||||||
|
class="bg-transparent text-sm font-semibold w-full focus:outline-none text-on-surface placeholder-outline"
|
||||||
|
placeholder="Usuario"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-[11px] uppercase tracking-widest font-bold text-on-surface-variant">Contraseña</label>
|
||||||
|
<div class="flex items-center gap-3 bg-surface-container-low px-4 py-3.5 rounded-xl">
|
||||||
|
<span class="material-symbols-outlined text-outline text-[20px]">lock</span>
|
||||||
|
<input id="inp-pass" type="password" autocomplete="current-password"
|
||||||
|
class="bg-transparent text-sm font-semibold w-full focus:outline-none text-on-surface placeholder-outline"
|
||||||
|
placeholder="Contraseña"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="ui-error" class="hidden text-sm text-error font-semibold text-center pt-1"></p>
|
||||||
|
|
||||||
|
<button id="btn-login"
|
||||||
|
class="w-full h-14 bg-gradient-to-br from-primary to-primary-container text-white rounded-xl font-bold text-base shadow-lg shadow-primary/20 active:scale-[0.98] transition-transform flex items-center justify-center gap-2 mt-2">
|
||||||
|
<span>Ingresar</span>
|
||||||
|
<span class="material-symbols-outlined text-[20px]">arrow_forward</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const btnLogin = document.getElementById('btn-login');
|
||||||
|
const inpUser = document.getElementById('inp-user');
|
||||||
|
const inpPass = document.getElementById('inp-pass');
|
||||||
|
const uiError = document.getElementById('ui-error');
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
uiError.classList.add('hidden');
|
||||||
|
btnLogin.disabled = true;
|
||||||
|
btnLogin.innerHTML = '<span>Verificando...</span>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch('api/login.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ user: inpUser.value, pass: inpPass.value })
|
||||||
|
});
|
||||||
|
const data = await r.json();
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
window.location.href = 'index.html';
|
||||||
|
} else {
|
||||||
|
uiError.textContent = data.message;
|
||||||
|
uiError.classList.remove('hidden');
|
||||||
|
btnLogin.disabled = false;
|
||||||
|
btnLogin.innerHTML = '<span>Ingresar</span><span class="material-symbols-outlined text-[20px]">arrow_forward</span>';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
uiError.textContent = 'Error de conexión con el servidor.';
|
||||||
|
uiError.classList.remove('hidden');
|
||||||
|
btnLogin.disabled = false;
|
||||||
|
btnLogin.innerHTML = '<span>Ingresar</span><span class="material-symbols-outlined text-[20px]">arrow_forward</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
btnLogin.addEventListener('click', login);
|
||||||
|
document.addEventListener('keydown', (e) => { if (e.key === 'Enter') login(); });
|
||||||
|
|
||||||
|
// Si ya está autenticado, redirigir directo
|
||||||
|
fetch('api/login.php').then(r => {
|
||||||
|
if (r.status === 200) window.location.href = 'index.html';
|
||||||
|
}).catch(() => {});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user