ajustes pedidos por juanjo

This commit is contained in:
Алекс 2026-05-24 23:56:50 +02:00
parent fa460b3ba8
commit 81f40f7d7f
24 changed files with 1067 additions and 212 deletions

8
.env
View File

@ -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

103
README.md Normal file
View File

@ -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.

11
ajustesDeJuanjo.txt Normal file
View File

@ -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)

View File

@ -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"
}
}

View File

@ -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: {}

View File

@ -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.

42
presentacion/que_subir.md Normal file
View File

@ -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.

View File

@ -65,7 +65,7 @@ export function InvoiceItem(invoice, onView) {
</td>
<td class="invoice-date">${formatDate(invoice.date)}</td>
<td class="invoice-total amount-positive">${formatCurrency(invoice.total)}</td>
<td class="invoice-remain ${parseFloat(invoice.remainToPay) > 0.009 ? 'amount-pending' : 'amount-paid'}">${formatCurrency(invoice.remainToPay)}</td>
<td class="invoice-remain ${invoice.status === 'paid' ? 'amount-muted' : 'amount-pending'}">${invoice.status === 'paid' ? '—' : formatCurrency(invoice.remainToPay)}</td>
<td class="invoice-actions">
<button class="btn-action btn-view" data-invoice-id="${invoice.id}" title="Ver/Editar detalles">
${icons.eye}

View File

@ -148,11 +148,11 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
</div>
<div class="form-group">
<label>Cliente ID</label>
<input
type="text"
name="clientId"
value="${invoice.clientId || ''}"
<label>Cliente</label>
<input
type="text"
name="clientId"
value="${invoice.clientName || invoice.clientId || ''}"
disabled
/>
</div>
@ -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(),

View File

@ -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'

View File

@ -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';

View File

@ -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 = '<p class="db-table-loading">Sin datos</p>'; 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 += `
<div class="quarterly-year-block">
<div class="quarterly-year-header">
<span class="quarterly-year-label">${year}</span>
<span class="quarterly-year-total">${fmt(tot.total)} · ${tot.count} facturas</span>
</div>
<table class="quarterly-table">
<thead><tr>
<th>Trimestre</th><th>Facturas</th><th>Base imponible</th>
<th>IVA</th><th>Total emitido</th><th>Cobrado</th><th>Pte. cobro</th>
</tr></thead>
<tbody>
${yr.map(r => `<tr>
<td class="quarterly-q-label">T${r.q} · <span>${qLabel(r.q)}</span></td>
<td class="quarterly-count">${r.count}</td>
<td class="quarterly-num">${fmt(r.ht)}</td>
<td class="quarterly-num quarterly-tax">${fmt(r.tax)}</td>
<td class="quarterly-num ${r.total > 0 ? 'quarterly-paid' : r.total < 0 ? 'quarterly-pending' : ''}">${fmt(r.total)}</td>
<td class="quarterly-num ${r.paid > 0 ? 'quarterly-paid' : r.paid < 0 ? 'quarterly-pending' : 'quarterly-zero'}"${r.paid < 0 ? ' title="Incluye facturas rectificativas (abonos)"' : ''}>${fmt(r.paid)}</td>
<td class="quarterly-num ${r.pending > 0.01 ? 'quarterly-pending' : 'quarterly-zero'}">${fmt(r.pending)}</td>
</tr>`).join('')}
</tbody>
<tfoot>
<tr class="quarterly-footer-row">
<td>Total ${year}</td><td>${tot.count}</td>
<td>${fmt(tot.ht)}</td><td>${fmt(tot.tax)}</td>
<td class="${tot.total > 0 ? 'quarterly-paid' : tot.total < 0 ? 'quarterly-pending' : ''}">${fmt(tot.total)}</td>
<td class="${tot.paid > 0 ? 'quarterly-paid' : tot.paid < 0 ? 'quarterly-pending' : 'quarterly-zero'}"${tot.paid < 0 ? ' title="Incluye facturas rectificativas (abonos)"' : ''}>${fmt(tot.paid)}</td>
<td class="${tot.pending > 0.01 ? 'quarterly-pending' : 'quarterly-zero'}">${fmt(tot.pending)}</td>
</tr>
</tfoot>
</table>
</div>`;
});
wrap.innerHTML = html;
}
function openInvoiceModal(invoiceId) {
const modal = InvoiceModal(invoiceId, null, () => { });
document.body.appendChild(modal);
@ -127,17 +216,35 @@ export function renderDashboard() {
<div class="db-kpi-card">
<div class="db-kpi-top">
<span class="db-kpi-label">Total facturado</span>
<span class="db-kpi-label">Total ventas</span>
<div class="db-kpi-icon db-kpi-icon--green">${icons.dollarSign}</div>
</div>
<div class="db-kpi-value" id="kpi-billed"><span class="db-kpi-skeleton"></span></div>
<div class="db-kpi-sub" id="kpi-billed-sub"><span class="db-kpi-skeleton db-kpi-skeleton-sm"></span></div>
<div class="db-kpi-value" id="kpi-ventas"><span class="db-kpi-skeleton"></span></div>
<div class="db-kpi-sub" id="kpi-ventas-sub"><span class="db-kpi-skeleton db-kpi-skeleton-sm"></span></div>
</div>
<div class="db-kpi-card">
<div class="db-kpi-top">
<span class="db-kpi-label">Total compras</span>
<div class="db-kpi-icon db-kpi-icon--red">${icons.dollarSign}</div>
</div>
<div class="db-kpi-value" id="kpi-compras"><span class="db-kpi-skeleton"></span></div>
<div class="db-kpi-sub" id="kpi-compras-sub"><span class="db-kpi-skeleton db-kpi-skeleton-sm"></span></div>
</div>
<div class="db-kpi-card">
<div class="db-kpi-top">
<span class="db-kpi-label">Balance neto</span>
<div class="db-kpi-icon db-kpi-icon--emerald">${icons.checkCircle}</div>
</div>
<div class="db-kpi-value" id="kpi-balance"><span class="db-kpi-skeleton"></span></div>
<div class="db-kpi-sub" id="kpi-balance-sub"><span class="db-kpi-skeleton db-kpi-skeleton-sm"></span></div>
</div>
<div class="db-kpi-card">
<div class="db-kpi-top">
<span class="db-kpi-label">Tasa de cobro</span>
<div class="db-kpi-icon db-kpi-icon--emerald">${icons.checkCircle}</div>
<div class="db-kpi-icon db-kpi-icon--purple">${icons.checkCircle}</div>
</div>
<div class="db-kpi-value" id="kpi-rate"><span class="db-kpi-skeleton"></span></div>
<div class="db-kpi-sub" id="kpi-rate-sub"><span class="db-kpi-skeleton db-kpi-skeleton-sm"></span></div>
@ -156,7 +263,7 @@ export function renderDashboard() {
<div class="db-mid-row">
<div class="db-chart-card">
<div class="db-card-header">
<span class="db-card-title">Facturación trimestral</span>
<span class="db-card-title">Balance trimestral</span>
<span class="db-card-year">${year}</span>
</div>
<div class="db-chart-body">
@ -166,7 +273,7 @@ export function renderDashboard() {
<div class="db-recent-card">
<div class="db-card-header">
<span class="db-card-title">Actividad reciente</span>
<span class="db-card-title">Últimas facturas</span>
<a href="#invoices" class="db-see-all">Ver todas</a>
</div>
<div class="db-recent-list" id="db-recent-list">
@ -182,7 +289,16 @@ export function renderDashboard() {
<div class="db-table-card">
<div class="db-card-header">
<span class="db-card-title">Últimas facturas</span>
<span class="db-card-title">Facturación trimestral</span>
</div>
<div class="db-table-wrap" id="db-quarterly-table-wrap">
<p class="db-table-loading">Cargando...</p>
</div>
</div>
<div class="db-table-card">
<div class="db-card-header">
<span class="db-card-title">Últimas facturas emitidas</span>
<a href="#invoices" class="db-see-all">Ver todas</a>
</div>
<div class="db-table-wrap">
@ -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() {
`<span class="db-sub-amber">${unpaid} pendientes</span>&nbsp;&middot;&nbsp;` +
`<span class="db-sub-muted">${draft} borrador</span>`;
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
? `<span class="db-sub-green">Resultado positivo</span>`
: `<span class="db-sub-red">Resultado negativo</span>`;
container.querySelector('#kpi-rate').textContent = `${paidRate}%`;
container.querySelector('#kpi-rate-sub').textContent = `${paid} de ${total} cobradas`;
@ -244,7 +376,7 @@ export function renderDashboard() {
: '<span class="db-sub-green">Sin importes pendientes</span>';
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 = `<div class="db-no-data">${icons.emptyInboxes || ''}No hay facturas recientes.</div>`;
recentList.innerHTML = `<div class="db-no-data">No hay facturas recientes.</div>`;
} else {
recentList.innerHTML = recent.map(inv => `
<div class="db-recent-item" data-id="${inv.id}" role="button" tabindex="0">
<div class="db-recent-dot ${statusDotClass(inv.status)}">
${icons.fileText}
recentList.innerHTML = recent.map(inv => {
const isVenta = inv._type === 'venta';
const typeBadge = isVenta
? `<span class="db-type-badge db-type-venta">V</span>`
: `<span class="db-type-badge db-type-compra">C</span>`;
return `
<div class="db-recent-item${isVenta ? '' : ' db-recent-compra'}" data-id="${inv.id}" data-type="${inv._type}" role="button" tabindex="0">
<div class="db-recent-dot ${statusDotClass(inv.status)}">
${icons.fileText}
</div>
<div class="db-recent-info">
<span class="db-recent-num">${typeBadge} ${escapeHtml(inv.number || `#${inv.id}`)}</span>
<span class="db-recent-client">${escapeHtml(inv._party || '—')}</span>
</div>
<div class="db-recent-right">
<span class="db-recent-amount">${formatCurrency(inv.total)}</span>
<span class="db-recent-date">${formatDate(inv.date)}</span>
</div>
<div class="db-recent-arrow">
${icons.chevron}
</div>
</div>
<div class="db-recent-info">
<span class="db-recent-num">${escapeHtml(inv.number || `#${inv.id}`)}</span>
<span class="db-recent-client">${escapeHtml(inv.clientName || '—')}</span>
</div>
<div class="db-recent-right">
<span class="db-recent-amount">${formatCurrency(inv.total)}</span>
<span class="db-recent-date">${formatDate(inv.date)}</span>
</div>
<div class="db-recent-arrow">
${icons.chevron}
</div>
</div>
`).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 = '<tr><td colspan="7" class="db-table-loading">No hay facturas</td></tr>';
} else {
tbody.innerHTML = tableRows.map(inv => `
<tr class="db-table-row" data-id="${inv.id}" role="button" tabindex="0">
<td class="db-cell-num">${escapeHtml(inv.number || `#${inv.id}`)}</td>
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 `
<tr class="db-table-row db-row--${inv.status || 'draft'}" data-id="${inv.id}" role="button" tabindex="0">
<td class="db-cell-num db-num--${inv.status || 'draft'}">${escapeHtml(inv.number || `#${inv.id}`)}</td>
<td class="db-cell-client">${escapeHtml(inv.clientName || '—')}</td>
<td>${getStatusBadge(inv.status)}</td>
<td>${formatDate(inv.date)}</td>
<td>${formatDate(inv.expireDate)}</td>
<td class="db-cell-right db-cell-num">${formatCurrency(inv.total)}</td>
<td class="db-cell-right db-cell-num ${(inv.remainToPay || 0) > 0 ? 'db-remain-due' : 'db-remain-ok'}">${formatCurrency(inv.remainToPay)}</td>
</tr>
`).join('');
<td class="${dateClass}">${formatDate(inv.expireDate)}</td>
<td class="db-cell-right db-cell-num ${(inv.total || 0) >= 0 ? 'db-total--pos' : 'db-total--neg'}">${formatCurrency(inv.total)}</td>
<td class="db-cell-right db-cell-num ${remainClass}">${inv.status === 'paid' || inv.status === 'draft' ? '—' : formatCurrency(inv.remainToPay)}</td>
</tr>`;
}).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;
}
}

View File

@ -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 += `
<div class="quarterly-year-block">
@ -473,15 +471,14 @@ export function renderFacturasPage() {
</thead>
<tbody>
${yearRows.map(r => {
const pending = r.total - r.paid;
return `<tr>
<td class="quarterly-q-label">T${r.q} · <span>${qLabel(r.q)}</span></td>
<td class="quarterly-count">${r.count}</td>
<td class="quarterly-num">${fmt(r.ht)}</td>
<td class="quarterly-num quarterly-tax">${fmt(r.tax)}</td>
<td class="quarterly-num ${r.total > 0 ? 'quarterly-total--pos' : ''}">${fmt(r.total)}</td>
<td class="quarterly-num ${r.paid > 0 ? 'quarterly-paid' : 'quarterly-zero'}">${fmt(r.paid)}</td>
<td class="quarterly-num ${pending > 0.01 ? 'quarterly-pending' : 'quarterly-zero'}">${fmt(pending)}</td>
<td class="quarterly-num ${r.total > 0 ? 'quarterly-paid' : r.total < 0 ? 'quarterly-pending' : ''}">${fmt(r.total)}</td>
<td class="quarterly-num ${r.paid > 0 ? 'quarterly-paid' : r.paid < 0 ? 'quarterly-pending' : 'quarterly-zero'}"${r.paid < 0 ? ' title="Incluye facturas rectificativas (abonos)"' : ''}>${fmt(r.paid)}</td>
<td class="quarterly-num ${r.pending > 0.01 ? 'quarterly-pending' : 'quarterly-zero'}">${fmt(r.pending)}</td>
</tr>`;
}).join('')}
</tbody>
@ -491,9 +488,9 @@ export function renderFacturasPage() {
<td>${totals.count}</td>
<td>${fmt(totals.ht)}</td>
<td>${fmt(totals.tax)}</td>
<td class="${totals.total > 0 ? 'quarterly-total--pos' : ''}">${fmt(totals.total)}</td>
<td class="${totals.paid > 0 ? 'quarterly-paid' : 'quarterly-zero'}">${fmt(totals.paid)}</td>
<td class="${totals.total - totals.paid > 0.01 ? 'quarterly-pending' : 'quarterly-zero'}">${fmt(totals.total - totals.paid)}</td>
<td class="${totals.total > 0 ? 'quarterly-paid' : totals.total < 0 ? 'quarterly-pending' : ''}">${fmt(totals.total)}</td>
<td class="${totals.paid > 0 ? 'quarterly-paid' : totals.paid < 0 ? 'quarterly-pending' : 'quarterly-zero'}"${totals.paid < 0 ? ' title="Incluye facturas rectificativas (abonos)"' : ''}>${fmt(totals.paid)}</td>
<td class="${totals.pending > 0.01 ? 'quarterly-pending' : 'quarterly-zero'}">${fmt(totals.pending)}</td>
</tr>
</tfoot>
</table>

View File

@ -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() {
<p id="webhook-status" style="font-size:0.8rem;color:var(--text-secondary);min-height:1.2em"></p>
</div>
</section>
<section class="settings-card" style="grid-column:1/-1">
<h2>VeriFactu</h2>
<p class="settings-help" style="margin-bottom:1rem">
Conexión con VeriFactu MidAPI para validar el enlace, registrar certificados y preparar el envío de facturas.
</p>
<div class="settings-row" style="flex-direction:column;align-items:stretch;gap:0.75rem">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:0.75rem;">
<input
type="text"
id="verifactu-cert-name"
placeholder="Nombre del certificado"
style="width:100%;padding:0.6rem 0.75rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary);font-size:0.9rem"
/>
<label style="display:flex;flex-direction:column;gap:0.25rem;font-size:0.85rem;color:var(--text-secondary)">
Archivo .p12
<input
type="file"
id="verifactu-cert-file"
accept=".p12"
style="padding:0.4rem 0;font-size:0.85rem;color:var(--text-primary)"
/>
</label>
</div>
<input
type="password"
id="verifactu-cert-password"
placeholder="Contraseña del certificado"
style="width:100%;padding:0.6rem 0.75rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary);font-size:0.9rem"
/>
<div style="display:flex;gap:0.75rem;justify-content:flex-end;flex-wrap:wrap">
<button type="button" class="btn-cancel-compact" id="verifactu-health-btn">Comprobar API</button>
<button type="button" class="btn-cancel-compact" id="verifactu-formats-btn">Ver formatos</button>
<button type="button" class="btn-submit-compact" id="verifactu-register-btn">Registrar certificado</button>
</div>
<p id="verifactu-status" style="font-size:0.8rem;color:var(--text-secondary);min-height:1.2em"></p>
</div>
</section>
</div>
`;
@ -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);

View File

@ -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);
}
return pagesRegistry.find(p => p.route === route);
}

View File

@ -1,19 +0,0 @@
export function renderTestPage() {
const container = document.createElement('div');
container.className = 'test-page';
container.innerHTML = /*html*/`
<h1>Test Page</h1>
<p>This is a test page to verify sidebar navigation functionality.</p>
<div class="test-content">
<p>The sidebar should be visible and allow navigation between different pages.</p>
<button id="test-btn" class="test-button">Test Button</button>
</div>
`;
container.querySelector('#test-btn').addEventListener('click', () => {
alert('Test button clicked!');
});
return container;
}

101
src/services/pagesConfig.js Normal file
View File

@ -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' },
];

View File

@ -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) {

94
src/services/verifactu.js Normal file
View File

@ -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);
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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);

View File

@ -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;