Cambios implementados: 1. Nginx: - gzip on (compresión JS/CSS/JSON) - Cache headers para assets estáticos (6M) - Proxy buffer tuning (10s connect, 30s read) 2. Frontend catalog.js: - Reemplazados 8x innerHTML += en loops por map+join - Event delegation en breadcrumb y cart (elimina memory leak) - AbortController en apiFetch (evita race conditions) - sessionStorage cache para years-all y brands por modo 3. Frontend templates HTML: - defer en todos los scripts POS (mejora First Paint) 4. Dashboard JS: - innerHTML += fix en dashboard.js y cuentas.js 5. Base de datos: - Índice parcial idx_wi_part_stock_positive en warehouse_inventory 6. Documentación: - docs/performance_audit_2026.md con análisis completo y roadmap Tests: 73/73 pasando (compat + fase3 + fase5 + fase6)
176 lines
11 KiB
HTML
176 lines
11 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="es" data-theme="industrial">
|
|
<head>
|
|
<script>/*pos_theme_early*/(function(){var t=localStorage.getItem("pos_theme")||"industrial";document.documentElement.setAttribute("data-theme",t);})()</script>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Cotizaciones — Nexus Autoparts POS</title>
|
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: var(--font-body); background: var(--color-bg-base); color: var(--color-text-primary); min-height: 100vh; }
|
|
.page { max-width: 1200px; margin: 0 auto; padding: var(--space-6); margin-left: 240px; }
|
|
@media (max-width: 1023px) { .page { margin-left: 0; } }
|
|
.page-title { font-family: var(--font-heading); font-size: var(--text-h3); margin-bottom: var(--space-6); }
|
|
.quote-table { width: 100%; border-collapse: collapse; background: var(--glass-bg); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); overflow: hidden; }
|
|
.quote-table th, .quote-table td { padding: var(--space-3) var(--space-4); text-align: left; border-bottom: 1px solid var(--glass-border); }
|
|
.quote-table th { background: var(--glass-bg-strong); font-family: var(--font-mono); font-size: var(--text-caption); text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted); }
|
|
.quote-table tbody tr { cursor: pointer; transition: background 0.15s; }
|
|
.quote-table tbody tr:hover { background: var(--glass-highlight); }
|
|
.badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 11px; font-weight: 700; }
|
|
.badge--active { background: rgba(63,185,80,0.15); color: #3FB950; }
|
|
.badge--converted { background: rgba(0,212,255,0.15); color: #00D4FF; }
|
|
.badge--cancelled { background: rgba(248,81,73,0.15); color: #F85149; }
|
|
.badge--expired { background: rgba(130,130,130,0.2); color: #888; }
|
|
.badge--wa { background: rgba(37,211,102,0.15); color: #25D366; }
|
|
.badge--pos { background: var(--color-primary-muted); color: var(--color-text-accent); }
|
|
.modal-overlay { display:none; position:fixed; inset:0; z-index:1000; background:var(--overlay-backdrop); backdrop-filter:blur(4px); align-items:flex-start; justify-content:center; padding:var(--space-8) var(--space-4); overflow-y:auto; }
|
|
.modal-overlay.open { display:flex; }
|
|
.modal-content { background:var(--glass-bg-strong); backdrop-filter:blur(24px); border:1px solid var(--glass-border); border-radius:var(--radius-lg); max-width:650px; width:100%; padding:var(--space-6); position:relative; }
|
|
.modal-close { position:absolute; top:var(--space-3); right:var(--space-3); background:none; border:none; color:var(--color-text-muted); font-size:1.4rem; cursor:pointer; }
|
|
.detail-table { width:100%; border-collapse:collapse; margin:var(--space-4) 0; }
|
|
.detail-table th, .detail-table td { padding:var(--space-2) var(--space-3); text-align:left; border-bottom:1px solid var(--glass-border); font-size:var(--text-body-sm); }
|
|
.detail-table th { color:var(--color-text-muted); font-size:var(--text-caption); text-transform:uppercase; }
|
|
.empty { text-align:center; padding:var(--space-12); color:var(--color-text-muted); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<script src="/pos/static/js/pos-utils.js" defer></script>
|
|
<script src="/pos/static/js/sidebar.js" defer></script>
|
|
<script src="/pos/static/js/app-init.js" defer></script>
|
|
|
|
<div class="page">
|
|
<h1 class="page-title">Cotizaciones</h1>
|
|
<div id="quoteList">Cargando...</div>
|
|
</div>
|
|
|
|
<div class="modal-overlay" id="quoteModal">
|
|
<div class="modal-content">
|
|
<button class="modal-close" onclick="document.getElementById('quoteModal').classList.remove('open')">×</button>
|
|
<div id="quoteDetail">Cargando...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function() {
|
|
var token = localStorage.getItem('pos_token');
|
|
if (!token) { window.location.href = '/pos/login'; return; }
|
|
var API = '/pos/api';
|
|
|
|
function headers() { return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }; }
|
|
function esc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
|
|
function fmt(n) { return (n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }
|
|
|
|
function loadQuotes() {
|
|
fetch(API + '/quotations?per_page=50', { headers: headers() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(d) {
|
|
var quotes = d.data || [];
|
|
if (!quotes.length) {
|
|
document.getElementById('quoteList').innerHTML = '<div class="empty"><h3>Sin cotizaciones</h3><p>Las cotizaciones creadas desde el POS (F4) o desde WhatsApp aparecen aqui.</p></div>';
|
|
return;
|
|
}
|
|
var html = '<table class="quote-table"><thead><tr>';
|
|
html += '<th>#</th><th>Origen</th><th>Cliente</th><th>Total</th><th>Estado</th><th>Fecha</th><th></th>';
|
|
html += '</tr></thead><tbody>';
|
|
quotes.forEach(function(q) {
|
|
var srcBadge = q.source === 'whatsapp'
|
|
? '<span class="badge badge--wa">📱 WA</span>'
|
|
: '<span class="badge badge--pos">🖥️ POS</span>';
|
|
var statusBadge = '<span class="badge badge--' + q.status + '">' + q.status + '</span>';
|
|
var client = q.customer_name || (q.wa_phone ? '📱 ' + q.wa_phone : 'Sin cliente');
|
|
var dateStr = q.created_at ? new Date(q.created_at).toLocaleDateString('es-MX') : '';
|
|
html += '<tr>';
|
|
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;"><strong>#' + q.id + '</strong></td>';
|
|
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;">' + srcBadge + '</td>';
|
|
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;">' + esc(client) + '</td>';
|
|
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;font-family:var(--font-mono);font-weight:700;">$' + fmt(q.total) + '</td>';
|
|
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;">' + statusBadge + '</td>';
|
|
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;color:var(--color-text-muted);">' + dateStr + '</td>';
|
|
html += '<td><button onclick="deleteQuote(' + q.id + ', event)" style="background:none;border:none;color:var(--color-text-muted);cursor:pointer;font-size:16px;padding:4px 8px;border-radius:4px;" onmouseover="this.style.color=\'#F85149\';this.style.background=\'rgba(248,81,73,0.1)\'" onmouseout="this.style.color=\'var(--color-text-muted)\';this.style.background=\'none\'">🗑️</button></td>';
|
|
html += '</tr>';
|
|
});
|
|
html += '</tbody></table>';
|
|
document.getElementById('quoteList').innerHTML = html;
|
|
})
|
|
.catch(function() {
|
|
document.getElementById('quoteList').innerHTML = '<div class="empty">Error cargando cotizaciones</div>';
|
|
});
|
|
}
|
|
|
|
window.openQuote = function(id) {
|
|
var modal = document.getElementById('quoteModal');
|
|
modal.classList.add('open');
|
|
document.getElementById('quoteDetail').innerHTML = 'Cargando...';
|
|
|
|
fetch(API + '/quotations/' + id, { headers: headers() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(q) {
|
|
if (q.error) { document.getElementById('quoteDetail').innerHTML = 'Error: ' + esc(q.error); return; }
|
|
var src = (q.notes || '').startsWith('WA:') ? 'WhatsApp' : 'POS';
|
|
var waPhone = src === 'WhatsApp' ? q.notes.replace('WA:', '') : null;
|
|
var html = '<h3 style="font-family:var(--font-heading);margin-bottom:var(--space-4);">Cotización #' + q.id + '</h3>';
|
|
html += '<div style="display:flex;gap:var(--space-6);margin-bottom:var(--space-4);font-size:var(--text-body-sm);">';
|
|
html += '<div><span style="color:var(--color-text-muted);">Origen:</span> ' + src + '</div>';
|
|
if (waPhone) html += '<div><span style="color:var(--color-text-muted);">WhatsApp:</span> +' + esc(waPhone) + '</div>';
|
|
if (q.customer_name) html += '<div><span style="color:var(--color-text-muted);">Cliente:</span> ' + esc(q.customer_name) + '</div>';
|
|
html += '<div><span style="color:var(--color-text-muted);">Estado:</span> <span class="badge badge--' + q.status + '">' + q.status + '</span></div>';
|
|
html += '<div><span style="color:var(--color-text-muted);">Vigencia:</span> ' + (q.valid_until || '—') + '</div>';
|
|
html += '</div>';
|
|
|
|
html += '<table class="detail-table"><thead><tr><th>#Parte</th><th>Nombre</th><th>Cant</th><th>Precio</th><th>Subtotal</th></tr></thead><tbody>';
|
|
(q.items || []).forEach(function(it) {
|
|
html += '<tr>';
|
|
html += '<td style="font-family:var(--font-mono);">' + esc(it.part_number) + '</td>';
|
|
html += '<td>' + esc(it.name) + '</td>';
|
|
html += '<td>' + it.quantity + '</td>';
|
|
html += '<td>$' + fmt(it.unit_price) + '</td>';
|
|
html += '<td style="font-weight:700;">$' + fmt(it.subtotal) + '</td>';
|
|
html += '</tr>';
|
|
});
|
|
html += '</tbody></table>';
|
|
|
|
html += '<div style="text-align:right;margin-top:var(--space-4);font-size:var(--text-body);">';
|
|
html += '<div>Subtotal: $' + fmt(q.subtotal) + '</div>';
|
|
html += '<div>IVA: $' + fmt(q.tax_total) + '</div>';
|
|
html += '<div style="font-size:var(--text-h5);font-weight:700;color:var(--color-text-accent);">Total: $' + fmt(q.total) + '</div>';
|
|
html += '</div>';
|
|
|
|
html += '<div style="margin-top:var(--space-5);display:flex;gap:var(--space-3);justify-content:flex-end;">';
|
|
html += '<button class="btn btn--ghost" onclick="deleteQuote(' + q.id + ')" style="color:#F85149;">Eliminar</button>';
|
|
html += '<button class="btn btn--ghost" onclick="exportVisibleTableCSV(\'cotizacion_' + q.id + '\')">Exportar CSV</button>';
|
|
html += '<button class="btn btn--ghost" onclick="window.print()">Imprimir</button>';
|
|
html += '</div>';
|
|
|
|
document.getElementById('quoteDetail').innerHTML = html;
|
|
});
|
|
};
|
|
|
|
window.deleteQuote = function(id, event) {
|
|
if (event) event.stopPropagation();
|
|
if (!confirm('¿Eliminar cotización #' + id + '? Esta acción no se puede deshacer.')) return;
|
|
fetch(API + '/quotations/' + id, { method: 'DELETE', headers: headers() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(d) {
|
|
if (d.ok) {
|
|
document.getElementById('quoteModal').classList.remove('open');
|
|
loadQuotes();
|
|
if (typeof showToast === 'function') showToast('Cotización #' + id + ' eliminada', 'ok');
|
|
} else {
|
|
alert('Error: ' + (d.error || 'desconocido'));
|
|
}
|
|
});
|
|
};
|
|
|
|
// Close modal on outside click
|
|
document.getElementById('quoteModal').addEventListener('click', function(e) {
|
|
if (e.target === this) this.classList.remove('open');
|
|
});
|
|
|
|
loadQuotes();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|