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)}
${icons.eye}
diff --git a/src/components/InvoiceModal.js b/src/components/InvoiceModal.js
index d998b79..f28a1e3 100644
--- a/src/components/InvoiceModal.js
+++ b/src/components/InvoiceModal.js
@@ -148,11 +148,11 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
- Cliente ID
- Cliente
+
@@ -940,6 +940,16 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
// Load invoice first so we have invoice.number for the documents endpoint
invoice = await getInvoiceById(invoiceId).catch(() => null);
+ // Resolve client name if BFF didn't return it
+ if (invoice && invoice.clientId && !invoice.clientName) {
+ try {
+ const clients = await apiGet(`/api/Clients?limit=500`);
+ const list = Array.isArray(clients) ? clients : clients.data || [];
+ const match = list.find(c => c.id === invoice.clientId);
+ if (match) invoice.clientName = match.name || match.fullName || match.label;
+ } catch { /* best-effort */ }
+ }
+
const [paymentsData, typesData, accountsData, documentsData] = await Promise.allSettled([
getPayments(invoiceId),
getPaymentTypes(),
diff --git a/src/main.js b/src/main.js
index a211338..3117418 100755
--- a/src/main.js
+++ b/src/main.js
@@ -1,4 +1,5 @@
import './style.css'
+import './styles/base.css'
import './styles/sidebar.css'
import './styles/login.css'
import './styles/dashboard.css'
@@ -9,6 +10,7 @@ import './styles/settings.css'
import './styles/voice-assistant.css'
import './styles/toast.css'
import './styles/banco.css'
+import './styles/modal.css'
import { initRouter } from './router.js'
import { initSessionManager } from './services/session.js'
import { initTheme } from './services/theme.js'
diff --git a/src/pages/BancoPage.js b/src/pages/BancoPage.js
index 67f5fcf..dff95df 100644
--- a/src/pages/BancoPage.js
+++ b/src/pages/BancoPage.js
@@ -1,3 +1,4 @@
+import { icons } from '../services/icons.js';
import { apiGet, apiPost, apiPut } from '../services/apiClient.js';
import { getCountries } from '../services/setup.js';
import { showToast } from '../services/toast.js';
diff --git a/src/pages/DashboardPage.js b/src/pages/DashboardPage.js
index 9d2b0b3..fc1b4b8 100644
--- a/src/pages/DashboardPage.js
+++ b/src/pages/DashboardPage.js
@@ -26,6 +26,11 @@ async function fetchAllInvoices() {
return Array.isArray(data) ? data : data.data || data.invoices || [];
}
+async function fetchAllSupplierInvoices() {
+ const data = await apiGet('/api/SupplierInvoices?limit=500&page=1');
+ return Array.isArray(data) ? data : data.data || data.invoices || [];
+}
+
function calcKPIs(invoices) {
const total = invoices.length;
const totalBilled = invoices.reduce((sum, inv) => sum + (inv.total || 0), 0);
@@ -37,18 +42,25 @@ function calcKPIs(invoices) {
return { total, totalBilled, paid, unpaid, draft, paidRate, pendingAmount };
}
-function calcQuarterly(invoices) {
+function calcQuarterlyBoth(ventas, compras) {
const year = new Date().getFullYear();
- const q = [0, 0, 0, 0];
- invoices.forEach(inv => {
- if (!inv.date || !inv.total) return;
- const d = new Date(inv.date);
- if (d.getFullYear() !== year) return;
- const m = d.getMonth();
- const idx = m <= 2 ? 0 : m <= 5 ? 1 : m <= 8 ? 2 : 3;
- q[idx] += inv.total;
- });
- return q;
+ const qv = [0, 0, 0, 0];
+ const qc = [0, 0, 0, 0];
+
+ const addTo = (arr, invoices) => {
+ invoices.forEach(inv => {
+ if (!inv.date || !inv.total) return;
+ const d = new Date(inv.date);
+ if (d.getFullYear() !== year) return;
+ const m = d.getMonth();
+ const idx = m <= 2 ? 0 : m <= 5 ? 1 : m <= 8 ? 2 : 3;
+ arr[idx] += inv.total;
+ });
+ };
+
+ addTo(qv, ventas);
+ addTo(qc, compras);
+ return { ventas: qv, compras: qc };
}
function formatCurrency(amount) {
@@ -90,6 +102,83 @@ function escapeHtml(str) {
return div.innerHTML;
}
+function buildQuarterlySummary(invoices) {
+ const quarters = {};
+ invoices.forEach(inv => {
+ if (!inv.date) return;
+ const d = new Date(inv.date);
+ const year = d.getFullYear();
+ const q = Math.floor(d.getMonth() / 3) + 1;
+ const key = `${year}-Q${q}`;
+ if (!quarters[key]) quarters[key] = { year, q, ht: 0, tax: 0, total: 0, paid: 0, pending: 0, count: 0 };
+ const ttc = parseFloat(inv.total) || 0;
+ quarters[key].ht += parseFloat(inv.totalHt) || 0;
+ quarters[key].tax += parseFloat(inv.totalTax) || 0;
+ quarters[key].total += ttc;
+ if (inv.status === 'paid') quarters[key].paid += ttc;
+ else if (inv.status === 'unpaid') quarters[key].pending += ttc;
+ quarters[key].count += 1;
+ });
+ return Object.values(quarters).sort((a, b) =>
+ b.year !== a.year ? b.year - a.year : b.q - a.q
+ );
+}
+
+function renderQuarterlyTable(wrap, invoices) {
+ const rows = buildQuarterlySummary(invoices);
+ if (rows.length === 0) { wrap.innerHTML = 'Sin datos
'; return; }
+
+ const fmt = n => new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(n);
+ const qLabel = q => [`1er`, `2º`, `3er`, `4º`][q - 1] + ` trimestre`;
+
+ const byYear = {};
+ rows.forEach(r => { if (!byYear[r.year]) byYear[r.year] = []; byYear[r.year].push(r); });
+
+ let html = '';
+ Object.keys(byYear).sort((a, b) => b - a).forEach(year => {
+ const yr = byYear[year];
+ const tot = yr.reduce((a, r) => ({
+ ht: a.ht + r.ht, tax: a.tax + r.tax, total: a.total + r.total,
+ paid: a.paid + r.paid, pending: a.pending + r.pending, count: a.count + r.count
+ }), { ht: 0, tax: 0, total: 0, paid: 0, pending: 0, count: 0 });
+
+ html += `
+
+
+
+
+ Trimestre Facturas Base imponible
+ IVA Total emitido Cobrado Pte. cobro
+
+
+ ${yr.map(r => `
+ T${r.q} · ${qLabel(r.q)}
+ ${r.count}
+ ${fmt(r.ht)}
+ ${fmt(r.tax)}
+ ${fmt(r.total)}
+ ${fmt(r.paid)}
+ ${fmt(r.pending)}
+ `).join('')}
+
+
+
+
+
+
`;
+ });
+ wrap.innerHTML = html;
+}
+
function openInvoiceModal(invoiceId) {
const modal = InvoiceModal(invoiceId, null, () => { });
document.body.appendChild(modal);
@@ -127,17 +216,35 @@ export function renderDashboard() {
-
Total facturado
+
Total ventas
${icons.dollarSign}
-
-
+
+
+
+
+
+
+
Total compras
+
${icons.dollarSign}
+
+
+
+
+
+
+
+
Balance neto
+
${icons.checkCircle}
+
+
+
Tasa de cobro
-
${icons.checkCircle}
+
${icons.checkCircle}
@@ -156,7 +263,7 @@ export function renderDashboard() {
@@ -166,7 +273,7 @@ export function renderDashboard() {
@@ -182,7 +289,16 @@ export function renderDashboard() {
+
+
+
@@ -213,8 +329,13 @@ export function renderDashboard() {
(async () => {
let invoices = [];
+ let supplierInvoices = [];
+
try {
- invoices = await fetchAllInvoices();
+ [invoices, supplierInvoices] = await Promise.all([
+ fetchAllInvoices(),
+ fetchAllSupplierInvoices(),
+ ]);
} catch (err) {
console.error('Dashboard: error al cargar facturas', err);
['#db-recent-list', '#db-table-body'].forEach(sel => {
@@ -224,6 +345,8 @@ export function renderDashboard() {
}
const { total, totalBilled, paid, unpaid, draft, paidRate, pendingAmount } = calcKPIs(invoices);
+ const totalCompras = supplierInvoices.reduce((s, inv) => s + (inv.total || 0), 0);
+ const balance = totalBilled - totalCompras;
container.querySelector('#kpi-total').textContent = total.toLocaleString('es-ES');
container.querySelector('#kpi-total-sub').innerHTML =
@@ -231,8 +354,17 @@ export function renderDashboard() {
`
${unpaid} pendientes · ` +
`
${draft} borrador `;
- container.querySelector('#kpi-billed').textContent = formatCurrency(totalBilled);
- container.querySelector('#kpi-billed-sub').textContent = `Acumulado ${year}`;
+ container.querySelector('#kpi-ventas').textContent = formatCurrency(totalBilled);
+ container.querySelector('#kpi-ventas-sub').textContent = `Acumulado ${year}`;
+
+ container.querySelector('#kpi-compras').textContent = formatCurrency(totalCompras);
+ container.querySelector('#kpi-compras-sub').textContent = `${supplierInvoices.length} facturas de proveedor`;
+
+ container.querySelector('#kpi-balance').textContent = formatCurrency(balance);
+ container.querySelector('#kpi-balance-sub').innerHTML =
+ balance >= 0
+ ? `
Resultado positivo `
+ : `
Resultado negativo `;
container.querySelector('#kpi-rate').textContent = `${paidRate}%`;
container.querySelector('#kpi-rate-sub').textContent = `${paid} de ${total} cobradas`;
@@ -244,7 +376,7 @@ export function renderDashboard() {
: '
Sin importes pendientes ';
const canvas = container.querySelector('#db-quarterly-chart');
- const q = calcQuarterly(invoices);
+ const { ventas: qv, compras: qc } = calcQuarterlyBoth(invoices, supplierInvoices);
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const chartGridColor = isDark ? 'rgba(148, 163, 184, 0.08)' : '#f3f4f6';
@@ -254,29 +386,51 @@ export function renderDashboard() {
type: 'bar',
data: {
labels: ['Q1 Ene–Mar', 'Q2 Abr–Jun', 'Q3 Jul–Sep', 'Q4 Oct–Dic'],
- datasets: [{
- label: 'Facturado',
- data: q,
- backgroundColor: 'rgba(37, 99, 235, 0.85)',
- hoverBackgroundColor: 'rgba(29, 78, 216, 1)',
- borderRadius: 6,
- borderSkipped: false,
- barPercentage: 0.5,
- }]
+ datasets: [
+ {
+ label: 'Ventas',
+ data: qv,
+ backgroundColor: 'rgba(37, 99, 235, 0.85)',
+ hoverBackgroundColor: 'rgba(29, 78, 216, 1)',
+ borderRadius: 4,
+ borderSkipped: false,
+ barPercentage: 0.7,
+ categoryPercentage: 0.8,
+ },
+ {
+ label: 'Compras',
+ data: qc,
+ backgroundColor: 'rgba(220, 38, 38, 0.75)',
+ hoverBackgroundColor: 'rgba(185, 28, 28, 1)',
+ borderRadius: 4,
+ borderSkipped: false,
+ barPercentage: 0.7,
+ categoryPercentage: 0.8,
+ }
+ ]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
- legend: { display: false },
+ legend: {
+ display: true,
+ position: 'top',
+ labels: {
+ color: chartTickColor,
+ font: { size: 12, family: 'Inter, system-ui, sans-serif' },
+ boxWidth: 12,
+ padding: 16,
+ }
+ },
tooltip: {
- callbacks: { label: ctx => ` ${formatCurrency(ctx.parsed.y)}` },
+ callbacks: { label: ctx => ` ${ctx.dataset.label}: ${formatCurrency(ctx.parsed.y)}` },
backgroundColor: isDark ? '#1e293b' : '#111827',
titleColor: '#f9fafb',
bodyColor: isDark ? '#cbd5e1' : '#d1d5db',
padding: 10,
cornerRadius: 6,
- displayColors: false,
+ displayColors: true,
}
},
scales: {
@@ -303,40 +457,66 @@ export function renderDashboard() {
}
});
- const recent = [...invoices]
+ // Combined recent list: ventas + compras, last 8 by date
+ const ventasTagged = invoices.map(inv => ({ ...inv, _type: 'venta', _party: inv.clientName }));
+ const comprasTagged = supplierInvoices.map(inv => ({
+ ...inv,
+ _type: 'compra',
+ _party: inv.supplierName,
+ number: inv.number || inv.supplierRef,
+ }));
+
+ const recent = [...ventasTagged, ...comprasTagged]
.sort((a, b) => new Date(b.date) - new Date(a.date))
.slice(0, 8);
const recentList = container.querySelector('#db-recent-list');
if (recent.length === 0) {
- recentList.innerHTML = `
${icons.emptyInboxes || ''}No hay facturas recientes.
`;
+ recentList.innerHTML = `
No hay facturas recientes.
`;
} else {
- recentList.innerHTML = recent.map(inv => `
-
-
- ${icons.fileText}
+ recentList.innerHTML = recent.map(inv => {
+ const isVenta = inv._type === 'venta';
+ const typeBadge = isVenta
+ ? `
V `
+ : `
C `;
+ return `
+
+
+ ${icons.fileText}
+
+
+ ${typeBadge} ${escapeHtml(inv.number || `#${inv.id}`)}
+ ${escapeHtml(inv._party || '—')}
+
+
+ ${formatCurrency(inv.total)}
+ ${formatDate(inv.date)}
+
+
+ ${icons.chevron}
+
-
- ${escapeHtml(inv.number || `#${inv.id}`)}
- ${escapeHtml(inv.clientName || '—')}
-
-
- ${formatCurrency(inv.total)}
- ${formatDate(inv.date)}
-
-
- ${icons.chevron}
-
-
- `).join('');
+ `;
+ }).join('');
recentList.querySelectorAll('.db-recent-item[data-id]').forEach(el => {
const id = parseInt(el.dataset.id);
- el.addEventListener('click', () => openInvoiceModal(id));
- el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') openInvoiceModal(id); });
+ const type = el.dataset.type;
+ el.addEventListener('click', () => {
+ if (type === 'venta') openInvoiceModal(id);
+ else window.location.hash = '#supplier-invoices';
+ });
+ el.addEventListener('keydown', e => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ if (type === 'venta') openInvoiceModal(id);
+ else window.location.hash = '#supplier-invoices';
+ }
+ });
});
}
+ renderQuarterlyTable(container.querySelector('#db-quarterly-table-wrap'), invoices);
+
const tableRows = [...invoices]
.sort((a, b) => new Date(b.date) - new Date(a.date))
.slice(0, 15);
@@ -345,17 +525,33 @@ export function renderDashboard() {
if (tableRows.length === 0) {
tbody.innerHTML = '
No hay facturas ';
} else {
- tbody.innerHTML = tableRows.map(inv => `
-
- ${escapeHtml(inv.number || `#${inv.id}`)}
+ const today = new Date(); today.setHours(0, 0, 0, 0);
+ const in7 = new Date(today); in7.setDate(in7.getDate() + 7);
+
+ tbody.innerHTML = tableRows.map(inv => {
+ const expDate = inv.expireDate ? new Date(inv.expireDate) : null;
+ const needsPayment = inv.status !== 'paid';
+ let dateClass = '';
+ if (expDate && needsPayment) {
+ if (expDate < today) dateClass = 'db-date--overdue';
+ else if (expDate <= in7) dateClass = 'db-date--soon';
+ }
+
+ const remainClass = inv.status === 'paid' ? 'db-remain-muted'
+ : inv.status === 'draft' ? 'db-remain-muted'
+ : 'db-remain-due';
+
+ return `
+
+ ${escapeHtml(inv.number || `#${inv.id}`)}
${escapeHtml(inv.clientName || '—')}
${getStatusBadge(inv.status)}
${formatDate(inv.date)}
- ${formatDate(inv.expireDate)}
- ${formatCurrency(inv.total)}
- ${formatCurrency(inv.remainToPay)}
-
- `).join('');
+
${formatDate(inv.expireDate)}
+
${formatCurrency(inv.total)}
+
${inv.status === 'paid' || inv.status === 'draft' ? '—' : formatCurrency(inv.remainToPay)}
+ `;
+ }).join('');
tbody.querySelectorAll('.db-table-row[data-id]').forEach(row => {
const id = parseInt(row.dataset.id);
@@ -366,4 +562,4 @@ export function renderDashboard() {
})();
return container;
-}
\ No newline at end of file
+}
diff --git a/src/pages/Facturas.js b/src/pages/FacturasPage.js
similarity index 94%
rename from src/pages/Facturas.js
rename to src/pages/FacturasPage.js
index 10af5ca..e9c4a48 100755
--- a/src/pages/Facturas.js
+++ b/src/pages/FacturasPage.js
@@ -406,18 +406,16 @@ export function renderFacturasPage() {
const q = Math.floor(d.getMonth() / 3) + 1;
const key = `${year}-Q${q}`;
- if (!quarters[key]) quarters[key] = { year, q, ht: 0, tax: 0, total: 0, paid: 0, count: 0 };
+ if (!quarters[key]) quarters[key] = { year, q, ht: 0, tax: 0, total: 0, paid: 0, pending: 0, count: 0 };
- const ht = parseFloat(inv.totalHt) || 0;
- const tax = parseFloat(inv.totalTax) || 0;
- const ttc = parseFloat(inv.total) || 0;
- const remain = parseFloat(inv.remainToPay) || 0;
+ const ttc = parseFloat(inv.total) || 0;
- quarters[key].ht += ht;
- quarters[key].tax += tax;
- quarters[key].total += ttc;
- quarters[key].paid += (ttc - remain);
- quarters[key].count += 1;
+ quarters[key].ht += parseFloat(inv.totalHt) || 0;
+ quarters[key].tax += parseFloat(inv.totalTax) || 0;
+ quarters[key].total += ttc;
+ if (inv.status === 'paid') quarters[key].paid += ttc;
+ else if (inv.status === 'unpaid') quarters[key].pending += ttc;
+ quarters[key].count += 1;
});
return Object.values(quarters).sort((a, b) =>
@@ -450,8 +448,8 @@ export function renderFacturasPage() {
const totals = yearRows.reduce((acc, r) => ({
ht: acc.ht + r.ht, tax: acc.tax + r.tax,
total: acc.total + r.total, paid: acc.paid + r.paid,
- count: acc.count + r.count
- }), { ht: 0, tax: 0, total: 0, paid: 0, count: 0 });
+ pending: acc.pending + r.pending, count: acc.count + r.count
+ }), { ht: 0, tax: 0, total: 0, paid: 0, pending: 0, count: 0 });
html += `
@@ -473,15 +471,14 @@ export function renderFacturasPage() {
${yearRows.map(r => {
- const pending = r.total - r.paid;
return `
T${r.q} · ${qLabel(r.q)}
${r.count}
${fmt(r.ht)}
${fmt(r.tax)}
- ${fmt(r.total)}
- ${fmt(r.paid)}
- ${fmt(pending)}
+ ${fmt(r.total)}
+ ${fmt(r.paid)}
+ ${fmt(r.pending)}
`;
}).join('')}
@@ -491,9 +488,9 @@ export function renderFacturasPage() {
${totals.count}
${fmt(totals.ht)}
${fmt(totals.tax)}
-
${fmt(totals.total)}
-
${fmt(totals.paid)}
-
${fmt(totals.total - totals.paid)}
+
${fmt(totals.total)}
+
${fmt(totals.paid)}
+
${fmt(totals.pending)}
diff --git a/src/pages/FacturasProveedores.js b/src/pages/FacturasProveedoresPage.js
similarity index 100%
rename from src/pages/FacturasProveedores.js
rename to src/pages/FacturasProveedoresPage.js
diff --git a/src/pages/SettingsPage.js b/src/pages/SettingsPage.js
index d92d3d3..8f656e7 100644
--- a/src/pages/SettingsPage.js
+++ b/src/pages/SettingsPage.js
@@ -2,6 +2,7 @@ import { isDarkTheme, toggleTheme } from '../services/theme.js';
import { icons } from '../services/icons.js';
import { apiGet, apiPut } from '../services/apiClient.js';
import { showToast } from '../services/toast.js';
+import { encryptVeriFactuPassword, formatVeriFactuError, getVeriFactuFormats, getVeriFactuHealth, registerVeriFactuCertificate } from '../services/verifactu.js';
function decodeTokenPayload(token) {
try {
@@ -121,6 +122,44 @@ export function renderSettingsPage() {
+
+
+ VeriFactu
+
+ Conexión con VeriFactu MidAPI para validar el enlace, registrar certificados y preparar el envío de facturas.
+
+
+
`;
@@ -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.
-
Test Button
-
- `;
-
- 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;