ajustes pedidos por juanjo
This commit is contained in:
parent
fa460b3ba8
commit
81f40f7d7f
8
.env
8
.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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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> · ` +
|
||||
`<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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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' },
|
||||
];
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue