diff --git a/.env b/.env index 2547912..f754573 100755 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ -# API Configuration — apunta al BFF -#_dev local: http://localhost:5269 -#docker: http://localhost:5001 -VITE_API_BASE_URL=http://localhost:5001 +# API Configuration — apunta al BFF local +# Desarrollo local: http://localhost:5269 +# Docker: http://localhost:5001 +VITE_API_BASE_URL=http://localhost:5269 diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd91285 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# doli-front + +SPA (Single Page Application) para la gestión empresarial integrada con Dolibarr. +Desarrollada en Vanilla JS con Vite 7, sin frameworks frontend. + +## Tecnologías + +- **Vanilla JS** con ES Modules +- **Vite 7** — bundler y servidor de desarrollo +- **Chart.js** — gráficas del dashboard +- **@huggingface/transformers** — asistente de voz (Whisper, ejecución local en el navegador) + +## Requisitos + +- Node.js 18+ +- pnpm + +## Instalación y desarrollo + +```bash +pnpm install +pnpm dev # http://localhost:5173 +``` + +## Build de producción + +```bash +pnpm build # genera la carpeta dist/ +pnpm preview # previsualización del build +``` + +## Configuración + +Crea un archivo `.env` en la raíz del proyecto: + +```env +VITE_API_BASE_URL=http://localhost:5001 +``` + +Por defecto apunta al BFF en `localhost:5001`. + +## Páginas disponibles + +| Ruta hash | Página | Descripción | +|-----------|--------|-------------| +| `#/` | Dashboard | Métricas generales y gráficas | +| `#/facturas` | Facturas | Facturas de clientes: crear, editar, anular | +| `#/facturas-proveedores` | Facturas proveedores | CRUD completo, líneas, pagos y cambio de estado | +| `#/clientes` | Clientes | Listado y gestión de clientes | +| `#/contacts` | Contactos | Listado y gestión de contactos | +| `#/banco` | Banco | Movimientos bancarios | +| `#/settings` | Configuración | Tema, sesión JWT, webhook y registro VeriFactu | + +## Estructura del proyecto + +``` +doli-front/ +├── src/ +│ ├── main.js # punto de entrada +│ ├── router.js # router hash-based +│ ├── pages/ # una página por módulo de negocio +│ │ ├── pagesRegistry.js # registro central de rutas +│ │ ├── DashboardPage.js +│ │ ├── Facturas.js +│ │ ├── FacturasProveedores.js +│ │ ├── ClientesPage.js +│ │ ├── ContactsPage.js +│ │ ├── BancoPage.js +│ │ └── SettingsPage.js +│ ├── services/ # clientes HTTP por entidad +│ │ ├── apiClient.js # fetch base con JWT automático +│ │ ├── auth.js +│ │ ├── invoices.js +│ │ ├── supplierInvoices.js +│ │ ├── clients.js +│ │ ├── contacts.js +│ │ ├── verifactu.js +│ │ └── ... +│ ├── components/ # componentes reutilizables +│ │ ├── Sidebar.js +│ │ ├── InvoiceModal.js +│ │ ├── VoiceAssistant.js +│ │ └── ... +│ └── styles/ # CSS modular +├── index.html +└── vite.config.js +``` + +## Arquitectura + +La aplicación sigue un patrón de **SPA con router hash** sin dependencias de framework: + +1. `main.js` inicializa el router y monta el layout principal (sidebar + área de contenido). +2. `router.js` escucha cambios en `window.location.hash` y renderiza la página correspondiente. +3. Cada página es una función que devuelve un `HTMLElement` con toda su lógica encapsulada. +4. `services/apiClient.js` centraliza todas las llamadas HTTP, adjunta el token JWT y gestiona errores 401. + +## Credenciales de acceso (desarrollo) + +- **Usuario:** `admin` +- **Contraseña:** `12345678` + +> Estas son las credenciales del BFF/Dolibarr por defecto. diff --git a/ajustesDeJuanjo.txt b/ajustesDeJuanjo.txt new file mode 100644 index 0000000..4c83cac --- /dev/null +++ b/ajustesDeJuanjo.txt @@ -0,0 +1,11 @@ +Dashboard: +1.Ver compras y ventas +2. Facturacion trimestral -> balance Trimestral +3. Ultimas facturas recibidas y realizadas +4. Dashboard hacerlo bonito + +Facturas: +1. Poner nombre en vez de id + +General: +1. SOLO UNA EMPRESA (todos en una misma) \ No newline at end of file diff --git a/package.json b/package.json index b3fb271..075cc94 100755 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@huggingface/transformers": "^3.8.1", - "chart.js": "^4.5.1" + "chart.js": "^4.5.1", + "node-forge": "^1.4.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d5d42c..5daff66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: chart.js: specifier: ^4.5.1 version: 4.5.1 + node-forge: + specifier: ^1.4.0 + version: 1.4.0 devDependencies: vite: specifier: ^7.2.4 @@ -626,6 +629,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + node-forge@1.4.0: + resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} + engines: {node: '>= 6.13.0'} + object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -1163,6 +1170,8 @@ snapshots: nanoid@3.3.12: {} + node-forge@1.4.0: {} + object-keys@1.1.1: {} onnxruntime-common@1.21.0: {} diff --git a/presentacion/guion_presentacion.md b/presentacion/guion_presentacion.md new file mode 100644 index 0000000..10d0f4c --- /dev/null +++ b/presentacion/guion_presentacion.md @@ -0,0 +1,95 @@ +# Guion de presentación — Frontend (doli-front) + +> Tiempo estimado de esta parte: ~15 min dentro de los 50 min totales del equipo. + +--- + +## 1. Introducción al frontend (2 min) + +- Qué es `doli-front`: SPA que consume el BFF para mostrar y gestionar los datos de Dolibarr. +- Decisión técnica: **Vanilla JS** sin frameworks → sin dependencias de React/Vue, control total del DOM. +- Herramienta de build: **Vite 7** — HMR instantáneo en desarrollo, build optimizado con ES Modules. + +--- + +## 2. Arquitectura interna (3 min) + +Mostrar la estructura de carpetas en el IDE: + +``` +src/ +├── main.js ← punto de entrada, monta layout +├── router.js ← escucha hash, carga la página correcta +├── pages/ ← cada página es una función que devuelve un HTMLElement +├── services/ ← apiClient.js centraliza el fetch + JWT +└── components/ ← sidebar, modales reutilizables +``` + +**Puntos clave a explicar:** +- El router es hash-based (`#/facturas`, `#/clientes`…) → no necesita servidor con rutas configuradas. +- `apiClient.js`: adjunta el token JWT automáticamente en cada petición, redirige al login en 401. +- Cada página encapsula su propio estado, listeners y cleanup (sin estado global compartido). + +--- + +## 3. Demo en vivo — recorrido por las páginas (8 min) + +Abrir la app en el navegador y mostrar: + +### Dashboard +- Gráficas de facturas con **Chart.js**. +- Métricas en tarjetas (total clientes, facturas pendientes…). + +### Facturas de proveedores ← parte más completa +- Listar facturas con búsqueda en tiempo real. +- Abrir detalle: ver líneas, importes, estado. +- Cambiar estado: Borrador → Validada → Pagada (botones contextuales). +- Añadir línea a una factura en borrador. +- Registrar un pago (seleccionar tipo, importe, fecha). +- Eliminar factura en borrador con confirmación. + +### Contactos +- Búsqueda en tiempo real sobre la tabla. +- Ver detalle / editar inline. +- Crear nuevo contacto con selector de empresa. + +### Configuración +- Toggle tema claro/oscuro (persiste en localStorage). +- Temporizador de expiración del JWT en tiempo real. +- Configurar URL de webhook (Teams/Slack). +- Registro de certificado VeriFactu: selector de archivo `.p12` → se convierte a base64 → se envía al BFF. + +--- + +## 4. Decisiones técnicas destacables (2 min) + +- **Sin framework**: cada página es una función `render*()` → fácil de leer, sin magia. +- **FileReader API**: el certificado `.p12` se lee en el navegador y se convierte a base64 antes de enviarlo, sin exponer rutas del servidor. +- **Asistente de voz**: Whisper ejecutándose en local en el navegador con `@huggingface/transformers` (sin APIs externas de voz). +- **Toast notifications**: sistema propio ligero sin librerías externas. +- **Cleanup de páginas**: cada página registra un método `cleanup()` que el router llama al navegar, evitando memory leaks de intervals/listeners. + +--- + +## Capturas recomendadas para incluir en slides + +- Captura del dashboard con las gráficas. +- Captura del modal de detalle de factura de proveedor (con líneas y pagos). +- Captura de la sección VeriFactu en ajustes. +- Diagrama simple del flujo: `Página → apiClient → BFF`. + +--- + +## Posibles preguntas del profesor + +**¿Por qué Vanilla JS y no React?** +→ Para demostrar conocimiento de las APIs del navegador sin abstracciones. El proyecto es de tamaño manejable y no requería el overhead de un framework. + +**¿Cómo gestionas el estado?** +→ Cada página tiene su propio estado local (variables en el closure). No hay estado global; el router destruye la página anterior antes de montar la nueva. + +**¿Cómo funciona el JWT?** +→ El BFF devuelve un token al hacer login. El frontend lo guarda en `localStorage` y `apiClient.js` lo adjunta en el header `Authorization: Bearer` de cada petición. + +**¿Cómo se integra con VeriFactu?** +→ La página de ajustes lee el `.p12` como base64 con `FileReader`, lo envía al BFF, y el BFF lo reenvía al servicio Go que valida y almacena el certificado. diff --git a/presentacion/que_subir.md b/presentacion/que_subir.md new file mode 100644 index 0000000..59a4077 --- /dev/null +++ b/presentacion/que_subir.md @@ -0,0 +1,42 @@ +# Qué subir a projectes.ieslamar.org + +## Incluir + +``` +doli-front/ +├── src/ ✅ todo el código fuente +├── public/ ✅ assets estáticos +├── index.html ✅ +├── vite.config.js ✅ +├── package.json ✅ +├── pnpm-lock.yaml ✅ +├── .env.example ✅ (si existe, sin valores reales) +├── README.md ✅ +└── presentacion/ ✅ tu carpeta de presentación personal +``` + +## Excluir (NO subir) + +``` +node_modules/ ❌ se regenera con pnpm install +dist/ ❌ se regenera con pnpm build +.env ❌ contiene credenciales reales +.git/ ❌ historial git interno +``` + +## Pasos para preparar el ZIP + +```bash +cd doli-front + +# Asegúrate de que .gitignore ya excluye node_modules y dist +# Luego crea el zip excluyendo lo que no debe ir: + +zip -r doli-front.zip . \ + --exclude "*/node_modules/*" \ + --exclude "*/dist/*" \ + --exclude "*/.git/*" \ + --exclude "*/.env" +``` + +El archivo `doli-front.zip` es lo que subes a la plataforma. diff --git a/src/components/InvoiceItem.js b/src/components/InvoiceItem.js index f67e020..bf46301 100644 --- a/src/components/InvoiceItem.js +++ b/src/components/InvoiceItem.js @@ -65,7 +65,7 @@ export function InvoiceItem(invoice, onView) { ${formatDate(invoice.date)} ${formatCurrency(invoice.total)} - ${formatCurrency(invoice.remainToPay)} + ${invoice.status === 'paid' ? '—' : formatCurrency(invoice.remainToPay)} + + + +

