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:
parent
81f40f7d7f
commit
7c3b05f2da
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue