From 7c3b05f2dae87dddb3ca9d704539dc7a063e9c0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81?= Date: Fri, 29 May 2026 16:34:58 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20UI/UX=20=E2=80=94=20dashboard,=20tercer?= =?UTF-8?q?os,=20fact.=20proveedores=20y=20presentaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard: - Sección renombrada a "Últimos movimientos", ordena por fecha de actividad - Badges V/C con tooltip Venta/Compra - Importes negativos y pendientes en rojo, pagados en verde - Tabla "Últimas facturas emitidas": quita columna Pendiente, colores en Total, Vencimiento en rojo/ámbar si vencida o próxima Terceros (antes Clientes): - Renombrado en sidebar, búsqueda y mensajes - Campo Tipo (Cliente/Proveedor/Ambos) en formulario de creación y en tabla - Modal de detalle muestra "Información del Tercero" Fact. Proveedores: - Crear factura convertido a página completa (mismo estilo que Facturas) - Detalle convertido a modal appended en body con backdrop correcto - Líneas con total calculado en tiempo real - Guard de race condition en openDetail/refreshDetail - Clic en C del widget navega y abre detalle directamente InvoiceModal: - Totales con highlight verde (pagado) o rojo (pendiente) según estado CSS: - create-invoice.css: flex/overflow scoped a .create-invoice-page - dashboard.css: pendientes en rojo (era ámbar), especificidad db-total - modal.css: sup-modal-backdrop/box, total-row--highlight Presentación: - Slides Reveal.js del frontend en presentacion/index.html Co-Authored-By: Claude Sonnet 4.6 --- presentacion/index.html | 332 +++++++++++++++++++++++++++ src/components/ClientItem.js | 8 +- src/components/InvoiceModal.js | 13 +- src/pages/ClientesPage.js | 31 ++- src/pages/DashboardPage.js | 34 ++- src/pages/FacturasProveedoresPage.js | 329 +++++++++++++++----------- src/services/pagesConfig.js | 4 +- src/styles/create-invoice.css | 14 +- src/styles/dashboard.css | 24 +- src/styles/modal.css | 73 +++++- 10 files changed, 672 insertions(+), 190 deletions(-) create mode 100644 presentacion/index.html diff --git a/presentacion/index.html b/presentacion/index.html new file mode 100644 index 0000000..6137bf2 --- /dev/null +++ b/presentacion/index.html @@ -0,0 +1,332 @@ + + + + + + doli-front — Frontend SPA para Dolibarr + + + + + + +
+ + +
+

2DAM — Proyecto Grupal — 2026

+

doli-front

+

SPA Vanilla JS · Vite · Sin frameworks

+

El frontend que Dolibarr nunca tuvo

+
+ + +
+

Dolibarr tiene todo. Menos una interfaz decente.

+

El ERP funciona, pero su interfaz tiene 20 años. El objetivo: dar una experiencia moderna sin tocar el backend del ERP.

+
+
+

Antes

+
+ Interfaz PHP de 2004
Sin dark mode
Sin búsqueda en tiempo real
Sin notificaciones
Imposible de personalizar +
+
+
+

Ahora

+
+ SPA moderna con dark mode
Búsqueda y filtros reactivos
Notificaciones Teams / Slack
Asistente de voz integrado
Soporte VeriFactu +
+
+
+
+ + +
+

Sin React. Sin Vue. Sin Angular.

+

Una SPA no necesita un framework. Necesita un router, componentes y control del DOM. El proyecto tiene ~7.700 líneas de JS puro sin contar dependencias.

+
+
+

Control total

+

Cada elemento del DOM lo creamos nosotros. Sin magia, sin diffing invisible.

+
+
+

Dependencias mínimas

+

Solo 3: Chart.js (gráficas), @huggingface/transformers (voz) y node-forge (cifrado RSA).

+
+
+

Aprendizaje real

+

Entendemos qué hace el navegador. Un framework encima de esto es trivial; al revés, no tanto.

+
+
+
+ + +
+

Estructura del proyecto

+
+
src/ +├── pages/ ← render*() → HTMLElement +│ ├── DashboardPage.js +│ ├── FacturasPage.js +│ └── pagesRegistry.js ← auto-glob +├── components/ ← reutilizables +│ ├── InvoiceModal.js +│ └── Sidebar.js +├── services/ ← lógica +│ ├── apiClient.js ← fetch+JWT +│ └── pagesConfig.js +├── styles/ +├── main.js ← entrada +└── router.js ← hash routing
+
+
+

Una página = una función

+

render*() devuelve un HTMLElement.
Estado local en closure.
El router la monta y destruye.

+
+
+

Sin estado global

+

No hay store ni contexto.
Cada página habla con el BFF
de forma independiente.

+
+
+
+
+ + +
+

Routing sin servidor

+

El router escucha el hash de la URL. Navegar a #invoices desmonta la página actual y monta la nueva.

+
// router.js — lo esencial
+window.addEventListener('hashchange', () => {
+  const route = location.hash.replace('#', '') || 'dashboard';
+  const page  = getPageByRoute(route);
+
+  currentContainer?.cleanup?.();       // cancela timers e intervals
+  currentContainer = page.render();    // monta la nueva página
+  appRoot.replaceChildren(currentContainer);
+});
+
+

cleanup() evita memory leaks. Si una página tiene un setInterval (ej.: countdown del JWT en Configuración), se cancela al navegar.

+
+
+ + +
+

Añadir una página es crear un archivo

+

Con import.meta.glob de Vite, cualquier *Page.js en /pages se registra solo en el sidebar.

+
+
// pagesRegistry.js
+const modules = import.meta.glob(
+  './*Page.js', { eager: true }
+);
+
+// filtra las declaradas en pagesConfig
+// el resto se auto-registran con ruta
+// derivada del nombre del archivo
+
+
+

Páginas explícitas

+

En pagesConfig.js: ruta, nombre, icono, voice patterns.

+
+
+

Páginas nuevas

+

Crear MiPaginaPage.js → aparece en el sidebar con ruta #mi-pagina automáticamente.

+
+
+
+
+ + +
+

Lo que puede hacer el usuario

+
+
+

Dashboard

+

KPIs, gráfica trimestral, últimos movimientos, tabla con colores.

+
+
+

Facturas

+

CRUD completo. Plantillas, líneas editables, pagos, cambio de estado.

+
+
+

Fact. Proveedores

+

Gestión de compras. Total por línea en tiempo real.

+
+
+

Terceros

+

Clientes y proveedores con rol. Contactos anidados.

+
+
+

Banco

+

Cuentas bancarias y movimientos. Alta con selector de país.

+
+
+

Configuración

+

Tema, JWT countdown, webhook Teams/Slack, VeriFactu.

+
+
+
+ + +
+

Whisper en el navegador

+

El asistente de voz ejecuta Whisper base (Xenova/whisper-base) directamente en el navegador con @huggingface/transformers. Sin API key, sin servidor de voz.

+
+
Micrófono
+ ⟶ +
Web Audio API
+ ⟶ +
Whisper (WASM)
+ ⟶ +
Navegación
+
+
+

"Ir a facturas", "abrir banco", "dashboard" → el router navega sin tocar el teclado. Los patrones de voz se definen en pagesConfig.js junto a la ruta.

+
+
+ + +
+

Firma de facturas: VeriFactu

+

Flujo de tres pasos que ocurre en el navegador antes de llegar al servidor.

+
+
+

1 — Certificado

+

El usuario selecciona su .p12. La FileReader API lo convierte a base64 en el navegador. Nunca toca el disco del servidor.

+
+
+

2 — Contraseña cifrada

+

El BFF expone una clave pública RSA. El frontend cifra la contraseña con ella. Nunca viaja en claro.

+
+
+

3 — Registro

+

El BFF reenvía al microservicio Go, que valida el .p12, lo almacena y devuelve un token de sesión.

+
+
+
+ + +
+

Una sola función para hablar con el backend

+

Todo el tráfico HTTP pasa por apiClient.js. JWT automático, 401 redirige al login, errores normalizados.

+
// services/apiClient.js
+export async function apiGet(endpoint) {
+  const token = localStorage.getItem('token');
+  const res   = await fetch(BASE_URL + endpoint, {
+    headers: { Authorization: `Bearer ${token}` }
+  });
+  if (res.status === 401) { navigate('login'); return; }
+  if (!res.ok) throw new Error(await res.text());
+  return res.json();
+}
+
+

Las páginas nunca manejan tokens ni status HTTP. Solo llaman a apiGet(), apiPost()… y reciben datos o un error.

+
+
+ + +
+

Decisiones que valió la pena tomar

+
+
+

Hash routing sin servidor

+

Archivos estáticos. Funciona en cualquier CDN sin configurar rutas.

+
+
+

Cleanup en cada página

+

container.cleanup() cancela intervals al navegar. Sin memory leaks.

+
+
+

Vite + import.meta.glob

+

HMR en desarrollo. Cada página es un chunk en producción. Registro dinámico.

+
+
+

FileReader + RSA en cliente

+

Certificado y contraseña procesados en el navegador. Nunca viajan en claro.

+
+
+
+ + +
+

Preguntas

+

doli-front · Vanilla JS · Vite · 2026

+
+ +
+ + + + + + diff --git a/src/components/ClientItem.js b/src/components/ClientItem.js index 3b68f5f..d2a25b1 100644 --- a/src/components/ClientItem.js +++ b/src/components/ClientItem.js @@ -1,9 +1,13 @@ -// Helper para mostrar N/A si el valor es nulo/vacío/inválido function displayValue(value) { if (value === null || value === undefined || value === '' || value === 'null') return 'N/A'; return value; } +function roleLabel(role) { + const map = { client: 'Cliente', supplier: 'Proveedor', both: 'Ambos' }; + return map[role] || 'N/A'; +} + export function ClientItem(client, onView) { const fragment = document.createDocumentFragment(); @@ -59,7 +63,7 @@ export function ClientItem(client, onView) { ${displayValue(client.codeClient)} - ${displayValue(client.typentCode)} + ${roleLabel(client.role)} ${getStatusText(client.status)} diff --git a/src/components/InvoiceModal.js b/src/components/InvoiceModal.js index f28a1e3..7182790 100644 --- a/src/components/InvoiceModal.js +++ b/src/components/InvoiceModal.js @@ -269,14 +269,15 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { Total: ${formatCurrency(invoice.total)} - `} diff --git a/src/pages/ClientesPage.js b/src/pages/ClientesPage.js index 366a566..1be5449 100644 --- a/src/pages/ClientesPage.js +++ b/src/pages/ClientesPage.js @@ -16,16 +16,16 @@ export function renderClientesPage() { container.innerHTML = /*html*/`
-

Clientes

+

Terceros

${icons.search} - +
@@ -43,7 +43,7 @@ export function renderClientesPage() { - Cargando clientes... + Cargando terceros... @@ -110,7 +110,7 @@ export function renderClientesPage() { clientsList.innerHTML = ''; if (clients.length === 0) { - clientsList.innerHTML = `
${icons.emptyClients}

No hay clientes

Los clientes aparecerán aquí una vez añadidos.

`; + clientsList.innerHTML = `
${icons.emptyClients}

No hay terceros

Los terceros aparecerán aquí una vez añadidos.

`; return; } @@ -156,7 +156,7 @@ export function renderClientesPage() {
-

Información del Cliente

+

Información del Tercero

ID @@ -168,7 +168,7 @@ export function renderClientesPage() {
Tipo - ${escapeHtml(displayValue(client.typentCode))} + ${{ client: 'Cliente', supplier: 'Proveedor', both: 'Ambos' }[client.role] || 'N/A'}
Estado @@ -209,7 +209,7 @@ export function renderClientesPage() { overlay.innerHTML = /*html*/`
-

Nuevo cliente

+

Nuevo tercero

@@ -222,6 +222,14 @@ export function renderClientesPage() {
+
+ + +
@@ -324,6 +332,7 @@ export function renderClientesPage() { const payload = { Name: name, + Role: getData('role') || 'client', Email: getData('email'), Phone: getData('phone'), VatNumber: getData('vatNumber'), @@ -341,7 +350,7 @@ export function renderClientesPage() { try { await createClient(payload); - toast.success(`Cliente "${name}" creado correctamente.`); + toast.success(`Tercero "${name}" creado correctamente.`); close(); await loadAllClients(); } catch (err) { @@ -360,7 +369,7 @@ export function renderClientesPage() { const clientsList = container.querySelector('.clients-list'); try { - clientsList.innerHTML = 'Cargando clientes...'; + clientsList.innerHTML = 'Cargando terceros...'; const data = await getClients(1000, 1); @@ -370,7 +379,7 @@ export function renderClientesPage() { renderPage(1); } catch (error) { console.error('Error al cargar clientes:', error); - clientsList.innerHTML = `
${icons.error}

Error al cargar

No se pudieron cargar los clientes. Inténtalo de nuevo.

`; + clientsList.innerHTML = `
${icons.error}

Error al cargar

No se pudieron cargar los terceros. Inténtalo de nuevo.

`; } } diff --git a/src/pages/DashboardPage.js b/src/pages/DashboardPage.js index fc1b4b8..3f856d1 100644 --- a/src/pages/DashboardPage.js +++ b/src/pages/DashboardPage.js @@ -273,7 +273,7 @@ export function renderDashboard() {
- Últimas facturas + Últimos movimientos Ver todas
@@ -311,11 +311,10 @@ export function renderDashboard() { Fecha Vencimiento Total - Pendiente - Cargando... + Cargando...
@@ -458,6 +457,8 @@ export function renderDashboard() { }); // Combined recent list: ventas + compras, last 8 by date + const activityDate = inv => new Date(inv.dateModification || inv.dateCreation || inv.date || 0); + const ventasTagged = invoices.map(inv => ({ ...inv, _type: 'venta', _party: inv.clientName })); const comprasTagged = supplierInvoices.map(inv => ({ ...inv, @@ -467,8 +468,8 @@ export function renderDashboard() { })); const recent = [...ventasTagged, ...comprasTagged] - .sort((a, b) => new Date(b.date) - new Date(a.date)) - .slice(0, 8); + .sort((a, b) => activityDate(b) - activityDate(a)) + .slice(0, 10); const recentList = container.querySelector('#db-recent-list'); if (recent.length === 0) { @@ -477,8 +478,8 @@ export function renderDashboard() { recentList.innerHTML = recent.map(inv => { const isVenta = inv._type === 'venta'; const typeBadge = isVenta - ? `V` - : `C`; + ? `V` + : `C`; return `
@@ -489,8 +490,8 @@ export function renderDashboard() { ${escapeHtml(inv._party || '—')}
- ${formatCurrency(inv.total)} - ${formatDate(inv.date)} + ${formatCurrency(inv.total)} + ${formatDate(inv.dateModification || inv.dateCreation || inv.date)}
${icons.chevron} @@ -504,12 +505,12 @@ export function renderDashboard() { const type = el.dataset.type; el.addEventListener('click', () => { if (type === 'venta') openInvoiceModal(id); - else window.location.hash = '#supplier-invoices'; + else { window.__openSupplierInvoice = id; window.location.hash = '#facturas-proveedores'; } }); el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { if (type === 'venta') openInvoiceModal(id); - else window.location.hash = '#supplier-invoices'; + else { window.__openSupplierInvoice = id; window.location.hash = '#facturas-proveedores'; } } }); }); @@ -519,11 +520,11 @@ export function renderDashboard() { const tableRows = [...invoices] .sort((a, b) => new Date(b.date) - new Date(a.date)) - .slice(0, 15); + .slice(0, 25); const tbody = container.querySelector('#db-table-body'); if (tableRows.length === 0) { - tbody.innerHTML = 'No hay facturas'; + tbody.innerHTML = 'No hay facturas'; } else { const today = new Date(); today.setHours(0, 0, 0, 0); const in7 = new Date(today); in7.setDate(in7.getDate() + 7); @@ -537,10 +538,6 @@ export function renderDashboard() { else if (expDate <= in7) dateClass = 'db-date--soon'; } - const remainClass = inv.status === 'paid' ? 'db-remain-muted' - : inv.status === 'draft' ? 'db-remain-muted' - : 'db-remain-due'; - return ` ${escapeHtml(inv.number || `#${inv.id}`)} @@ -548,8 +545,7 @@ export function renderDashboard() { ${getStatusBadge(inv.status)} ${formatDate(inv.date)} ${formatDate(inv.expireDate)} - ${formatCurrency(inv.total)} - ${inv.status === 'paid' || inv.status === 'draft' ? '—' : formatCurrency(inv.remainToPay)} + ${formatCurrency(inv.total)} `; }).join(''); diff --git a/src/pages/FacturasProveedoresPage.js b/src/pages/FacturasProveedoresPage.js index f628cff..18312e1 100644 --- a/src/pages/FacturasProveedoresPage.js +++ b/src/pages/FacturasProveedoresPage.js @@ -112,70 +112,65 @@ export function renderFacturasProveedoresPage() {
- -