+ + `; @@ -159,6 +198,20 @@ export function renderSettingsPage() { // Webhook config const webhookInput = container.querySelector('#webhook-url-input'); const webhookStatus = container.querySelector('#webhook-status'); + const verifactuStatus = container.querySelector('#verifactu-status'); + const verifactuCertName = container.querySelector('#verifactu-cert-name'); + const verifactuCertFile = container.querySelector('#verifactu-cert-file'); + const verifactuCertPassword = container.querySelector('#verifactu-cert-password'); + + verifactuCertFile.addEventListener('change', () => { + const file = verifactuCertFile.files?.[0]; + if (!file) return; + + if (!file.name.toLowerCase().endsWith('.p12')) { + verifactuCertFile.value = ''; + showToast('Solo se permite subir archivos con extension .p12', 'error'); + } + }); async function loadWebhookUrl() { try { @@ -188,6 +241,110 @@ export function renderSettingsPage() { webhookInput.value = ''; }); + container.querySelector('#verifactu-health-btn').addEventListener('click', async () => { + const button = container.querySelector('#verifactu-health-btn'); + button.disabled = true; + verifactuStatus.textContent = 'Consultando estado de VeriFactu...'; + try { + const health = await getVeriFactuHealth(); + if (health?.status === 'down') { + verifactuStatus.textContent = `VeriFactu no responde: ${health.error || 'sin detalle'}`; + showToast('VeriFactu no está disponible en 6789', 'error'); + } else { + verifactuStatus.textContent = `API activa: ${health.status || 'ok'}`; + showToast('VeriFactu responde correctamente', 'success'); + } + } catch (error) { + verifactuStatus.textContent = `Error al comprobar la API: ${error.message}`; + showToast(`VeriFactu: ${error.message}`, 'error'); + } finally { + button.disabled = false; + } + }); + + container.querySelector('#verifactu-formats-btn').addEventListener('click', async () => { + const button = container.querySelector('#verifactu-formats-btn'); + button.disabled = true; + verifactuStatus.textContent = 'Cargando formatos soportados...'; + try { + const data = await getVeriFactuFormats(); + const formats = Array.isArray(data.formats) ? data.formats.join(', ') : 'sin datos'; + verifactuStatus.textContent = `Formatos soportados: ${formats}`; + } catch (error) { + verifactuStatus.textContent = `Error al cargar formatos: ${error.message}`; + showToast(`VeriFactu: ${error.message}`, 'error'); + } finally { + button.disabled = false; + } + }); + + container.querySelector('#verifactu-register-btn').addEventListener('click', async () => { + const button = container.querySelector('#verifactu-register-btn'); + const certName = verifactuCertName.value.trim(); + const file = verifactuCertFile.files?.[0]; + const password = verifactuCertPassword.value; + + if (!certName || !file || !password) { + showToast('Rellena nombre, selecciona el archivo .p12 y escribe la contraseña', 'error'); + return; + } + + if (!file.name.toLowerCase().endsWith('.p12')) { + showToast('El certificado debe tener extension .p12', 'error'); + return; + } + + let cert_file; + try { + cert_file = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result.split(',')[1]); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + } catch { + showToast('No se pudo leer el archivo .p12', 'error'); + return; + } + + let password_encrypted; + try { + console.log('[DEBUG] Password before encryption:', password); + password_encrypted = await encryptVeriFactuPassword(password); + console.log('[DEBUG] Encrypted password:', password_encrypted); + } catch (error) { + showToast(`No se pudo cifrar la contraseña: ${error.message}`, 'error'); + return; + } + + button.disabled = true; + verifactuStatus.textContent = 'Registrando certificado en VeriFactu...'; + try { + const result = await registerVeriFactuCertificate({ + cert_name: certName, + cert_file, + password: password_encrypted + }); + + if (result?.success === false) { + const errorMessage = formatVeriFactuError(result.error); + verifactuStatus.textContent = `Error al registrar certificado: ${errorMessage}`; + showToast(`VeriFactu: ${errorMessage}`, 'error'); + return; + } + + const token = result.token ? ` Token: ${result.token}` : ''; + verifactuStatus.textContent = `Certificado registrado.${token}`; + showToast('Certificado registrado en VeriFactu', 'success'); + } catch (error) { + const errorMessage = formatVeriFactuError(error); + verifactuStatus.textContent = `Error al registrar certificado: ${errorMessage}`; + showToast(`VeriFactu: ${errorMessage}`, 'error'); + } finally { + button.disabled = false; + } + }); + loadWebhookUrl(); container.cleanup = () => clearInterval(intervalId); diff --git a/src/pages/pagesRegistry.js b/src/pages/pagesRegistry.js index 873306d..7dc0c70 100755 --- a/src/pages/pagesRegistry.js +++ b/src/pages/pagesRegistry.js @@ -1,98 +1,48 @@ -import { renderDashboard } from './DashboardPage.js'; -import { renderFacturasPage } from './Facturas.js'; -import { renderFacturasProveedoresPage } from './FacturasProveedores.js'; -import { renderCreateInvoicePage } from './CreateInvoicePage.js'; -import { renderClientesPage } from './ClientesPage.js'; -import { renderContactsPage } from './ContactsPage.js'; -import { renderSettingsPage } from './SettingsPage.js'; -import { renderBancoPage } from './BancoPage.js'; +import { pagesConfig } from '../services/pagesConfig.js'; import { icons } from '../services/icons.js'; -export const pagesRegistry = [ - { - route: 'dashboard', - name: 'Dashboard', - icon: icons.dashboard, - voicePatterns: ['dashboard', 'inicio', 'ir al inicio', 'ir al dashboard', 'resumen', 'home', 'panel', 'principal'], - requiresAuth: true, - showInSidebar: true, - render: renderDashboard - }, - { - route: 'invoices', - name: 'Facturas', - icon: icons.invoices, - voicePatterns: ['facturas', 'ver facturas', 'ir a facturas', 'lista facturas', 'mis facturas', 'listado facturas'], - requiresAuth: true, - showInSidebar: true, - render: renderFacturasPage - }, - { - route: 'facturas-proveedores', - name: 'Fact. Proveedores', - icon: icons.supplierInvoices, - voicePatterns: ['facturas proveedores', 'proveedores', 'facturas de proveedor', 'compras'], - requiresAuth: true, - showInSidebar: true, - render: renderFacturasProveedoresPage - }, - { - route: 'create-invoice', - name: 'Nueva Factura', - icon: icons.plus, - voicePatterns: ['nueva factura', 'crear factura', 'factura nueva', 'añadir factura'], - requiresAuth: true, - showInSidebar: false, - render: renderCreateInvoicePage - }, - { - route: 'clients', - name: 'Clientes', - icon: icons.clients, - voicePatterns: ['clientes', 'ver clientes', 'ir a clientes', 'lista clientes', 'mis clientes'], - requiresAuth: true, - showInSidebar: true, - render: renderClientesPage - }, - { - route: 'contacts', - name: 'Contactos', - icon: icons.user, - voicePatterns: ['contactos', 'ver contactos', 'ir a contactos', 'lista contactos'], - requiresAuth: true, - showInSidebar: true, - render: renderContactsPage - }, - { - route: 'banco', - name: 'Banco', - icon: icons.bank, - voicePatterns: ['banco', 'ir al banco', 'cuentas', 'cuentas bancarias', 'movimientos'], - requiresAuth: true, - showInSidebar: true, - render: renderBancoPage - }, - { - route: 'settings', - name: 'Configuración', - icon: icons.settings, - voicePatterns: ['configuración', 'configuracion', 'ajustes', 'settings', 'preferencias'], - requiresAuth: true, - showInSidebar: true, - render: renderSettingsPage - } -]; +const modules = import.meta.glob('./*Page.js', { eager: true }); +const knownFiles = new Set(pagesConfig.map(p => p._file).filter(Boolean)); + +function toRoute(path) { + return path.replace('./', '').replace('Page.js', '') + .replace(/([A-Z])/g, (c, l, i) => (i ? '-' : '') + l.toLowerCase()); +} + +function toName(path) { + return path.replace('./', '').replace('Page.js', '') + .replace(/([A-Z])/g, (c, l, i) => (i ? ' ' : '') + l).trim(); +} + +const autoPages = Object.entries(modules) + .filter(([path]) => { + const file = path.replace('./', '').replace('.js', ''); + return !knownFiles.has(file); + }) + .map(([path, mod]) => { + const render = mod.render ?? mod.default + ?? Object.values(mod).find(v => typeof v === 'function') + ?? (() => document.createElement('div')); + return { + route: mod.route ?? toRoute(path), + name: mod.name ?? toName(path), + order: mod.order ?? 99, + icon: mod.icon ?? icons.dashboard, + voicePatterns: mod.voicePatterns ?? [], + requiresAuth: mod.requiresAuth ?? true, + showInSidebar: mod.showInSidebar ?? true, + render, + }; + }); + +export const pagesRegistry = [...pagesConfig, ...autoPages] + .sort((a, b) => (a.order ?? 99) - (b.order ?? 99)); export function getAvailablePages(isAuthenticated) { - if (!isAuthenticated) { - return []; - } - return pagesRegistry.filter(page => { - if (page.requiresAuth && !isAuthenticated) return false; - return page.showInSidebar; - }); + if (!isAuthenticated) return []; + return pagesRegistry.filter(p => p.showInSidebar && (!p.requiresAuth || isAuthenticated)); } export function getPageByRoute(route) { - return pagesRegistry.find(page => page.route === route); -} \ No newline at end of file + return pagesRegistry.find(p => p.route === route); +} diff --git a/src/pages/test.js b/src/pages/test.js deleted file mode 100755 index 890d52a..0000000 --- a/src/pages/test.js +++ /dev/null @@ -1,19 +0,0 @@ -export function renderTestPage() { - const container = document.createElement('div'); - container.className = 'test-page'; - - container.innerHTML = /*html*/` -

Test Page

-

This is a test page to verify sidebar navigation functionality.

-
-

The sidebar should be visible and allow navigation between different pages.

- -
- `; - - container.querySelector('#test-btn').addEventListener('click', () => { - alert('Test button clicked!'); - }); - - return container; -} diff --git a/src/services/pagesConfig.js b/src/services/pagesConfig.js new file mode 100644 index 0000000..bd38e30 --- /dev/null +++ b/src/services/pagesConfig.js @@ -0,0 +1,101 @@ +import { icons } from './icons.js'; +import { renderDashboard } from '../pages/DashboardPage.js'; +import { renderFacturasPage } from '../pages/FacturasPage.js'; +import { renderFacturasProveedoresPage } from '../pages/FacturasProveedoresPage.js'; +import { renderCreateInvoicePage } from '../pages/CreateInvoicePage.js'; +import { renderClientesPage } from '../pages/ClientesPage.js'; +import { renderContactsPage } from '../pages/ContactsPage.js'; +import { renderBancoPage } from '../pages/BancoPage.js'; +import { renderSettingsPage } from '../pages/SettingsPage.js'; + +export const pagesConfig = [ + { + _file: 'DashboardPage', + route: 'dashboard', + name: 'Dashboard', + order: 1, + icon: icons.dashboard, + voicePatterns: ['dashboard', 'inicio', 'ir al inicio', 'ir al dashboard', 'resumen', 'home', 'panel', 'principal'], + requiresAuth: true, + showInSidebar: true, + render: renderDashboard, + }, + { + _file: 'FacturasPage', + route: 'invoices', + name: 'Facturas', + order: 2, + icon: icons.invoices, + voicePatterns: ['facturas', 'ver facturas', 'ir a facturas', 'lista facturas', 'mis facturas', 'listado facturas'], + requiresAuth: true, + showInSidebar: true, + render: renderFacturasPage, + }, + { + _file: 'FacturasProveedoresPage', + route: 'facturas-proveedores', + name: 'Fact. Proveedores', + order: 3, + icon: icons.supplierInvoices, + voicePatterns: ['facturas proveedores', 'proveedores', 'facturas de proveedor', 'compras'], + requiresAuth: true, + showInSidebar: true, + render: renderFacturasProveedoresPage, + }, + { + _file: 'CreateInvoicePage', + route: 'create-invoice', + name: 'Nueva Factura', + order: 4, + icon: icons.plus, + voicePatterns: ['nueva factura', 'crear factura', 'factura nueva', 'añadir factura'], + requiresAuth: true, + showInSidebar: false, + render: renderCreateInvoicePage, + }, + { + _file: 'ClientesPage', + route: 'clients', + name: 'Clientes', + order: 5, + icon: icons.clients, + voicePatterns: ['clientes', 'ver clientes', 'ir a clientes', 'lista clientes', 'mis clientes'], + requiresAuth: true, + showInSidebar: true, + render: renderClientesPage, + }, + { + _file: 'ContactsPage', + route: 'contacts', + name: 'Contactos', + order: 6, + icon: icons.user, + voicePatterns: ['contactos', 'ver contactos', 'ir a contactos', 'lista contactos'], + requiresAuth: true, + showInSidebar: true, + render: renderContactsPage, + }, + { + _file: 'BancoPage', + route: 'banco', + name: 'Banco', + order: 7, + icon: icons.bank, + voicePatterns: ['banco', 'ir al banco', 'cuentas', 'cuentas bancarias', 'movimientos'], + requiresAuth: true, + showInSidebar: true, + render: renderBancoPage, + }, + { + _file: 'SettingsPage', + route: 'settings', + name: 'Configuración', + order: 8, + icon: icons.settings, + voicePatterns: ['configuración', 'configuracion', 'ajustes', 'settings', 'preferencias'], + requiresAuth: true, + showInSidebar: true, + render: renderSettingsPage, + }, + { _file: 'LoginPage' }, +]; diff --git a/src/services/theme.js b/src/services/theme.js index f8c326b..73476f6 100644 --- a/src/services/theme.js +++ b/src/services/theme.js @@ -36,7 +36,7 @@ export function getSavedTheme() { const userKey = getUserThemeKey(); const saved = userKey ? localStorage.getItem(userKey) : localStorage.getItem(THEME_KEY); if (saved === 'dark' || saved === 'light') return saved; - return getSystemPrefersDark() ? 'dark' : 'light'; + return 'light'; } export function applyTheme(theme) { diff --git a/src/services/verifactu.js b/src/services/verifactu.js new file mode 100644 index 0000000..f96c5c1 --- /dev/null +++ b/src/services/verifactu.js @@ -0,0 +1,94 @@ +import forge from 'node-forge'; +import { apiGet, apiPost } from './apiClient.js'; + +let cachedPublicKeyPem = null; + +const VERIFACTU_ERROR_MESSAGES = { + missing_fields: 'Faltan campos obligatorios para registrar el certificado.', + invalid_json: 'La peticion enviada a VeriFactu no tiene un JSON valido.', + invalid_password_encrypted: 'La contraseña cifrada no tiene un formato valido.', + decrypt_failed: 'No se pudo descifrar la contraseña del certificado.', + file_not_found: 'No se encontro el archivo del certificado.', + invalid_password_or_format: 'La contraseña del .p12 es incorrecta o el archivo no es valido.', + certificate_not_yet_valid: 'El certificado aun no es valido por fecha.', + certificate_expired: 'El certificado ha caducado.', + temp_storage_failed: 'No se pudo guardar el certificado temporalmente.', + storage_failed: 'No se pudo guardar el certificado de forma permanente.', + token_generation_failed: 'No se pudo generar el token de sesion del certificado.', + hash_storage_error: 'Error leyendo el hash previo de facturacion.', + hash_save_error: 'Error guardando el hash de la factura.', + internal_server_error: 'VeriFactu devolvio un error interno del servidor.' +}; + +export async function getVeriFactuHealth() { + return apiGet('/api/VeriFactu/health', { auth: false }); +} + +export async function getVeriFactuPublicKey() { + return apiGet('/api/VeriFactu/public-key', { auth: false }); +} + +async function getVeriFactuPublicKeyPem() { + if (cachedPublicKeyPem) { + return cachedPublicKeyPem; + } + + const response = await getVeriFactuPublicKey(); + if (!response?.public_key) { + throw new Error('La API no devolvio la clave publica de VeriFactu'); + } + + cachedPublicKeyPem = atob(response.public_key); + return cachedPublicKeyPem; +} + +export async function encryptVeriFactuPassword(password) { + if (!password) { + throw new Error('La contraseña del certificado no puede estar vacia'); + } + + console.log('[ENCRYPT] Input password:', password); + const publicKeyPem = await getVeriFactuPublicKeyPem(); + console.log('[ENCRYPT] Got public key'); + const publicKey = forge.pki.publicKeyFromPem(publicKeyPem); + console.log('[ENCRYPT] Parsed public key'); + const encryptedBytes = publicKey.encrypt(forge.util.encodeUtf8(password), 'RSAES-PKCS1-V1_5'); + console.log('[ENCRYPT] RSA encrypted, bytes length:', encryptedBytes.length); + const result = forge.util.encode64(encryptedBytes); + console.log('[ENCRYPT] Base64 encoded, result length:', result.length); + return result; +} + +export async function getVeriFactuFormats() { + return apiGet('/api/VeriFactu/formats', { auth: false }); +} + +export async function registerVeriFactuCertificate(payload) { + return apiPost('/api/VeriFactu/certificates/register', payload); +} + +export function formatVeriFactuError(errorLike) { + const raw = String(errorLike?.message || errorLike || '').trim(); + if (!raw) { + return 'Error desconocido en VeriFactu.'; + } + + // Preserve backend text format for these errors to avoid changing JSON/detail structure. + if (raw === 'invalid_json' || raw.startsWith('validation_failed') || raw.startsWith('aeat_error:') || raw.startsWith('aeat_fault:')) { + return raw; + } + + if (VERIFACTU_ERROR_MESSAGES[raw]) { + return VERIFACTU_ERROR_MESSAGES[raw]; + } + + return raw; +} + +export async function sendVeriFactuInvoice(payload) { + return apiPost('/api/VeriFactu/facturas', payload); +} + +export async function cancelVeriFactuInvoice(payload) { + return apiPost('/api/VeriFactu/facturas/anular', payload); +} \ No newline at end of file diff --git a/src/styles/base.css b/src/styles/base.css index 712e4ba..6568950 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -4,8 +4,8 @@ line-height: 1.6; font-weight: 400; color-scheme: light; - color: #1e293b; - background-color: #f8fafc; + color: var(--text-primary); + background-color: var(--bg-page); --primary: #2563eb; --primary-hover: #1d4ed8; @@ -17,6 +17,9 @@ --warning-hover: #a16207; --danger: #dc2626; --danger-hover: #b91c1c; + --green-600: #16a34a; + --amber-600: #d97706; + --red-600: #dc2626; --card-bg: #ffffff; --border-color: #e2e8f0; --border-subtle: #f1f5f9; @@ -354,6 +357,9 @@ button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } so the browser renders options with the correct light/dark contrast */ :root[data-theme='dark'] { color-scheme: dark; + --green-600: #86efac; + --amber-600: #fcd34d; + --red-600: #fca5a5; } select { diff --git a/src/styles/clients.css b/src/styles/clients.css index 6111e83..3e591fa 100644 --- a/src/styles/clients.css +++ b/src/styles/clients.css @@ -117,6 +117,11 @@ font-size: 13px; } +.client-type { + color: var(--text-primary); + font-size: 13px; +} + .client-actions { text-align: center; } @@ -133,6 +138,14 @@ .clients-table td.client-actions .btn-view { margin: 0 auto; + color: var(--primary); + background: transparent; + border: none; +} + +.clients-table td.client-actions .btn-view:hover { + color: var(--primary-hover); + background: var(--primary-light); } .status-badge-client { diff --git a/src/styles/dashboard.css b/src/styles/dashboard.css index c540d06..833a91b 100644 --- a/src/styles/dashboard.css +++ b/src/styles/dashboard.css @@ -26,7 +26,7 @@ .db-kpi-row { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(6, 1fr); gap: var(--space-4); margin-bottom: var(--space-5); } @@ -86,6 +86,40 @@ color: var(--amber-600); } +.db-kpi-icon--red { + background: rgba(220, 38, 38, 0.1); + color: #dc2626; +} + +.db-kpi-icon--purple { + background: rgba(124, 58, 237, 0.1); + color: #7c3aed; +} + +.db-type-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 3px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0; + vertical-align: middle; + margin-right: 2px; +} + +.db-type-venta { + background: rgba(37, 99, 235, 0.12); + color: #2563eb; +} + +.db-type-compra { + background: rgba(220, 38, 38, 0.12); + color: #dc2626; +} + .db-kpi-label { font-size: 11px; font-weight: 500; @@ -331,9 +365,38 @@ .db-table-row:last-child td { border-bottom: none; } .db-table-row:hover td { background: var(--gray-50); } -.db-table-row td:first-child { padding-left: var(--space-5); } +.db-table-row td:first-child { padding-left: var(--space-5); border-left: 3px solid transparent; } .db-table-row td:last-child { padding-right: var(--space-5); } +/* row tint + border + number color by status */ +.db-row--paid td { background: rgba(22, 163, 74, 0.04); } +.db-row--unpaid td { background: rgba(217, 119, 6, 0.05); } + +.db-row--paid td:first-child { border-left-color: var(--green-600); } +.db-row--unpaid td:first-child { border-left-color: var(--amber-600); } +.db-row--draft td:first-child { border-left-color: var(--gray-400); } + +.db-row--paid:hover td { background: rgba(22, 163, 74, 0.09) !important; } +.db-row--unpaid:hover td { background: rgba(217, 119, 6, 0.10) !important; } + +.db-num--paid { color: var(--green-600) !important; } +.db-num--unpaid { color: var(--amber-600) !important; } +.db-num--draft { color: var(--text-secondary) !important; } + +.db-total--pos { color: var(--green-600); font-weight: 600; } +.db-total--neg { color: var(--red-600); font-weight: 600; } + +:root[data-theme='dark'] .db-row--paid td { background: rgba(34, 197, 94, 0.07); } +:root[data-theme='dark'] .db-row--unpaid td { background: rgba(245, 158, 11, 0.07); } +:root[data-theme='dark'] .db-row--paid:hover td { background: rgba(34, 197, 94, 0.13) !important; } +:root[data-theme='dark'] .db-row--unpaid:hover td { background: rgba(245, 158, 11, 0.12) !important; } +:root[data-theme='dark'] .db-row--paid td:first-child { border-left-color: #86efac; } +:root[data-theme='dark'] .db-row--unpaid td:first-child { border-left-color: #fcd34d; } +:root[data-theme='dark'] .db-num--paid { color: #86efac !important; } +:root[data-theme='dark'] .db-num--unpaid { color: #fcd34d !important; } +:root[data-theme='dark'] .db-total--pos { color: #86efac; } +:root[data-theme='dark'] .db-total--neg { color: #fca5a5; } + .db-cell-num { font-variant-numeric: tabular-nums; font-weight: 500; @@ -348,8 +411,12 @@ } .db-cell-right { text-align: right; } -.db-remain-due { color: var(--red-600); font-weight: 600; } -.db-remain-ok { color: var(--green-600); } +.db-remain-due { color: var(--red-600); font-weight: 600; } +.db-remain-ok { color: var(--green-600); } +.db-remain-muted { color: var(--text-secondary); } + +.db-date--overdue { color: var(--red-600); font-weight: 600; } +.db-date--soon { color: var(--amber-600); font-weight: 500; } .db-table-loading { text-align: center; @@ -420,6 +487,10 @@ .db-skel-right span:first-child { width: 60px; } .db-skel-right span:last-child { width: 44px; } +@media (max-width: 1400px) { + .db-kpi-row { grid-template-columns: repeat(3, 1fr); } +} + @media (max-width: 1100px) { .db-kpi-row { grid-template-columns: repeat(2, 1fr); } } @@ -436,7 +507,7 @@ /* ── Large / Presentation Displays ── */ @media (min-width: 1600px) { .db-kpi-row { - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(6, 1fr); gap: var(--space-6); } .db-kpi-value { font-size: 2rem; } @@ -556,6 +627,16 @@ color: #fcd34d; } +:root[data-theme='dark'] .db-kpi-icon--red { + background: rgba(239, 68, 68, 0.15); + color: #fca5a5; +} + +:root[data-theme='dark'] .db-kpi-icon--purple { + background: rgba(167, 139, 250, 0.15); + color: #c4b5fd; +} + :root[data-theme='dark'] .logout-button { background-color: transparent; border-color: rgba(148, 163, 184, 0.2); diff --git a/src/styles/facturas.css b/src/styles/facturas.css index 17c6882..641b35e 100644 --- a/src/styles/facturas.css +++ b/src/styles/facturas.css @@ -229,6 +229,10 @@ color: var(--text-primary); } +.quarterly-footer-row td.quarterly-paid { color: var(--success); } +.quarterly-footer-row td.quarterly-pending { color: var(--danger); } +.quarterly-footer-row td.quarterly-zero { color: var(--text-secondary); } + .quarterly-loading, .quarterly-empty { padding: var(--space-8); text-align: center; @@ -514,6 +518,7 @@ .amount-positive { color: var(--primary); } .amount-paid { color: var(--success); } .amount-pending { color: var(--danger); } +.amount-muted { color: var(--text-secondary); } .invoice-actions { display: flex;