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
|
# API Configuration — apunta al BFF local
|
||||||
#_dev local: http://localhost:5269
|
# Desarrollo local: http://localhost:5269
|
||||||
#docker: http://localhost:5001
|
# Docker: http://localhost:5001
|
||||||
VITE_API_BASE_URL=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": {
|
"dependencies": {
|
||||||
"@huggingface/transformers": "^3.8.1",
|
"@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:
|
chart.js:
|
||||||
specifier: ^4.5.1
|
specifier: ^4.5.1
|
||||||
version: 4.5.1
|
version: 4.5.1
|
||||||
|
node-forge:
|
||||||
|
specifier: ^1.4.0
|
||||||
|
version: 1.4.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
vite:
|
vite:
|
||||||
specifier: ^7.2.4
|
specifier: ^7.2.4
|
||||||
|
|
@ -626,6 +629,10 @@ packages:
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
node-forge@1.4.0:
|
||||||
|
resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==}
|
||||||
|
engines: {node: '>= 6.13.0'}
|
||||||
|
|
||||||
object-keys@1.1.1:
|
object-keys@1.1.1:
|
||||||
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
|
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -1163,6 +1170,8 @@ snapshots:
|
||||||
|
|
||||||
nanoid@3.3.12: {}
|
nanoid@3.3.12: {}
|
||||||
|
|
||||||
|
node-forge@1.4.0: {}
|
||||||
|
|
||||||
object-keys@1.1.1: {}
|
object-keys@1.1.1: {}
|
||||||
|
|
||||||
onnxruntime-common@1.21.0: {}
|
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>
|
||||||
<td class="invoice-date">${formatDate(invoice.date)}</td>
|
<td class="invoice-date">${formatDate(invoice.date)}</td>
|
||||||
<td class="invoice-total amount-positive">${formatCurrency(invoice.total)}</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">
|
<td class="invoice-actions">
|
||||||
<button class="btn-action btn-view" data-invoice-id="${invoice.id}" title="Ver/Editar detalles">
|
<button class="btn-action btn-view" data-invoice-id="${invoice.id}" title="Ver/Editar detalles">
|
||||||
${icons.eye}
|
${icons.eye}
|
||||||
|
|
|
||||||
|
|
@ -148,11 +148,11 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Cliente ID</label>
|
<label>Cliente</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="clientId"
|
name="clientId"
|
||||||
value="${invoice.clientId || ''}"
|
value="${invoice.clientName || invoice.clientId || ''}"
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -940,6 +940,16 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
// Load invoice first so we have invoice.number for the documents endpoint
|
// Load invoice first so we have invoice.number for the documents endpoint
|
||||||
invoice = await getInvoiceById(invoiceId).catch(() => null);
|
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([
|
const [paymentsData, typesData, accountsData, documentsData] = await Promise.allSettled([
|
||||||
getPayments(invoiceId),
|
getPayments(invoiceId),
|
||||||
getPaymentTypes(),
|
getPaymentTypes(),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import './style.css'
|
import './style.css'
|
||||||
|
import './styles/base.css'
|
||||||
import './styles/sidebar.css'
|
import './styles/sidebar.css'
|
||||||
import './styles/login.css'
|
import './styles/login.css'
|
||||||
import './styles/dashboard.css'
|
import './styles/dashboard.css'
|
||||||
|
|
@ -9,6 +10,7 @@ import './styles/settings.css'
|
||||||
import './styles/voice-assistant.css'
|
import './styles/voice-assistant.css'
|
||||||
import './styles/toast.css'
|
import './styles/toast.css'
|
||||||
import './styles/banco.css'
|
import './styles/banco.css'
|
||||||
|
import './styles/modal.css'
|
||||||
import { initRouter } from './router.js'
|
import { initRouter } from './router.js'
|
||||||
import { initSessionManager } from './services/session.js'
|
import { initSessionManager } from './services/session.js'
|
||||||
import { initTheme } from './services/theme.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 { apiGet, apiPost, apiPut } from '../services/apiClient.js';
|
||||||
import { getCountries } from '../services/setup.js';
|
import { getCountries } from '../services/setup.js';
|
||||||
import { showToast } from '../services/toast.js';
|
import { showToast } from '../services/toast.js';
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,11 @@ async function fetchAllInvoices() {
|
||||||
return Array.isArray(data) ? data : data.data || data.invoices || [];
|
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) {
|
function calcKPIs(invoices) {
|
||||||
const total = invoices.length;
|
const total = invoices.length;
|
||||||
const totalBilled = invoices.reduce((sum, inv) => sum + (inv.total || 0), 0);
|
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 };
|
return { total, totalBilled, paid, unpaid, draft, paidRate, pendingAmount };
|
||||||
}
|
}
|
||||||
|
|
||||||
function calcQuarterly(invoices) {
|
function calcQuarterlyBoth(ventas, compras) {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const q = [0, 0, 0, 0];
|
const qv = [0, 0, 0, 0];
|
||||||
|
const qc = [0, 0, 0, 0];
|
||||||
|
|
||||||
|
const addTo = (arr, invoices) => {
|
||||||
invoices.forEach(inv => {
|
invoices.forEach(inv => {
|
||||||
if (!inv.date || !inv.total) return;
|
if (!inv.date || !inv.total) return;
|
||||||
const d = new Date(inv.date);
|
const d = new Date(inv.date);
|
||||||
if (d.getFullYear() !== year) return;
|
if (d.getFullYear() !== year) return;
|
||||||
const m = d.getMonth();
|
const m = d.getMonth();
|
||||||
const idx = m <= 2 ? 0 : m <= 5 ? 1 : m <= 8 ? 2 : 3;
|
const idx = m <= 2 ? 0 : m <= 5 ? 1 : m <= 8 ? 2 : 3;
|
||||||
q[idx] += inv.total;
|
arr[idx] += inv.total;
|
||||||
});
|
});
|
||||||
return q;
|
};
|
||||||
|
|
||||||
|
addTo(qv, ventas);
|
||||||
|
addTo(qc, compras);
|
||||||
|
return { ventas: qv, compras: qc };
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrency(amount) {
|
function formatCurrency(amount) {
|
||||||
|
|
@ -90,6 +102,83 @@ function escapeHtml(str) {
|
||||||
return div.innerHTML;
|
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) {
|
function openInvoiceModal(invoiceId) {
|
||||||
const modal = InvoiceModal(invoiceId, null, () => { });
|
const modal = InvoiceModal(invoiceId, null, () => { });
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
|
|
@ -127,17 +216,35 @@ export function renderDashboard() {
|
||||||
|
|
||||||
<div class="db-kpi-card">
|
<div class="db-kpi-card">
|
||||||
<div class="db-kpi-top">
|
<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 class="db-kpi-icon db-kpi-icon--green">${icons.dollarSign}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="db-kpi-value" id="kpi-billed"><span class="db-kpi-skeleton"></span></div>
|
<div class="db-kpi-value" id="kpi-ventas"><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-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>
|
||||||
|
|
||||||
<div class="db-kpi-card">
|
<div class="db-kpi-card">
|
||||||
<div class="db-kpi-top">
|
<div class="db-kpi-top">
|
||||||
<span class="db-kpi-label">Tasa de cobro</span>
|
<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>
|
||||||
<div class="db-kpi-value" id="kpi-rate"><span class="db-kpi-skeleton"></span></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>
|
<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-mid-row">
|
||||||
<div class="db-chart-card">
|
<div class="db-chart-card">
|
||||||
<div class="db-card-header">
|
<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>
|
<span class="db-card-year">${year}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="db-chart-body">
|
<div class="db-chart-body">
|
||||||
|
|
@ -166,7 +273,7 @@ export function renderDashboard() {
|
||||||
|
|
||||||
<div class="db-recent-card">
|
<div class="db-recent-card">
|
||||||
<div class="db-card-header">
|
<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>
|
<a href="#invoices" class="db-see-all">Ver todas</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="db-recent-list" id="db-recent-list">
|
<div class="db-recent-list" id="db-recent-list">
|
||||||
|
|
@ -182,7 +289,16 @@ export function renderDashboard() {
|
||||||
|
|
||||||
<div class="db-table-card">
|
<div class="db-table-card">
|
||||||
<div class="db-card-header">
|
<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>
|
<a href="#invoices" class="db-see-all">Ver todas</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="db-table-wrap">
|
<div class="db-table-wrap">
|
||||||
|
|
@ -213,8 +329,13 @@ export function renderDashboard() {
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
let invoices = [];
|
let invoices = [];
|
||||||
|
let supplierInvoices = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
invoices = await fetchAllInvoices();
|
[invoices, supplierInvoices] = await Promise.all([
|
||||||
|
fetchAllInvoices(),
|
||||||
|
fetchAllSupplierInvoices(),
|
||||||
|
]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Dashboard: error al cargar facturas', err);
|
console.error('Dashboard: error al cargar facturas', err);
|
||||||
['#db-recent-list', '#db-table-body'].forEach(sel => {
|
['#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 { 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').textContent = total.toLocaleString('es-ES');
|
||||||
container.querySelector('#kpi-total-sub').innerHTML =
|
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-amber">${unpaid} pendientes</span> · ` +
|
||||||
`<span class="db-sub-muted">${draft} borrador</span>`;
|
`<span class="db-sub-muted">${draft} borrador</span>`;
|
||||||
|
|
||||||
container.querySelector('#kpi-billed').textContent = formatCurrency(totalBilled);
|
container.querySelector('#kpi-ventas').textContent = formatCurrency(totalBilled);
|
||||||
container.querySelector('#kpi-billed-sub').textContent = `Acumulado ${year}`;
|
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').textContent = `${paidRate}%`;
|
||||||
container.querySelector('#kpi-rate-sub').textContent = `${paid} de ${total} cobradas`;
|
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>';
|
: '<span class="db-sub-green">Sin importes pendientes</span>';
|
||||||
|
|
||||||
const canvas = container.querySelector('#db-quarterly-chart');
|
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 isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||||
const chartGridColor = isDark ? 'rgba(148, 163, 184, 0.08)' : '#f3f4f6';
|
const chartGridColor = isDark ? 'rgba(148, 163, 184, 0.08)' : '#f3f4f6';
|
||||||
|
|
@ -254,29 +386,51 @@ export function renderDashboard() {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
labels: ['Q1 Ene–Mar', 'Q2 Abr–Jun', 'Q3 Jul–Sep', 'Q4 Oct–Dic'],
|
labels: ['Q1 Ene–Mar', 'Q2 Abr–Jun', 'Q3 Jul–Sep', 'Q4 Oct–Dic'],
|
||||||
datasets: [{
|
datasets: [
|
||||||
label: 'Facturado',
|
{
|
||||||
data: q,
|
label: 'Ventas',
|
||||||
|
data: qv,
|
||||||
backgroundColor: 'rgba(37, 99, 235, 0.85)',
|
backgroundColor: 'rgba(37, 99, 235, 0.85)',
|
||||||
hoverBackgroundColor: 'rgba(29, 78, 216, 1)',
|
hoverBackgroundColor: 'rgba(29, 78, 216, 1)',
|
||||||
borderRadius: 6,
|
borderRadius: 4,
|
||||||
borderSkipped: false,
|
borderSkipped: false,
|
||||||
barPercentage: 0.5,
|
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: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
plugins: {
|
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: {
|
tooltip: {
|
||||||
callbacks: { label: ctx => ` ${formatCurrency(ctx.parsed.y)}` },
|
callbacks: { label: ctx => ` ${ctx.dataset.label}: ${formatCurrency(ctx.parsed.y)}` },
|
||||||
backgroundColor: isDark ? '#1e293b' : '#111827',
|
backgroundColor: isDark ? '#1e293b' : '#111827',
|
||||||
titleColor: '#f9fafb',
|
titleColor: '#f9fafb',
|
||||||
bodyColor: isDark ? '#cbd5e1' : '#d1d5db',
|
bodyColor: isDark ? '#cbd5e1' : '#d1d5db',
|
||||||
padding: 10,
|
padding: 10,
|
||||||
cornerRadius: 6,
|
cornerRadius: 6,
|
||||||
displayColors: false,
|
displayColors: true,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
|
|
@ -303,22 +457,36 @@ 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))
|
.sort((a, b) => new Date(b.date) - new Date(a.date))
|
||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
|
|
||||||
const recentList = container.querySelector('#db-recent-list');
|
const recentList = container.querySelector('#db-recent-list');
|
||||||
if (recent.length === 0) {
|
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 {
|
} else {
|
||||||
recentList.innerHTML = recent.map(inv => `
|
recentList.innerHTML = recent.map(inv => {
|
||||||
<div class="db-recent-item" data-id="${inv.id}" role="button" tabindex="0">
|
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)}">
|
<div class="db-recent-dot ${statusDotClass(inv.status)}">
|
||||||
${icons.fileText}
|
${icons.fileText}
|
||||||
</div>
|
</div>
|
||||||
<div class="db-recent-info">
|
<div class="db-recent-info">
|
||||||
<span class="db-recent-num">${escapeHtml(inv.number || `#${inv.id}`)}</span>
|
<span class="db-recent-num">${typeBadge} ${escapeHtml(inv.number || `#${inv.id}`)}</span>
|
||||||
<span class="db-recent-client">${escapeHtml(inv.clientName || '—')}</span>
|
<span class="db-recent-client">${escapeHtml(inv._party || '—')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="db-recent-right">
|
<div class="db-recent-right">
|
||||||
<span class="db-recent-amount">${formatCurrency(inv.total)}</span>
|
<span class="db-recent-amount">${formatCurrency(inv.total)}</span>
|
||||||
|
|
@ -328,15 +496,27 @@ export function renderDashboard() {
|
||||||
${icons.chevron}
|
${icons.chevron}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
recentList.querySelectorAll('.db-recent-item[data-id]').forEach(el => {
|
recentList.querySelectorAll('.db-recent-item[data-id]').forEach(el => {
|
||||||
const id = parseInt(el.dataset.id);
|
const id = parseInt(el.dataset.id);
|
||||||
el.addEventListener('click', () => openInvoiceModal(id));
|
const type = el.dataset.type;
|
||||||
el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') openInvoiceModal(id); });
|
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]
|
const tableRows = [...invoices]
|
||||||
.sort((a, b) => new Date(b.date) - new Date(a.date))
|
.sort((a, b) => new Date(b.date) - new Date(a.date))
|
||||||
.slice(0, 15);
|
.slice(0, 15);
|
||||||
|
|
@ -345,17 +525,33 @@ export function renderDashboard() {
|
||||||
if (tableRows.length === 0) {
|
if (tableRows.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="7" class="db-table-loading">No hay facturas</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="7" class="db-table-loading">No hay facturas</td></tr>';
|
||||||
} else {
|
} else {
|
||||||
tbody.innerHTML = tableRows.map(inv => `
|
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||||
<tr class="db-table-row" data-id="${inv.id}" role="button" tabindex="0">
|
const in7 = new Date(today); in7.setDate(in7.getDate() + 7);
|
||||||
<td class="db-cell-num">${escapeHtml(inv.number || `#${inv.id}`)}</td>
|
|
||||||
|
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 class="db-cell-client">${escapeHtml(inv.clientName || '—')}</td>
|
||||||
<td>${getStatusBadge(inv.status)}</td>
|
<td>${getStatusBadge(inv.status)}</td>
|
||||||
<td>${formatDate(inv.date)}</td>
|
<td>${formatDate(inv.date)}</td>
|
||||||
<td>${formatDate(inv.expireDate)}</td>
|
<td class="${dateClass}">${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.total || 0) >= 0 ? 'db-total--pos' : 'db-total--neg'}">${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>
|
<td class="db-cell-right db-cell-num ${remainClass}">${inv.status === 'paid' || inv.status === 'draft' ? '—' : formatCurrency(inv.remainToPay)}</td>
|
||||||
</tr>
|
</tr>`;
|
||||||
`).join('');
|
}).join('');
|
||||||
|
|
||||||
tbody.querySelectorAll('.db-table-row[data-id]').forEach(row => {
|
tbody.querySelectorAll('.db-table-row[data-id]').forEach(row => {
|
||||||
const id = parseInt(row.dataset.id);
|
const id = parseInt(row.dataset.id);
|
||||||
|
|
|
||||||
|
|
@ -406,17 +406,15 @@ export function renderFacturasPage() {
|
||||||
const q = Math.floor(d.getMonth() / 3) + 1;
|
const q = Math.floor(d.getMonth() / 3) + 1;
|
||||||
const key = `${year}-Q${q}`;
|
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 ttc = parseFloat(inv.total) || 0;
|
||||||
const remain = parseFloat(inv.remainToPay) || 0;
|
|
||||||
|
|
||||||
quarters[key].ht += ht;
|
quarters[key].ht += parseFloat(inv.totalHt) || 0;
|
||||||
quarters[key].tax += tax;
|
quarters[key].tax += parseFloat(inv.totalTax) || 0;
|
||||||
quarters[key].total += ttc;
|
quarters[key].total += ttc;
|
||||||
quarters[key].paid += (ttc - remain);
|
if (inv.status === 'paid') quarters[key].paid += ttc;
|
||||||
|
else if (inv.status === 'unpaid') quarters[key].pending += ttc;
|
||||||
quarters[key].count += 1;
|
quarters[key].count += 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -450,8 +448,8 @@ export function renderFacturasPage() {
|
||||||
const totals = yearRows.reduce((acc, r) => ({
|
const totals = yearRows.reduce((acc, r) => ({
|
||||||
ht: acc.ht + r.ht, tax: acc.tax + r.tax,
|
ht: acc.ht + r.ht, tax: acc.tax + r.tax,
|
||||||
total: acc.total + r.total, paid: acc.paid + r.paid,
|
total: acc.total + r.total, paid: acc.paid + r.paid,
|
||||||
count: acc.count + r.count
|
pending: acc.pending + r.pending, count: acc.count + r.count
|
||||||
}), { ht: 0, tax: 0, total: 0, paid: 0, count: 0 });
|
}), { ht: 0, tax: 0, total: 0, paid: 0, pending: 0, count: 0 });
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="quarterly-year-block">
|
<div class="quarterly-year-block">
|
||||||
|
|
@ -473,15 +471,14 @@ export function renderFacturasPage() {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${yearRows.map(r => {
|
${yearRows.map(r => {
|
||||||
const pending = r.total - r.paid;
|
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td class="quarterly-q-label">T${r.q} · <span>${qLabel(r.q)}</span></td>
|
<td class="quarterly-q-label">T${r.q} · <span>${qLabel(r.q)}</span></td>
|
||||||
<td class="quarterly-count">${r.count}</td>
|
<td class="quarterly-count">${r.count}</td>
|
||||||
<td class="quarterly-num">${fmt(r.ht)}</td>
|
<td class="quarterly-num">${fmt(r.ht)}</td>
|
||||||
<td class="quarterly-num quarterly-tax">${fmt(r.tax)}</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.total > 0 ? 'quarterly-paid' : r.total < 0 ? 'quarterly-pending' : ''}">${fmt(r.total)}</td>
|
||||||
<td class="quarterly-num ${r.paid > 0 ? 'quarterly-paid' : 'quarterly-zero'}">${fmt(r.paid)}</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 ${pending > 0.01 ? 'quarterly-pending' : 'quarterly-zero'}">${fmt(pending)}</td>
|
<td class="quarterly-num ${r.pending > 0.01 ? 'quarterly-pending' : 'quarterly-zero'}">${fmt(r.pending)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('')}
|
}).join('')}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -491,9 +488,9 @@ export function renderFacturasPage() {
|
||||||
<td>${totals.count}</td>
|
<td>${totals.count}</td>
|
||||||
<td>${fmt(totals.ht)}</td>
|
<td>${fmt(totals.ht)}</td>
|
||||||
<td>${fmt(totals.tax)}</td>
|
<td>${fmt(totals.tax)}</td>
|
||||||
<td class="${totals.total > 0 ? 'quarterly-total--pos' : ''}">${fmt(totals.total)}</td>
|
<td class="${totals.total > 0 ? 'quarterly-paid' : totals.total < 0 ? 'quarterly-pending' : ''}">${fmt(totals.total)}</td>
|
||||||
<td class="${totals.paid > 0 ? 'quarterly-paid' : 'quarterly-zero'}">${fmt(totals.paid)}</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.total - totals.paid > 0.01 ? 'quarterly-pending' : 'quarterly-zero'}">${fmt(totals.total - totals.paid)}</td>
|
<td class="${totals.pending > 0.01 ? 'quarterly-pending' : 'quarterly-zero'}">${fmt(totals.pending)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
|
@ -2,6 +2,7 @@ import { isDarkTheme, toggleTheme } from '../services/theme.js';
|
||||||
import { icons } from '../services/icons.js';
|
import { icons } from '../services/icons.js';
|
||||||
import { apiGet, apiPut } from '../services/apiClient.js';
|
import { apiGet, apiPut } from '../services/apiClient.js';
|
||||||
import { showToast } from '../services/toast.js';
|
import { showToast } from '../services/toast.js';
|
||||||
|
import { encryptVeriFactuPassword, formatVeriFactuError, getVeriFactuFormats, getVeriFactuHealth, registerVeriFactuCertificate } from '../services/verifactu.js';
|
||||||
|
|
||||||
function decodeTokenPayload(token) {
|
function decodeTokenPayload(token) {
|
||||||
try {
|
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>
|
<p id="webhook-status" style="font-size:0.8rem;color:var(--text-secondary);min-height:1.2em"></p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -159,6 +198,20 @@ export function renderSettingsPage() {
|
||||||
// Webhook config
|
// Webhook config
|
||||||
const webhookInput = container.querySelector('#webhook-url-input');
|
const webhookInput = container.querySelector('#webhook-url-input');
|
||||||
const webhookStatus = container.querySelector('#webhook-status');
|
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() {
|
async function loadWebhookUrl() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -188,6 +241,110 @@ export function renderSettingsPage() {
|
||||||
webhookInput.value = '';
|
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();
|
loadWebhookUrl();
|
||||||
|
|
||||||
container.cleanup = () => clearInterval(intervalId);
|
container.cleanup = () => clearInterval(intervalId);
|
||||||
|
|
|
||||||
|
|
@ -1,98 +1,48 @@
|
||||||
import { renderDashboard } from './DashboardPage.js';
|
import { pagesConfig } from '../services/pagesConfig.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 { icons } from '../services/icons.js';
|
import { icons } from '../services/icons.js';
|
||||||
|
|
||||||
export const pagesRegistry = [
|
const modules = import.meta.glob('./*Page.js', { eager: true });
|
||||||
{
|
const knownFiles = new Set(pagesConfig.map(p => p._file).filter(Boolean));
|
||||||
route: 'dashboard',
|
|
||||||
name: 'Dashboard',
|
function toRoute(path) {
|
||||||
icon: icons.dashboard,
|
return path.replace('./', '').replace('Page.js', '')
|
||||||
voicePatterns: ['dashboard', 'inicio', 'ir al inicio', 'ir al dashboard', 'resumen', 'home', 'panel', 'principal'],
|
.replace(/([A-Z])/g, (c, l, i) => (i ? '-' : '') + l.toLowerCase());
|
||||||
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
|
|
||||||
}
|
}
|
||||||
];
|
|
||||||
|
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) {
|
export function getAvailablePages(isAuthenticated) {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) return [];
|
||||||
return [];
|
return pagesRegistry.filter(p => p.showInSidebar && (!p.requiresAuth || isAuthenticated));
|
||||||
}
|
|
||||||
return pagesRegistry.filter(page => {
|
|
||||||
if (page.requiresAuth && !isAuthenticated) return false;
|
|
||||||
return page.showInSidebar;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPageByRoute(route) {
|
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 userKey = getUserThemeKey();
|
||||||
const saved = userKey ? localStorage.getItem(userKey) : localStorage.getItem(THEME_KEY);
|
const saved = userKey ? localStorage.getItem(userKey) : localStorage.getItem(THEME_KEY);
|
||||||
if (saved === 'dark' || saved === 'light') return saved;
|
if (saved === 'dark' || saved === 'light') return saved;
|
||||||
return getSystemPrefersDark() ? 'dark' : 'light';
|
return 'light';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyTheme(theme) {
|
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;
|
line-height: 1.6;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
color: #1e293b;
|
color: var(--text-primary);
|
||||||
background-color: #f8fafc;
|
background-color: var(--bg-page);
|
||||||
|
|
||||||
--primary: #2563eb;
|
--primary: #2563eb;
|
||||||
--primary-hover: #1d4ed8;
|
--primary-hover: #1d4ed8;
|
||||||
|
|
@ -17,6 +17,9 @@
|
||||||
--warning-hover: #a16207;
|
--warning-hover: #a16207;
|
||||||
--danger: #dc2626;
|
--danger: #dc2626;
|
||||||
--danger-hover: #b91c1c;
|
--danger-hover: #b91c1c;
|
||||||
|
--green-600: #16a34a;
|
||||||
|
--amber-600: #d97706;
|
||||||
|
--red-600: #dc2626;
|
||||||
--card-bg: #ffffff;
|
--card-bg: #ffffff;
|
||||||
--border-color: #e2e8f0;
|
--border-color: #e2e8f0;
|
||||||
--border-subtle: #f1f5f9;
|
--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 */
|
so the browser renders options with the correct light/dark contrast */
|
||||||
:root[data-theme='dark'] {
|
:root[data-theme='dark'] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
--green-600: #86efac;
|
||||||
|
--amber-600: #fcd34d;
|
||||||
|
--red-600: #fca5a5;
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,11 @@
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-type {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.client-actions {
|
.client-actions {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
@ -133,6 +138,14 @@
|
||||||
|
|
||||||
.clients-table td.client-actions .btn-view {
|
.clients-table td.client-actions .btn-view {
|
||||||
margin: 0 auto;
|
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 {
|
.status-badge-client {
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
|
|
||||||
.db-kpi-row {
|
.db-kpi-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(6, 1fr);
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
margin-bottom: var(--space-5);
|
margin-bottom: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|
@ -86,6 +86,40 @@
|
||||||
color: var(--amber-600);
|
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 {
|
.db-kpi-label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
@ -331,9 +365,38 @@
|
||||||
.db-table-row:last-child td { border-bottom: none; }
|
.db-table-row:last-child td { border-bottom: none; }
|
||||||
.db-table-row:hover td { background: var(--gray-50); }
|
.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); }
|
.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 {
|
.db-cell-num {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
@ -350,6 +413,10 @@
|
||||||
.db-cell-right { text-align: right; }
|
.db-cell-right { text-align: right; }
|
||||||
.db-remain-due { color: var(--red-600); font-weight: 600; }
|
.db-remain-due { color: var(--red-600); font-weight: 600; }
|
||||||
.db-remain-ok { color: var(--green-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 {
|
.db-table-loading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
@ -420,6 +487,10 @@
|
||||||
.db-skel-right span:first-child { width: 60px; }
|
.db-skel-right span:first-child { width: 60px; }
|
||||||
.db-skel-right span:last-child { width: 44px; }
|
.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) {
|
@media (max-width: 1100px) {
|
||||||
.db-kpi-row { grid-template-columns: repeat(2, 1fr); }
|
.db-kpi-row { grid-template-columns: repeat(2, 1fr); }
|
||||||
}
|
}
|
||||||
|
|
@ -436,7 +507,7 @@
|
||||||
/* ── Large / Presentation Displays ── */
|
/* ── Large / Presentation Displays ── */
|
||||||
@media (min-width: 1600px) {
|
@media (min-width: 1600px) {
|
||||||
.db-kpi-row {
|
.db-kpi-row {
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(6, 1fr);
|
||||||
gap: var(--space-6);
|
gap: var(--space-6);
|
||||||
}
|
}
|
||||||
.db-kpi-value { font-size: 2rem; }
|
.db-kpi-value { font-size: 2rem; }
|
||||||
|
|
@ -556,6 +627,16 @@
|
||||||
color: #fcd34d;
|
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 {
|
:root[data-theme='dark'] .logout-button {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border-color: rgba(148, 163, 184, 0.2);
|
border-color: rgba(148, 163, 184, 0.2);
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,10 @@
|
||||||
color: var(--text-primary);
|
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 {
|
.quarterly-loading, .quarterly-empty {
|
||||||
padding: var(--space-8);
|
padding: var(--space-8);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
@ -514,6 +518,7 @@
|
||||||
.amount-positive { color: var(--primary); }
|
.amount-positive { color: var(--primary); }
|
||||||
.amount-paid { color: var(--success); }
|
.amount-paid { color: var(--success); }
|
||||||
.amount-pending { color: var(--danger); }
|
.amount-pending { color: var(--danger); }
|
||||||
|
.amount-muted { color: var(--text-secondary); }
|
||||||
|
|
||||||
.invoice-actions {
|
.invoice-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue