feat: UI/UX — dashboard, terceros, fact. proveedores y presentación

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 <noreply@anthropic.com>
This commit is contained in:
Алекс 2026-05-29 16:34:58 +02:00
parent 81f40f7d7f
commit 7c3b05f2da
10 changed files with 672 additions and 190 deletions

332
presentacion/index.html Normal file
View File

@ -0,0 +1,332 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>doli-front — Frontend SPA para Dolibarr</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/theme/black.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/plugin/highlight/monokai.css">
<style>
.reveal h1,.reveal h2,.reveal h3{text-transform:none;letter-spacing:0}
.reveal section{padding:18px 42px;box-sizing:border-box;overflow:hidden}
.reveal h1{font-size:1.6em;font-weight:800;margin-bottom:.1em}
.reveal h2{font-size:1.05em;margin-bottom:.4em;color:#fff;font-weight:700}
.reveal p{font-size:.76em;line-height:1.5;margin:.3em 0}
.reveal ul{font-size:.74em;line-height:1.6;margin:.2em 0 .2em 1.2em}
.reveal pre{width:100%;margin:.4em 0;border-radius:5px}
.reveal pre code{font-size:.58em;line-height:1.2}
body,.reveal{background:#111;color:#eee}
.cols{display:flex;gap:12px;align-items:stretch}
.cols>*{flex:1}
.center{text-align:center}
.card{
background:#181818;
border:1px solid #333;
border-radius:6px;
padding:10px 14px;
margin:3px 0;
}
.callout{
background:#121c14;
border:1px solid #3fb95044;
padding:10px 16px;
border-radius:6px;
margin:.5em 0;
}
.callout p{margin:0;font-size:.73em;line-height:1.45}
.flow{display:flex;align-items:center;justify-content:center;gap:12px;margin:.7em 0;font-size:.8em}
.flow-box{padding:10px 18px;border-radius:6px;font-weight:700;text-align:center;font-size:.9em}
.flow-arrow{color:#3b82f6;font-size:1.3em;font-weight:800}
.sub{font-size:.63em;color:#888;line-height:1.45}
.accent{color:#3b82f6}
.green{color:#4ade80}
.red{color:#f87171}
.amber{color:#fbbf24}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:6px 16px}
.grid1{display:grid;grid-template-columns:1fr;gap:5px}
.file-tree{
font-family:'SF Mono',Consolas,monospace;
font-size:.44em;line-height:1.6;
color:#ccc;background:#181818;
border-radius:6px;padding:10px 14px;
border:1px solid #333;
white-space:pre;
overflow:hidden
}
.file-tree .dir{color:#3b82f6;font-weight:700}
.file-tree .comment{color:#555}
</style>
</head>
<body>
<div class="reveal"><div class="slides">
<!-- PORTADA -->
<section class="center">
<p style="font-size:.62em;color:#888;margin-bottom:.5em">2DAM — Proyecto Grupal — 2026</p>
<h1 style="color:#fff">doli-front</h1>
<p style="font-size:.9em;color:#3b82f6;margin:.2em 0 .4em">SPA Vanilla JS · Vite · Sin frameworks</p>
<p class="sub" style="margin-top:.8em">El frontend que Dolibarr nunca tuvo</p>
</section>
<!-- EL PROBLEMA -->
<section>
<h2>Dolibarr tiene todo. Menos una interfaz decente.</h2>
<p>El ERP funciona, pero su interfaz tiene 20 años. El objetivo: dar una experiencia moderna sin tocar el backend del ERP.</p>
<div class="cols" style="margin-top:.7em">
<div class="card" style="border-top:3px solid #f87171">
<p style="font-size:.75em;color:#f87171;font-weight:700;margin:0 0 6px">Antes</p>
<div class="sub" style="line-height:1.75">
Interfaz PHP de 2004<br>Sin dark mode<br>Sin búsqueda en tiempo real<br>Sin notificaciones<br>Imposible de personalizar
</div>
</div>
<div class="card" style="border-top:3px solid #4ade80">
<p style="font-size:.75em;color:#4ade80;font-weight:700;margin:0 0 6px">Ahora</p>
<div class="sub" style="line-height:1.75">
SPA moderna con dark mode<br>Búsqueda y filtros reactivos<br>Notificaciones Teams / Slack<br>Asistente de voz integrado<br>Soporte VeriFactu
</div>
</div>
</div>
</section>
<!-- ¿POR QUÉ VANILLA JS? -->
<section>
<h2>Sin React. Sin Vue. Sin Angular.</h2>
<p>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.</p>
<div class="cols" style="margin-top:.7em">
<div class="card" style="border-top:3px solid #3b82f6">
<p style="font-size:.74em;color:#3b82f6;font-weight:700;margin:0 0 3px">Control total</p>
<p class="sub">Cada elemento del DOM lo creamos nosotros. Sin magia, sin diffing invisible.</p>
</div>
<div class="card" style="border-top:3px solid #3b82f6">
<p style="font-size:.74em;color:#3b82f6;font-weight:700;margin:0 0 3px">Dependencias mínimas</p>
<p class="sub">Solo 3: Chart.js (gráficas), @huggingface/transformers (voz) y node-forge (cifrado RSA).</p>
</div>
<div class="card" style="border-top:3px solid #3b82f6">
<p style="font-size:.74em;color:#3b82f6;font-weight:700;margin:0 0 3px">Aprendizaje real</p>
<p class="sub">Entendemos qué hace el navegador. Un framework encima de esto es trivial; al revés, no tanto.</p>
</div>
</div>
</section>
<!-- ARQUITECTURA -->
<section>
<h2>Estructura del proyecto</h2>
<div class="cols" style="align-items:flex-start;margin-top:.4em;gap:14px">
<div class="file-tree"><span class="dir">src/</span>
├── <span class="dir">pages/</span> <span class="comment">← render*() → HTMLElement</span>
│ ├── DashboardPage.js
│ ├── FacturasPage.js
│ └── pagesRegistry.js <span class="comment">← auto-glob</span>
├── <span class="dir">components/</span> <span class="comment">← reutilizables</span>
│ ├── InvoiceModal.js
│ └── Sidebar.js
├── <span class="dir">services/</span> <span class="comment">← lógica</span>
│ ├── apiClient.js <span class="comment">← fetch+JWT</span>
│ └── pagesConfig.js
├── <span class="dir">styles/</span>
├── main.js <span class="comment">← entrada</span>
└── router.js <span class="comment">← hash routing</span></div>
<div>
<div class="card" style="border-top:3px solid #3b82f6;margin-bottom:7px">
<p style="font-size:.72em;color:#3b82f6;font-weight:700;margin:0 0 3px">Una página = una función</p>
<p class="sub"><code>render*()</code> devuelve un <code>HTMLElement</code>.<br>Estado local en closure.<br>El router la monta y destruye.</p>
</div>
<div class="card" style="border-top:3px solid #4ade80">
<p style="font-size:.72em;color:#4ade80;font-weight:700;margin:0 0 3px">Sin estado global</p>
<p class="sub">No hay store ni contexto.<br>Cada página habla con el BFF<br>de forma independiente.</p>
</div>
</div>
</div>
</section>
<!-- ROUTER -->
<section>
<h2>Routing sin servidor</h2>
<p>El router escucha el hash de la URL. Navegar a <code>#invoices</code> desmonta la página actual y monta la nueva.</p>
<pre><code class="javascript">// 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);
});</code></pre>
<div class="callout" style="margin-top:.5em">
<p><code>cleanup()</code> evita memory leaks. Si una página tiene un <code>setInterval</code> (ej.: countdown del JWT en Configuración), se cancela al navegar.</p>
</div>
</section>
<!-- REGISTRO DINÁMICO -->
<section>
<h2>Añadir una página es crear un archivo</h2>
<p>Con <code>import.meta.glob</code> de Vite, cualquier <code>*Page.js</code> en <code>/pages</code> se registra solo en el sidebar.</p>
<div class="cols" style="margin-top:.6em;align-items:flex-start">
<pre><code class="javascript">// 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</code></pre>
<div>
<div class="card" style="border-top:3px solid #3b82f6;margin-bottom:7px">
<p style="font-size:.72em;color:#3b82f6;font-weight:700;margin:0 0 3px">Páginas explícitas</p>
<p class="sub">En <code>pagesConfig.js</code>: ruta, nombre, icono, voice patterns.</p>
</div>
<div class="card" style="border-top:3px solid #4ade80">
<p style="font-size:.72em;color:#4ade80;font-weight:700;margin:0 0 3px">Páginas nuevas</p>
<p class="sub">Crear <code>MiPaginaPage.js</code> → aparece en el sidebar con ruta <code>#mi-pagina</code> automáticamente.</p>
</div>
</div>
</div>
</section>
<!-- PÁGINAS PRINCIPALES -->
<section>
<h2>Lo que puede hacer el usuario</h2>
<div class="grid2" style="margin-top:.35em;gap:5px 12px">
<div class="card" style="border-left:3px solid #3b82f6;padding:7px 12px">
<p style="font-size:.65em;color:#3b82f6;font-weight:700;margin:0 0 2px">Dashboard</p>
<p style="font-size:.55em;color:#888;line-height:1.4;margin:0">KPIs, gráfica trimestral, últimos movimientos, tabla con colores.</p>
</div>
<div class="card" style="border-left:3px solid #3b82f6;padding:7px 12px">
<p style="font-size:.65em;color:#3b82f6;font-weight:700;margin:0 0 2px">Facturas</p>
<p style="font-size:.55em;color:#888;line-height:1.4;margin:0">CRUD completo. Plantillas, líneas editables, pagos, cambio de estado.</p>
</div>
<div class="card" style="border-left:3px solid #3b82f6;padding:7px 12px">
<p style="font-size:.65em;color:#3b82f6;font-weight:700;margin:0 0 2px">Fact. Proveedores</p>
<p style="font-size:.55em;color:#888;line-height:1.4;margin:0">Gestión de compras. Total por línea en tiempo real.</p>
</div>
<div class="card" style="border-left:3px solid #3b82f6;padding:7px 12px">
<p style="font-size:.65em;color:#3b82f6;font-weight:700;margin:0 0 2px">Terceros</p>
<p style="font-size:.55em;color:#888;line-height:1.4;margin:0">Clientes y proveedores con rol. Contactos anidados.</p>
</div>
<div class="card" style="border-left:3px solid #3b82f6;padding:7px 12px">
<p style="font-size:.65em;color:#3b82f6;font-weight:700;margin:0 0 2px">Banco</p>
<p style="font-size:.55em;color:#888;line-height:1.4;margin:0">Cuentas bancarias y movimientos. Alta con selector de país.</p>
</div>
<div class="card" style="border-left:3px solid #3b82f6;padding:7px 12px">
<p style="font-size:.65em;color:#3b82f6;font-weight:700;margin:0 0 2px">Configuración</p>
<p style="font-size:.55em;color:#888;line-height:1.4;margin:0">Tema, JWT countdown, webhook Teams/Slack, VeriFactu.</p>
</div>
</div>
</section>
<!-- ASISTENTE DE VOZ -->
<section>
<h2>Whisper en el navegador</h2>
<p>El asistente de voz ejecuta <strong>Whisper base</strong> (<code>Xenova/whisper-base</code>) directamente en el navegador con <code>@huggingface/transformers</code>. Sin API key, sin servidor de voz.</p>
<div class="flow" style="margin:.8em 0">
<div class="flow-box" style="background:#1a1a2e;color:#a78bfa;border:2px solid #a78bfa88">Micrófono</div>
<span class="flow-arrow">⟶</span>
<div class="flow-box" style="background:#1a1a2e;color:#a78bfa;border:2px solid #a78bfa88">Web Audio API</div>
<span class="flow-arrow">⟶</span>
<div class="flow-box" style="background:#1a1a2e;color:#a78bfa;border:2px solid #a78bfa88">Whisper (WASM)</div>
<span class="flow-arrow">⟶</span>
<div class="flow-box" style="background:#1a2e1a;color:#4ade80;border:2px solid #4ade8088">Navegación</div>
</div>
<div class="callout">
<p>"Ir a facturas", "abrir banco", "dashboard" → el router navega sin tocar el teclado. Los patrones de voz se definen en <code>pagesConfig.js</code> junto a la ruta.</p>
</div>
</section>
<!-- VERIFACTU -->
<section>
<h2>Firma de facturas: VeriFactu</h2>
<p>Flujo de tres pasos que ocurre en el navegador antes de llegar al servidor.</p>
<div class="cols" style="margin-top:.6em">
<div class="card" style="border-top:3px solid #fbbf24">
<p style="font-size:.72em;color:#fbbf24;font-weight:700;margin:0 0 3px">1 — Certificado</p>
<p class="sub">El usuario selecciona su <code>.p12</code>. La <strong>FileReader API</strong> lo convierte a base64 en el navegador. Nunca toca el disco del servidor.</p>
</div>
<div class="card" style="border-top:3px solid #fbbf24">
<p style="font-size:.72em;color:#fbbf24;font-weight:700;margin:0 0 3px">2 — Contraseña cifrada</p>
<p class="sub">El BFF expone una clave pública RSA. El frontend cifra la contraseña con ella. Nunca viaja en claro.</p>
</div>
<div class="card" style="border-top:3px solid #4ade80">
<p style="font-size:.72em;color:#4ade80;font-weight:700;margin:0 0 3px">3 — Registro</p>
<p class="sub">El BFF reenvía al microservicio Go, que valida el <code>.p12</code>, lo almacena y devuelve un token de sesión.</p>
</div>
</div>
</section>
<!-- APICLIENT + JWT -->
<section>
<h2>Una sola función para hablar con el backend</h2>
<p>Todo el tráfico HTTP pasa por <code>apiClient.js</code>. JWT automático, 401 redirige al login, errores normalizados.</p>
<pre><code class="javascript">// 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();
}</code></pre>
<div class="callout" style="margin-top:.5em">
<p>Las páginas nunca manejan tokens ni status HTTP. Solo llaman a <code>apiGet()</code>, <code>apiPost()</code>… y reciben datos o un error.</p>
</div>
</section>
<!-- DECISIONES TÉCNICAS -->
<section>
<h2>Decisiones que valió la pena tomar</h2>
<div class="grid2" style="margin-top:.35em;gap:5px 12px">
<div class="card" style="border-left:3px solid #3b82f6;padding:7px 12px">
<p style="font-size:.65em;color:#3b82f6;font-weight:700;margin:0 0 2px">Hash routing sin servidor</p>
<p style="font-size:.55em;color:#888;line-height:1.4;margin:0">Archivos estáticos. Funciona en cualquier CDN sin configurar rutas.</p>
</div>
<div class="card" style="border-left:3px solid #4ade80;padding:7px 12px">
<p style="font-size:.65em;color:#4ade80;font-weight:700;margin:0 0 2px">Cleanup en cada página</p>
<p style="font-size:.55em;color:#888;line-height:1.4;margin:0"><code>container.cleanup()</code> cancela intervals al navegar. Sin memory leaks.</p>
</div>
<div class="card" style="border-left:3px solid #a78bfa;padding:7px 12px">
<p style="font-size:.65em;color:#a78bfa;font-weight:700;margin:0 0 2px">Vite + import.meta.glob</p>
<p style="font-size:.55em;color:#888;line-height:1.4;margin:0">HMR en desarrollo. Cada página es un chunk en producción. Registro dinámico.</p>
</div>
<div class="card" style="border-left:3px solid #fbbf24;padding:7px 12px">
<p style="font-size:.65em;color:#fbbf24;font-weight:700;margin:0 0 2px">FileReader + RSA en cliente</p>
<p style="font-size:.55em;color:#888;line-height:1.4;margin:0">Certificado y contraseña procesados en el navegador. Nunca viajan en claro.</p>
</div>
</div>
</section>
<!-- CIERRE -->
<section class="center">
<h1 style="font-size:2em">Preguntas</h1>
<p style="margin-top:.8em;color:#555">doli-front · Vanilla JS · Vite · 2026</p>
</section>
</div></div>
<script src="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.js"></script>
<script src="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/plugin/highlight/highlight.js"></script>
<script>
Reveal.initialize({
hash: true,
slideNumber: 'c/t',
transition: 'fade',
transitionSpeed: 'fast',
plugins: [RevealHighlight],
width: 1100,
height: 680,
margin: 0.04,
center: false,
disableLayout: false,
});
</script>
</body>
</html>

View File

@ -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) {
<span class="client-code">${displayValue(client.codeClient)}</span>
</div>
</td>
<td class="client-type">${displayValue(client.typentCode)}</td>
<td class="client-type">${roleLabel(client.role)}</td>
<td class="client-status">
<span class="status-badge-client ${getStatusClass(client.status)}">${getStatusText(client.status)}</span>
</td>

View File

@ -269,14 +269,15 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
<span>Total:</span>
<strong class="amount-positive">${formatCurrency(invoice.total)}</strong>
</div>
<div class="total-row paid">
${invoice.status === 'paid' ? `
<div class="total-row total-row--highlight total-row--highlight-paid">
<span>Pagado:</span>
<strong class="amount-paid">${formatCurrency((invoice.total || 0) - (invoice.remainToPay || 0))}</strong>
</div>
<div class="total-row pending">
<strong>${formatCurrency(invoice.total || 0)}</strong>
</div>` : `
<div class="total-row total-row--highlight total-row--highlight-pending">
<span>Pendiente:</span>
<strong class="amount-pending">${formatCurrency(invoice.remainToPay)}</strong>
</div>
<strong>${formatCurrency(Math.max(0, invoice.remainToPay || 0))}</strong>
</div>`}
</div>
</div>

View File

@ -16,16 +16,16 @@ export function renderClientesPage() {
container.innerHTML = /*html*/`
<div class="clientes-header">
<h1>Clientes</h1>
<h1>Terceros</h1>
</div>
<div class="clientes-filters">
<div class="search-wrapper">
<span class="search-icon">${icons.search}</span>
<input type="search" placeholder="Buscar clientes..." class="search-input search-input--with-icon" />
<input type="search" placeholder="Buscar terceros..." class="search-input search-input--with-icon" />
</div>
<button class="btn-new-client">
${icons.plus} Nuevo cliente
${icons.plus} Nuevo tercero
</button>
</div>
@ -43,7 +43,7 @@ export function renderClientesPage() {
</tr>
</thead>
<tbody class="clients-list">
<tr><td colspan="7" class="loading">Cargando clientes...</td></tr>
<tr><td colspan="7" class="loading">Cargando terceros...</td></tr>
</tbody>
</table>
</div>
@ -110,7 +110,7 @@ export function renderClientesPage() {
clientsList.innerHTML = '';
if (clients.length === 0) {
clientsList.innerHTML = `<tr><td colspan="7"><div class="empty-state">${icons.emptyClients}<h3>No hay clientes</h3><p>Los clientes aparecerán aquí una vez añadidos.</p></div></td></tr>`;
clientsList.innerHTML = `<tr><td colspan="7"><div class="empty-state">${icons.emptyClients}<h3>No hay terceros</h3><p>Los terceros aparecerán aquí una vez añadidos.</p></div></td></tr>`;
return;
}
@ -156,7 +156,7 @@ export function renderClientesPage() {
</div>
<div class="client-modal-body">
<div class="client-info-section">
<h3>Información del Cliente</h3>
<h3>Información del Tercero</h3>
<div class="info-grid">
<div class="info-item">
<span class="info-label">ID</span>
@ -168,7 +168,7 @@ export function renderClientesPage() {
</div>
<div class="info-item">
<span class="info-label">Tipo</span>
<span class="info-value">${escapeHtml(displayValue(client.typentCode))}</span>
<span class="info-value">${{ client: 'Cliente', supplier: 'Proveedor', both: 'Ambos' }[client.role] || 'N/A'}</span>
</div>
<div class="info-item">
<span class="info-label">Estado</span>
@ -209,7 +209,7 @@ export function renderClientesPage() {
overlay.innerHTML = /*html*/`
<div class="client-modal create-client-modal">
<div class="client-modal-header">
<h2>Nuevo cliente</h2>
<h2>Nuevo tercero</h2>
<button class="btn-close-modal" type="button">${icons.close}</button>
</div>
<form class="create-client-form" novalidate>
@ -222,6 +222,14 @@ export function renderClientesPage() {
<input id="cc-name" name="name" type="text" class="form-input" placeholder="Nombre o razón social" required maxlength="200" />
<span class="form-error" id="cc-name-error"></span>
</div>
<div class="form-field">
<label class="form-label" for="cc-role">Tipo <span class="form-required">*</span></label>
<select id="cc-role" name="role" class="form-input">
<option value="client">Cliente</option>
<option value="supplier">Proveedor</option>
<option value="both">Ambos</option>
</select>
</div>
<div class="form-field">
<label class="form-label" for="cc-email">Email</label>
<input id="cc-email" name="email" type="email" class="form-input" placeholder="empresa@ejemplo.com" maxlength="200" />
@ -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 = '<tr><td colspan="7" class="loading">Cargando clientes...</td></tr>';
clientsList.innerHTML = '<tr><td colspan="7" class="loading">Cargando terceros...</td></tr>';
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 = `<tr><td colspan="7"><div class="empty-state">${icons.error}<h3>Error al cargar</h3><p>No se pudieron cargar los clientes. Inténtalo de nuevo.</p></div></td></tr>`;
clientsList.innerHTML = `<tr><td colspan="7"><div class="empty-state">${icons.error}<h3>Error al cargar</h3><p>No se pudieron cargar los terceros. Inténtalo de nuevo.</p></div></td></tr>`;
}
}

View File

@ -273,7 +273,7 @@ export function renderDashboard() {
<div class="db-recent-card">
<div class="db-card-header">
<span class="db-card-title">Últimas facturas</span>
<span class="db-card-title">Últimos movimientos</span>
<a href="#invoices" class="db-see-all">Ver todas</a>
</div>
<div class="db-recent-list" id="db-recent-list">
@ -311,11 +311,10 @@ export function renderDashboard() {
<th>Fecha</th>
<th>Vencimiento</th>
<th class="db-th-right">Total</th>
<th class="db-th-right">Pendiente</th>
</tr>
</thead>
<tbody id="db-table-body">
<tr><td colspan="7" class="db-table-loading">Cargando...</td></tr>
<tr><td colspan="6" class="db-table-loading">Cargando...</td></tr>
</tbody>
</table>
</div>
@ -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
? `<span class="db-type-badge db-type-venta">V</span>`
: `<span class="db-type-badge db-type-compra">C</span>`;
? `<span class="db-type-badge db-type-venta" title="Venta">V</span>`
: `<span class="db-type-badge db-type-compra" title="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)}">
@ -489,8 +490,8 @@ export function renderDashboard() {
<span class="db-recent-client">${escapeHtml(inv._party || '—')}</span>
</div>
<div class="db-recent-right">
<span class="db-recent-amount">${formatCurrency(inv.total)}</span>
<span class="db-recent-date">${formatDate(inv.date)}</span>
<span class="db-recent-amount${(inv.total || 0) < 0 || inv.status === 'unpaid' ? ' db-recent-amount--pending' : inv.status === 'paid' ? ' db-recent-amount--paid' : ''}">${formatCurrency(inv.total)}</span>
<span class="db-recent-date">${formatDate(inv.dateModification || inv.dateCreation || inv.date)}</span>
</div>
<div class="db-recent-arrow">
${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 = '<tr><td colspan="7" class="db-table-loading">No hay facturas</td></tr>';
tbody.innerHTML = '<tr><td colspan="6" class="db-table-loading">No hay facturas</td></tr>';
} 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 `
<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>
@ -548,8 +545,7 @@ export function renderDashboard() {
<td>${getStatusBadge(inv.status)}</td>
<td>${formatDate(inv.date)}</td>
<td class="${dateClass}">${formatDate(inv.expireDate)}</td>
<td class="db-cell-right db-cell-num ${(inv.total || 0) >= 0 ? 'db-total--pos' : 'db-total--neg'}">${formatCurrency(inv.total)}</td>
<td class="db-cell-right db-cell-num ${remainClass}">${inv.status === 'paid' || inv.status === 'draft' ? '—' : formatCurrency(inv.remainToPay)}</td>
<td class="db-cell-right db-cell-num ${inv.status === 'unpaid' || (inv.total || 0) < 0 ? 'db-total--neg' : inv.status === 'paid' ? 'db-total--pos' : ''}">${formatCurrency(inv.total)}</td>
</tr>`;
}).join('');

View File

@ -112,70 +112,65 @@ export function renderFacturasProveedoresPage() {
</table>
</div>
<!-- Modal detalle -->
<div class="modal-overlay sup-detail-overlay" id="sup-detail-overlay" style="display:none">
<div class="modal-content sup-detail-modal" id="sup-detail-modal">
<div class="modal-header">
<h2 id="sup-detail-title">Detalle</h2>
<button class="btn-close" id="sup-detail-close">×</button>
</div>
<div class="modal-body" id="sup-detail-body">
<div class="loading">Cargando...</div>
</div>
<!-- Vista crear factura proveedor (página completa) -->
<div id="sup-create-view" class="create-invoice-page" style="display:none">
<div class="invoice-header-compact">
<h1>Nueva factura de proveedor</h1>
<button type="button" class="btn-back" id="sup-create-close">← Volver</button>
</div>
</div>
<!-- Modal crear factura proveedor -->
<div class="modal-overlay sup-detail-overlay" id="sup-create-overlay" style="display:none">
<div class="modal-content sup-detail-modal" id="sup-create-modal">
<div class="modal-header">
<h2>Nueva factura de proveedor</h2>
<button class="btn-close" id="sup-create-close">×</button>
<form id="sup-create-form" class="invoice-form-compact">
<div class="form-grid-compact">
<div class="form-field-compact full-width">
<label>Proveedor *</label>
<select id="sup-supplier-select" required>
<option value="">Cargando proveedores...</option>
</select>
</div>
<div class="form-field-compact full-width">
<label>Ref. proveedor *</label>
<input type="text" id="sup-ref-supplier" required placeholder="Ej: FAC-2024-001" />
</div>
<div class="form-field-compact">
<label>Fecha *</label>
<div class="date-input-wrapper">
<input type="date" id="sup-date" required />
<button type="button" class="date-picker-btn" data-target="sup-date" title="Seleccionar fecha">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
</button>
</div>
</div>
<div class="form-field-compact">
<label>Vencimiento</label>
<div class="date-input-wrapper">
<input type="date" id="sup-expire" />
<button type="button" class="date-picker-btn" data-target="sup-expire" title="Seleccionar fecha">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
</button>
</div>
</div>
</div>
<div class="modal-body">
<form id="sup-create-form">
<div class="invoice-detail-grid" style="margin-bottom:1rem">
<div class="form-field-compact full-width" style="grid-column:1/-1">
<label>Proveedor *</label>
<select id="sup-supplier-select" required style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--card-bg);color:var(--text-primary)">
<option value="">Cargando proveedores...</option>
</select>
</div>
<div class="form-field-compact">
<label>Ref. proveedor *</label>
<input type="text" id="sup-ref-supplier" required placeholder="Ej: FAC-2024-001"
style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
<div class="form-field-compact">
<label>Fecha *</label>
<input type="date" id="sup-date" required
style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
<div class="form-field-compact">
<label>Vencimiento</label>
<input type="date" id="sup-expire"
style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
<div class="form-field-compact">
<label>Nota pública</label>
<input type="text" id="sup-note-public" placeholder="Opcional"
style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
</div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
<h3 class="lines-section-title" style="margin:0">Líneas *</h3>
<button type="button" class="btn-add-compact" id="sup-add-line">+ Añadir línea</button>
</div>
<div id="sup-lines-container"></div>
<div class="form-actions-compact" style="margin-top:1.5rem">
<button type="button" class="btn-cancel-compact" id="sup-create-cancel">Cancelar</button>
<button type="submit" class="btn-submit-compact" id="sup-create-submit">Crear factura</button>
</div>
</form>
<div class="notes-grid-compact">
<div class="form-field-compact">
<label>Nota pública</label>
<textarea id="sup-note-public" rows="2" placeholder="Opcional"></textarea>
</div>
</div>
</div>
<div class="lines-section-compact">
<div class="lines-header-compact">
<h3>Líneas de Factura</h3>
<button type="button" class="btn-add-compact" id="sup-add-line">+ Añadir</button>
</div>
<div class="lines-table-compact" id="sup-lines-container"></div>
</div>
<div class="form-actions-compact">
<button type="button" class="btn-cancel-compact" id="sup-create-cancel">Cancelar</button>
<button type="submit" class="btn-submit-compact" id="sup-create-submit">Crear factura</button>
</div>
</form>
</div>
`;
@ -183,11 +178,30 @@ export function renderFacturasProveedoresPage() {
const searchInput = container.querySelector('#sup-search');
const filterSelect = container.querySelector('#sup-filter');
const summary = container.querySelector('#sup-summary');
const overlay = container.querySelector('#sup-detail-overlay');
const detailBody = container.querySelector('#sup-detail-body');
const detailTitle = container.querySelector('#sup-detail-title');
container.querySelector('#sup-detail-close').addEventListener('click', closeDetail);
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeDetail(); });
let detailView = null;
let detailBody = null;
let detailTitle = null;
function createDetailModal() {
const el = document.createElement('div');
el.className = 'sup-modal-backdrop';
el.innerHTML = `
<div class="sup-modal-box">
<div class="sup-modal-head">
<h2 id="sup-detail-title">Detalle</h2>
<button class="btn-close" id="sup-detail-close">×</button>
</div>
<div class="sup-modal-body" id="sup-detail-body">
<div class="loading">Cargando...</div>
</div>
</div>`;
el.querySelector('#sup-detail-close').addEventListener('click', closeDetail);
el.addEventListener('click', e => { if (e.target === el) closeDetail(); });
document.body.appendChild(el);
detailView = el;
detailBody = el.querySelector('#sup-detail-body');
detailTitle = el.querySelector('#sup-detail-title');
}
function applyFilters() {
filteredInvoices = allInvoices.filter(inv => {
@ -254,37 +268,43 @@ export function renderFacturasProveedoresPage() {
container.querySelector('#sum-pending').textContent = fmt(pending);
}
function isModalOpen() {
return detailView && detailView.isConnected;
}
async function openDetail(id) {
overlay.style.display = 'flex';
detailTitle.textContent = 'Cargando...';
detailBody.innerHTML = '<div class="loading">Cargando detalle...</div>';
if (isModalOpen()) closeDetail();
createDetailModal();
payments = [];
// Load payment types once per page instance
if (paymentTypes.length === 0) {
try { paymentTypes = await getPaymentTypes(); } catch { paymentTypes = []; }
}
if (!isModalOpen()) return;
try {
const inv = await getSupplierInvoiceById(id);
if (!isModalOpen()) return;
currentInvoice = inv;
detailTitle.textContent = `Factura ${inv.number}`;
await loadPayments(id);
if (!isModalOpen()) return;
renderDetail(inv);
} catch (err) {
if (!isModalOpen()) return;
detailBody.innerHTML = `<div class="error">No se pudo cargar el detalle: ${escHtml(err.message)}</div>`;
}
}
async function refreshDetail() {
if (!currentInvoice) return;
if (!currentInvoice || !isModalOpen()) return;
try {
const inv = await getSupplierInvoiceById(currentInvoice.id);
if (!isModalOpen()) return;
currentInvoice = inv;
detailTitle.textContent = `Factura ${inv.number}`;
await loadPayments(inv.id);
if (!isModalOpen()) return;
renderDetail(inv);
// Refresh list row too
const idx = allInvoices.findIndex(x => x.id === inv.id);
if (idx >= 0) allInvoices[idx] = { ...allInvoices[idx], ...inv };
applyFilters();
@ -306,51 +326,50 @@ export function renderFacturasProveedoresPage() {
const isUnpaid = inv.status === 'unpaid';
const lines = inv.lines || [];
// Update title with status badge
detailTitle.innerHTML = `${escHtml(inv.number || '—')} <span class="badge ${STATUS_CLASS[inv.status] || 'badge-draft'}" style="font-size:.7em;vertical-align:middle;margin-left:.5rem">${STATUS_TEXT[inv.status] || inv.status}</span>`;
detailBody.innerHTML = /*html*/`
<!-- Header info -->
<div class="invoice-detail-grid" id="sup-detail-info">
<div class="detail-row">
<span class="detail-label">Proveedor</span>
<span class="detail-value">${escHtml(inv.supplierName || '—')}</span>
<!-- Info fields -->
<div class="form-section" id="sup-detail-info">
<div class="form-row">
<div class="form-field">
<label class="form-label">Proveedor</label>
<input class="form-input" value="${escHtml(inv.supplierName || '—')}" readonly />
</div>
<div class="form-field">
<label class="form-label">Ref. proveedor</label>
<input class="form-input" id="sup-d-ref" value="${escHtml(inv.supplierRef || '—')}" readonly />
</div>
</div>
<div class="detail-row">
<span class="detail-label">Ref. interna</span>
<span class="detail-value">${escHtml(inv.number || '—')}</span>
<div class="form-row">
<div class="form-field">
<label class="form-label">Fecha</label>
<input class="form-input" value="${fmtDate(inv.date)}" readonly />
</div>
<div class="form-field">
<label class="form-label">Vencimiento</label>
<input class="form-input" id="sup-d-expire" value="${fmtDate(inv.expireDate)}" readonly />
</div>
</div>
<div class="detail-row">
<span class="detail-label">Ref. proveedor</span>
<span class="detail-value" id="sup-d-ref">${escHtml(inv.supplierRef || '—')}</span>
</div>
<div class="detail-row">
<span class="detail-label">Estado</span>
<span class="detail-value">
<span class="status-badge ${STATUS_CLASS[inv.status] || 'status-draft'}">
${STATUS_TEXT[inv.status] || inv.status}
</span>
</span>
</div>
<div class="detail-row">
<span class="detail-label">Fecha</span>
<span class="detail-value">${fmtDate(inv.date)}</span>
</div>
<div class="detail-row">
<span class="detail-label">Vencimiento</span>
<span class="detail-value" id="sup-d-expire">${fmtDate(inv.expireDate)}</span>
</div>
${inv.notePublic ? `<div class="detail-row full-width"><span class="detail-label">Nota pública</span><span class="detail-value" id="sup-d-note">${escHtml(inv.notePublic)}</span></div>` : ''}
${inv.notePublic ? `
<div class="form-field" style="margin-top:.5rem">
<label class="form-label">Nota pública</label>
<textarea class="form-input" id="sup-d-note" readonly rows="2" style="resize:none">${escHtml(inv.notePublic)}</textarea>
</div>` : ''}
</div>
<!-- Actions bar -->
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin:1rem 0">
<!-- Actions -->
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin:1rem 0 1.5rem">
${isDraft ? `<button class="btn-submit-compact" id="sup-btn-validate">Validar factura</button>` : ''}
${isUnpaid ? `<button class="btn-submit-compact" id="sup-btn-paid" style="background:var(--success,#5dd39e);color:#000">Marcar como pagada</button>` : ''}
${isUnpaid ? `<button class="btn-submit-compact" id="sup-btn-paid" style="background:var(--success);color:#fff">Marcar como pagada</button>` : ''}
${inv.status === 'paid' || isUnpaid ? `<button class="btn-cancel-compact" id="sup-btn-draft">Volver a borrador</button>` : ''}
${isDraft ? `<button class="btn-cancel-compact" id="sup-btn-edit">Editar</button>` : ''}
${isDraft ? `<button class="btn-cancel-compact" id="sup-btn-delete" style="color:var(--danger)">Eliminar</button>` : ''}
</div>
<!-- Lines -->
<h3 class="lines-section-title">Líneas</h3>
<h3 class="lines-section-title">Líneas de Factura</h3>
<div class="invoice-lines-table-wrap">
<table class="invoice-lines-table">
<thead>
@ -398,11 +417,16 @@ export function renderFacturasProveedoresPage() {
<!-- Totals -->
<div class="invoice-totals">
${inv.totalHt != null ? `<div class="total-row subtotal"><span>Base imponible:</span><strong>${fmt(inv.totalHt)}</strong></div>` : ''}
${inv.totalHt != null ? `<div class="total-row subtotal"><span>Base imponible:</span><strong>${fmt(inv.totalHt)}</strong></div>` : ''}
${inv.totalTax != null ? `<div class="total-row subtotal"><span>IVA soportado:</span><strong>${fmt(inv.totalTax)}</strong></div>` : ''}
<div class="total-row"><span>Total:</span><strong class="amount-positive">${fmt(inv.total)}</strong></div>
<div class="total-row paid"><span>Pagado:</span><strong class="amount-paid">${fmt((inv.total || 0) - (inv.remainToPay || 0))}</strong></div>
<div class="total-row pending"><span>Pendiente:</span><strong class="amount-pending">${fmt(inv.remainToPay)}</strong></div>
${inv.status === 'paid' ? `
<div class="total-row total-row--highlight total-row--highlight-paid">
<span>Pagado:</span><strong>${fmt(inv.total || 0)}</strong>
</div>` : `
<div class="total-row total-row--highlight total-row--highlight-pending">
<span>Pendiente:</span><strong>${fmt(Math.max(0, inv.remainToPay || 0))}</strong>
</div>`}
</div>
<!-- Payments -->
@ -668,7 +692,8 @@ export function renderFacturasProveedoresPage() {
}
function closeDetail() {
overlay.style.display = 'none';
detailView?.remove();
detailView = detailBody = detailTitle = null;
currentInvoice = null;
}
@ -686,43 +711,74 @@ export function renderFacturasProveedoresPage() {
filterSelect.addEventListener('change', () => { currentFilter = filterSelect.value; applyFilters(); });
// ===== CREAR FACTURA =====
const createOverlay = container.querySelector('#sup-create-overlay');
const createView = container.querySelector('#sup-create-view');
const listEls = [
container.querySelector('.facturas-header'),
container.querySelector('.facturas-filters'),
container.querySelector('#sup-summary'),
container.querySelector('.invoices-table-container'),
];
function showListView() {
detailView.style.display = 'none';
createView.style.display = 'none';
listEls.forEach(el => { if (el) el.style.display = ''; });
}
const supplierSelect = container.querySelector('#sup-supplier-select');
const linesContainer = container.querySelector('#sup-lines-container');
let lineCount = 0;
function addLine() {
function createSupplierLine() {
lineCount++;
const row = document.createElement('div');
row.className = 'line-row-compact';
row.dataset.line = lineCount;
row.innerHTML = `
<input type="text" placeholder="Descripción *" class="line-desc" required style="flex:2;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
<input type="number" placeholder="Cant." class="line-qty" value="1" min="0.01" step="0.01" required style="width:70px;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
<input type="number" placeholder="P.Unit." class="line-price" min="0" step="0.01" required style="width:90px;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
<input type="number" placeholder="IVA%" class="line-tax" value="21" min="0" max="100" step="0.01" required style="width:70px;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
<button type="button" class="btn-remove-line" title="Eliminar" style="background:none;border:none;color:var(--danger);cursor:pointer;font-size:1.1rem;padding:0 0.25rem">✕</button>
<input type="text" class="line-desc-compact" required placeholder="Descripción" />
<input type="number" class="line-qty-compact" required min="0.01" step="0.01" value="1" placeholder="Cant" />
<input type="number" class="line-price-compact" required min="0" step="0.01" placeholder="Precio" />
<input type="number" class="line-tax-compact" required min="0" max="100" step="0.01" value="21" placeholder="IVA %" />
<div class="line-total-compact">0.00 €</div>
<button type="button" class="btn-delete-compact">×</button>
`;
row.querySelector('.btn-remove-line').addEventListener('click', () => {
const qty = row.querySelector('.line-qty-compact');
const price = row.querySelector('.line-price-compact');
const tax = row.querySelector('.line-tax-compact');
const total = row.querySelector('.line-total-compact');
const updateTotal = () => {
const t = (parseFloat(qty.value) || 0) * (parseFloat(price.value) || 0) * (1 + (parseFloat(tax.value) || 0) / 100);
total.textContent = `${t.toFixed(2)} €`;
};
qty.addEventListener('input', updateTotal);
price.addEventListener('input', updateTotal);
tax.addEventListener('input', updateTotal);
row.querySelector('.btn-delete-compact').addEventListener('click', () => {
row.remove();
if (linesContainer.children.length === 0) addLine();
if (linesContainer.children.length === 0) linesContainer.appendChild(createSupplierLine());
});
linesContainer.appendChild(row);
return row;
}
function openCreateModal() {
createOverlay.style.display = 'flex';
function showCreateView() {
listEls.forEach(el => { if (el) el.style.display = 'none'; });
createView.style.display = 'block';
linesContainer.innerHTML = '';
lineCount = 0;
container.querySelector('#sup-ref-supplier').value = '';
container.querySelector('#sup-date').value = new Date().toISOString().split('T')[0];
container.querySelector('#sup-expire').value = '';
container.querySelector('#sup-note-public').value = '';
addLine();
linesContainer.appendChild(createSupplierLine());
}
container.querySelectorAll('.date-picker-btn').forEach(btn => {
btn.addEventListener('click', () => {
const input = container.querySelector(`#${btn.dataset.target}`);
if (input) input.showPicker?.();
});
});
function closeCreate() {
createOverlay.style.display = 'none';
showListView();
}
async function loadSuppliers() {
@ -741,13 +797,12 @@ export function renderFacturasProveedoresPage() {
}
container.querySelector('#btn-new-sup').addEventListener('click', () => {
openCreateModal();
showCreateView();
loadSuppliers();
});
container.querySelector('#sup-create-close').addEventListener('click', closeCreate);
container.querySelector('#sup-create-cancel').addEventListener('click', closeCreate);
createOverlay.addEventListener('click', (e) => { if (e.target === createOverlay) closeCreate(); });
container.querySelector('#sup-add-line').addEventListener('click', addLine);
container.querySelector('#sup-add-line').addEventListener('click', () => linesContainer.appendChild(createSupplierLine()));
container.querySelector('#sup-create-form').addEventListener('submit', async (e) => {
e.preventDefault();
@ -756,10 +811,10 @@ export function renderFacturasProveedoresPage() {
if (!supplierId) { showToast('Selecciona un proveedor', 'error'); return; }
const lines = [...linesContainer.querySelectorAll('.line-row-compact')].map(row => ({
description: row.querySelector('.line-desc').value.trim(),
quantity: parseFloat(row.querySelector('.line-qty').value) || 1,
unitPrice: parseFloat(row.querySelector('.line-price').value) || 0,
taxRate: parseFloat(row.querySelector('.line-tax').value) || 0,
description: row.querySelector('.line-desc-compact').value.trim(),
quantity: parseFloat(row.querySelector('.line-qty-compact').value) || 1,
unitPrice: parseFloat(row.querySelector('.line-price-compact').value) || 0,
taxRate: parseFloat(row.querySelector('.line-tax-compact').value) || 0,
}));
if (lines.some(l => !l.description)) { showToast('Rellena la descripción de todas las líneas', 'error'); return; }
@ -777,7 +832,7 @@ export function renderFacturasProveedoresPage() {
lines
});
showToast('Factura de proveedor creada', 'success');
closeCreate();
showListView();
allInvoices = await getSupplierInvoices({ limit: 200 });
applyFilters();
} catch (err) {
@ -788,7 +843,13 @@ export function renderFacturasProveedoresPage() {
}
});
load();
load().then(() => {
const pendingId = window.__openSupplierInvoice;
if (pendingId) {
window.__openSupplierInvoice = null;
openDetail(pendingId);
}
});
return container;
}

View File

@ -56,10 +56,10 @@ export const pagesConfig = [
{
_file: 'ClientesPage',
route: 'clients',
name: 'Clientes',
name: 'Terceros',
order: 5,
icon: icons.clients,
voicePatterns: ['clientes', 'ver clientes', 'ir a clientes', 'lista clientes', 'mis clientes'],
voicePatterns: ['terceros', 'ver terceros', 'ir a terceros', 'clientes', 'ver clientes'],
requiresAuth: true,
showInSidebar: true,
render: renderClientesPage,

View File

@ -249,13 +249,16 @@
}
.lines-section-compact {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
margin-bottom: var(--space-5);
}
.create-invoice-page .lines-section-compact {
flex: 1;
overflow: hidden;
}
.lines-header-compact {
display: flex;
justify-content: space-between;
@ -287,13 +290,16 @@
}
.lines-table-compact {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.create-invoice-page .lines-table-compact {
flex: 1;
overflow-y: auto;
}
.line-row-compact {
display: grid;
grid-template-columns: 4fr 0.8fr 1.2fr 0.8fr 1.2fr auto;

View File

@ -320,6 +320,10 @@
font-weight: 600;
color: var(--text-primary);
}
.db-recent-amount--pending { color: var(--red-600); }
.db-recent-amount--paid { color: var(--green-600); }
:root[data-theme='dark'] .db-recent-amount--pending { color: #fca5a5; }
:root[data-theme='dark'] .db-recent-amount--paid { color: #86efac; }
.db-recent-date {
font-size: 11px;
@ -370,32 +374,36 @@
/* 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--unpaid td { background: rgba(220, 38, 38, 0.04); }
.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--unpaid td:first-child { border-left-color: var(--red-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-row--unpaid:hover td { background: rgba(220, 38, 38, 0.08) !important; }
.db-num--paid { color: var(--green-600) !important; }
.db-num--unpaid { color: var(--amber-600) !important; }
.db-num--unpaid { color: var(--red-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; }
.db-table-row td.db-total--pos { color: var(--green-600); font-weight: 600; }
.db-table-row td.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--unpaid td { background: rgba(220, 38, 38, 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--unpaid:hover td { background: rgba(220, 38, 38, 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-row--unpaid td:first-child { border-left-color: #fca5a5; }
: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-num--unpaid { color: #fca5a5 !important; }
:root[data-theme='dark'] .db-total--pos { color: #86efac; }
:root[data-theme='dark'] .db-total--neg { color: #fca5a5; }
:root[data-theme='dark'] .db-table-row td.db-total--pos { color: #86efac; }
:root[data-theme='dark'] .db-table-row td.db-total--neg { color: #fca5a5; }
.db-cell-num {
font-variant-numeric: tabular-nums;

View File

@ -313,6 +313,22 @@
border-top: 2px solid var(--border-color);
}
.total-row--highlight {
font-size: 1.35rem;
font-weight: 700;
padding-top: 0.75rem;
margin-top: 0.25rem;
border-top: 2px solid var(--border-color);
}
.total-row--highlight-paid {
color: var(--success);
}
.total-row--highlight-pending {
color: var(--danger);
}
/* Payment Section */
.payment-section {
border-top: 2px solid var(--border-color);
@ -933,17 +949,66 @@
background: rgba(0,0,0,0.45);
z-index: 1000;
display: flex;
align-items: center;
align-items: flex-start;
justify-content: center;
padding: 1rem;
overflow-y: auto;
padding: 2rem 1rem;
}
.sup-detail-modal {
width: 100%;
max-width: 780px;
max-height: 90vh;
overflow-y: auto;
max-height: none;
overflow-y: visible;
border-radius: 12px;
margin: auto;
}
/* Modal detalle factura proveedor */
.sup-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
overflow-y: auto;
}
.sup-modal-box {
background: var(--card-bg);
border-radius: 12px;
width: 100%;
max-width: 820px;
max-height: 88vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 40px rgba(0,0,0,0.18);
animation: slideUp 0.25s ease-out;
}
.sup-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.75rem;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.sup-modal-head h2 {
font-size: 1.05rem;
font-weight: 700;
margin: 0;
color: var(--text-primary);
}
.sup-modal-body {
padding: 1.5rem 1.75rem;
overflow-y: auto;
flex: 1;
}
.invoice-detail-grid {