Add frontend portal SPA with hash-based routing and all views

Implements the complete single-page application for the refaccionaria
portal with five views: Facturas list with filters, Factura detail with
label printing, Articulos search, Catalogo management with inline
editing and CSV/XLSX import, and Configuracion for printer settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
consultoria-as
2026-02-17 06:39:21 +00:00
parent 9b1aaf2802
commit 74b21c7fa8
2 changed files with 1574 additions and 0 deletions

774
src/public/css/styles.css Normal file
View File

@@ -0,0 +1,774 @@
/* ============================================
Portal Refaccionaria - Styles
============================================ */
:root {
--sidebar-bg: #1a2332;
--sidebar-hover: #243044;
--sidebar-text: #94a3b8;
--sidebar-active: #ffffff;
--sidebar-active-bg: #2563eb;
--sidebar-width: 240px;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--primary-light: #dbeafe;
--bg: #f1f5f9;
--card-bg: #ffffff;
--text: #1e293b;
--text-secondary: #64748b;
--text-muted: #94a3b8;
--border: #e2e8f0;
--border-focus: #93c5fd;
--success: #22c55e;
--success-bg: #f0fdf4;
--error: #ef4444;
--error-bg: #fef2f2;
--warning: #f59e0b;
--warning-bg: #fffbeb;
--radius: 8px;
--radius-sm: 4px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
--transition: 150ms ease;
}
/* ---- Reset ---- */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--text);
background: var(--bg);
}
body {
display: flex;
overflow: hidden;
}
/* ---- Sidebar ---- */
.sidebar {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
height: 100vh;
background: var(--sidebar-bg);
display: flex;
flex-direction: column;
overflow-y: auto;
z-index: 100;
}
.sidebar-header {
padding: 24px 20px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.sidebar-title {
font-size: 16px;
font-weight: 700;
color: var(--sidebar-active);
letter-spacing: -0.01em;
}
.sidebar-nav {
flex: 1;
padding: 12px 0;
}
.nav-link {
display: flex;
align-items: center;
padding: 10px 20px;
color: var(--sidebar-text);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: all var(--transition);
border-left: 3px solid transparent;
gap: 12px;
}
.nav-link:hover {
color: var(--sidebar-active);
background: var(--sidebar-hover);
}
.nav-link.active {
color: var(--sidebar-active);
background: var(--sidebar-hover);
border-left-color: var(--primary);
}
.nav-icon {
font-size: 16px;
width: 24px;
text-align: center;
flex-shrink: 0;
}
.nav-text {
white-space: nowrap;
}
.sidebar-footer {
padding: 16px 20px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
color: var(--text-muted);
font-size: 11px;
}
/* ---- Content Area ---- */
.content {
flex: 1;
height: 100vh;
overflow-y: auto;
padding: 24px 32px;
background: var(--bg);
}
/* ---- Loading ---- */
.loading-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 50vh;
color: var(--text-secondary);
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin-bottom: 12px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ---- Page Header ---- */
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-size: 22px;
font-weight: 700;
color: var(--text);
margin-bottom: 4px;
}
.page-header p {
color: var(--text-secondary);
font-size: 14px;
}
/* ---- Cards ---- */
.card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.card-body {
padding: 20px;
}
.card + .card {
margin-top: 16px;
}
/* ---- Filters Bar ---- */
.filters-bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-end;
padding: 16px 20px;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.filter-group label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
/* ---- Form Elements ---- */
input[type="text"],
input[type="date"],
input[type="number"],
input[type="file"],
select {
height: 36px;
padding: 0 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--text);
background: var(--card-bg);
transition: border-color var(--transition), box-shadow var(--transition);
outline: none;
font-family: inherit;
}
input[type="text"]:focus,
input[type="date"]:focus,
input[type="number"]:focus,
select:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
input[type="file"] {
height: auto;
padding: 8px 12px;
cursor: pointer;
}
input[type="text"]::placeholder {
color: var(--text-muted);
}
/* ---- Buttons ---- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 36px;
padding: 0 16px;
border: none;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all var(--transition);
white-space: nowrap;
text-decoration: none;
}
.btn:active {
transform: scale(0.98);
}
.btn-primary {
background: var(--primary);
color: #ffffff;
}
.btn-primary:hover {
background: var(--primary-hover);
}
.btn-secondary {
background: var(--card-bg);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--bg);
}
.btn-success {
background: var(--success);
color: #ffffff;
}
.btn-success:hover {
background: #16a34a;
}
.btn-sm {
height: 30px;
padding: 0 10px;
font-size: 12px;
}
.btn-icon {
width: 30px;
height: 30px;
padding: 0;
font-size: 14px;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ---- Tables ---- */
.table-container {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
thead {
background: var(--bg);
position: sticky;
top: 0;
z-index: 1;
}
thead th {
padding: 10px 12px;
text-align: left;
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
border-bottom: 2px solid var(--border);
white-space: nowrap;
}
tbody td {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
color: var(--text);
vertical-align: middle;
}
tbody tr {
transition: background var(--transition);
}
tbody tr:hover {
background: #f8fafc;
}
tbody tr.clickable-row {
cursor: pointer;
}
tbody tr.clickable-row:hover {
background: var(--primary-light);
}
/* ---- Checkbox ---- */
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--primary);
}
/* ---- Currency ---- */
.currency {
font-variant-numeric: tabular-nums;
text-align: right;
white-space: nowrap;
}
/* ---- Empty State ---- */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
color: var(--text-secondary);
text-align: center;
}
.empty-state .empty-icon {
font-size: 48px;
margin-bottom: 12px;
opacity: 0.4;
}
.empty-state p {
font-size: 14px;
max-width: 300px;
}
/* ---- Badge ---- */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
}
.badge-primary {
background: var(--primary-light);
color: var(--primary);
}
/* ---- Detail Header ---- */
.detail-header {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-start;
}
.detail-meta {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
flex: 1;
}
.meta-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.meta-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
}
.meta-value {
font-size: 15px;
font-weight: 500;
color: var(--text);
word-break: break-all;
}
.meta-value.total {
font-size: 20px;
font-weight: 700;
color: var(--primary);
}
/* ---- Back Button ---- */
.back-btn {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--primary);
text-decoration: none;
font-weight: 600;
font-size: 14px;
margin-bottom: 16px;
transition: color var(--transition);
cursor: pointer;
background: none;
border: none;
padding: 0;
font-family: inherit;
}
.back-btn:hover {
color: var(--primary-hover);
}
/* ---- Print Section ---- */
.print-section {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: var(--bg);
border-top: 1px solid var(--border);
border-radius: 0 0 var(--radius) var(--radius);
}
.print-section label {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.print-section input[type="number"] {
width: 80px;
}
/* ---- Config Form ---- */
.config-form {
display: grid;
gap: 16px;
max-width: 480px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group label {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.form-group input {
width: 100%;
}
.form-group .form-hint {
font-size: 11px;
color: var(--text-muted);
}
/* ---- Import Section ---- */
.import-section {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.import-result {
font-size: 13px;
font-weight: 600;
color: var(--success);
}
/* ---- Inline Edit ---- */
.inline-edit-input {
width: 100%;
max-width: 200px;
}
/* ---- Add Form (Catalog) ---- */
.add-form {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-end;
padding: 16px 20px;
border-top: 1px solid var(--border);
background: var(--bg);
border-radius: 0 0 var(--radius) var(--radius);
}
/* ---- Search Bar ---- */
.search-bar {
display: flex;
gap: 8px;
max-width: 600px;
margin-bottom: 16px;
}
.search-bar input[type="text"] {
flex: 1;
height: 40px;
font-size: 14px;
}
.search-bar .btn {
height: 40px;
}
/* ---- Toast Notifications ---- */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 8px;
}
.toast {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 20px;
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
font-size: 13px;
font-weight: 500;
animation: toastIn 0.3s ease;
min-width: 280px;
max-width: 420px;
}
.toast-success {
background: var(--success-bg);
border: 1px solid var(--success);
color: #166534;
}
.toast-error {
background: var(--error-bg);
border: 1px solid var(--error);
color: #991b1b;
}
.toast-warning {
background: var(--warning-bg);
border: 1px solid var(--warning);
color: #92400e;
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateX(40px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.toast-exit {
animation: toastOut 0.2s ease forwards;
}
@keyframes toastOut {
to {
opacity: 0;
transform: translateX(40px);
}
}
/* ---- Utility ---- */
.text-right {
text-align: right;
}
.text-center {
text-align: center;
}
.text-muted {
color: var(--text-secondary);
}
.mt-16 {
margin-top: 16px;
}
.mb-16 {
margin-bottom: 16px;
}
.flex-gap {
display: flex;
gap: 12px;
align-items: center;
}
/* ---- Responsive ---- */
@media (max-width: 768px) {
body {
flex-direction: column;
}
.sidebar {
width: 100%;
min-width: 100%;
height: auto;
max-height: none;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
}
.sidebar-header {
padding: 12px 16px;
border-bottom: none;
border-right: 1px solid rgba(255, 255, 255, 0.08);
}
.sidebar-title {
font-size: 14px;
}
.sidebar-nav {
display: flex;
flex: 1;
padding: 0;
overflow-x: auto;
}
.nav-link {
padding: 12px 14px;
border-left: none;
border-bottom: 3px solid transparent;
font-size: 13px;
}
.nav-link.active {
border-left-color: transparent;
border-bottom-color: var(--primary);
}
.sidebar-footer {
display: none;
}
.content {
height: calc(100vh - 50px);
padding: 16px;
}
.filters-bar {
flex-direction: column;
align-items: stretch;
}
.filter-group {
width: 100%;
}
.filter-group input,
.filter-group select {
width: 100%;
}
.detail-meta {
grid-template-columns: 1fr 1fr;
}
.search-bar {
max-width: 100%;
}
}
@media (max-width: 480px) {
.detail-meta {
grid-template-columns: 1fr;
}
.print-section {
flex-wrap: wrap;
}
}

800
src/public/js/app.js Normal file
View File

@@ -0,0 +1,800 @@
/* ============================================
Portal Refaccionaria - app.js
Single-page application with hash-based routing
============================================ */
(function () {
'use strict';
// ---- Constants ----
const contentEl = document.getElementById('content');
const toastContainer = document.getElementById('toastContainer');
const currencyFmt = new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
});
// ---- API Helper ----
async function fetchJSON(url, options) {
const res = await fetch(url, options);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Error ${res.status}`);
}
return res.json();
}
// ---- Toast Notifications ----
function showToast(message, type) {
// type: 'success' | 'error' | 'warning'
const toast = document.createElement('div');
toast.className = 'toast toast-' + type;
const icons = { success: '\u2705', error: '\u274C', warning: '\u26A0\uFE0F' };
toast.innerHTML = '<span>' + (icons[type] || '') + '</span><span>' + escapeHtml(message) + '</span>';
toastContainer.appendChild(toast);
setTimeout(function () {
toast.classList.add('toast-exit');
setTimeout(function () { toast.remove(); }, 200);
}, 3500);
}
// ---- Utility ----
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function truncateUUID(uuid) {
if (!uuid) return '';
return uuid.substring(0, 8) + '...';
}
function formatDate(dateStr) {
if (!dateStr) return '';
// Dates come as ISO strings, show YYYY-MM-DD
return dateStr.substring(0, 10);
}
function showLoading() {
contentEl.innerHTML =
'<div class="loading-screen"><div class="spinner"></div><p>Cargando...</p></div>';
}
function buildQueryString(params) {
var parts = [];
for (var key in params) {
if (params[key]) {
parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(params[key]));
}
}
return parts.length ? '?' + parts.join('&') : '';
}
// ---- Router ----
function router() {
var hash = window.location.hash || '#facturas';
var parts = hash.substring(1).split('/');
var route = parts[0];
var param = parts[1] || null;
// Update active nav link
var links = document.querySelectorAll('.nav-link');
links.forEach(function (link) {
var linkRoute = link.getAttribute('data-route');
if (linkRoute === route || (route === 'factura' && linkRoute === 'facturas')) {
link.classList.add('active');
} else {
link.classList.remove('active');
}
});
showLoading();
switch (route) {
case 'facturas':
renderFacturas();
break;
case 'factura':
renderFacturaDetail(param);
break;
case 'articulos':
renderArticulos();
break;
case 'catalogo':
renderCatalogo();
break;
case 'config':
renderConfig();
break;
default:
renderFacturas();
}
}
// ============================================
// VIEW: Facturas List
// ============================================
async function renderFacturas() {
contentEl.innerHTML =
'<div class="page-header">' +
'<h2>Facturas</h2>' +
'<p>Listado de facturas importadas desde archivos XML</p>' +
'</div>' +
'<div class="card">' +
'<div class="filters-bar">' +
'<div class="filter-group">' +
'<label for="f-fecha-inicio">Fecha inicio</label>' +
'<input type="date" id="f-fecha-inicio">' +
'</div>' +
'<div class="filter-group">' +
'<label for="f-fecha-fin">Fecha fin</label>' +
'<input type="date" id="f-fecha-fin">' +
'</div>' +
'<div class="filter-group">' +
'<label for="f-proveedor">Proveedor</label>' +
'<input type="text" id="f-proveedor" placeholder="Nombre del proveedor">' +
'</div>' +
'<div class="filter-group">' +
'<label for="f-buscar">Buscar</label>' +
'<input type="text" id="f-buscar" placeholder="UUID o emisor...">' +
'</div>' +
'<div class="filter-group">' +
'<label>&nbsp;</label>' +
'<button class="btn btn-primary" id="f-btn-buscar">Buscar</button>' +
'</div>' +
'</div>' +
'<div class="table-container" id="facturas-table-container">' +
'<div class="loading-screen"><div class="spinner"></div><p>Cargando facturas...</p></div>' +
'</div>' +
'</div>';
// Wire up filter button and enter key
document.getElementById('f-btn-buscar').addEventListener('click', loadFacturas);
['f-fecha-inicio', 'f-fecha-fin', 'f-proveedor', 'f-buscar'].forEach(function (id) {
document.getElementById(id).addEventListener('keydown', function (e) {
if (e.key === 'Enter') loadFacturas();
});
});
await loadFacturas();
}
async function loadFacturas() {
var container = document.getElementById('facturas-table-container');
if (!container) return;
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Cargando facturas...</p></div>';
var params = {
fecha_inicio: document.getElementById('f-fecha-inicio').value,
fecha_fin: document.getElementById('f-fecha-fin').value,
proveedor: document.getElementById('f-proveedor').value,
q: document.getElementById('f-buscar').value,
};
try {
var facturas = await fetchJSON('/api/facturas' + buildQueryString(params));
if (!facturas.length) {
container.innerHTML =
'<div class="empty-state">' +
'<div class="empty-icon">&#128196;</div>' +
'<p>No se encontraron facturas.</p>' +
'</div>';
return;
}
var rows = facturas.map(function (f) {
return '<tr class="clickable-row" data-id="' + f.id + '">' +
'<td>' + escapeHtml(formatDate(f.fecha)) + '</td>' +
'<td><code>' + escapeHtml(truncateUUID(f.uuid)) + '</code></td>' +
'<td>' + escapeHtml(f.nombre_emisor) + '</td>' +
'<td class="currency">' + currencyFmt.format(f.total || 0) + '</td>' +
'<td class="text-center">' + (f.num_conceptos || 0) + '</td>' +
'</tr>';
}).join('');
container.innerHTML =
'<table>' +
'<thead><tr>' +
'<th>Fecha</th>' +
'<th>UUID</th>' +
'<th>Emisor</th>' +
'<th class="text-right">Total</th>' +
'<th class="text-center"># Art\u00edculos</th>' +
'</tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>';
// Row click navigation
container.querySelectorAll('.clickable-row').forEach(function (row) {
row.addEventListener('click', function () {
window.location.hash = '#factura/' + row.getAttribute('data-id');
});
});
} catch (err) {
container.innerHTML =
'<div class="empty-state">' +
'<div class="empty-icon">&#9888;&#65039;</div>' +
'<p>Error al cargar facturas: ' + escapeHtml(err.message) + '</p>' +
'</div>';
}
}
// ============================================
// VIEW: Factura Detail
// ============================================
async function renderFacturaDetail(id) {
if (!id) {
window.location.hash = '#facturas';
return;
}
try {
var data = await fetchJSON('/api/facturas/' + encodeURIComponent(id));
var f = data.factura;
var conceptos = data.conceptos || [];
var conceptoRows = conceptos.map(function (c, index) {
return '<tr>' +
'<td><input type="checkbox" class="concepto-check" data-index="' + index + '"></td>' +
'<td>' + escapeHtml(c.descripcion) + '</td>' +
'<td><code>' + escapeHtml(c.no_identificacion || '') + '</code></td>' +
'<td class="text-center">' + (c.cantidad || 0) + '</td>' +
'<td class="currency">' + currencyFmt.format(c.valor_unitario || 0) + '</td>' +
'<td class="currency">' + currencyFmt.format(c.importe || 0) + '</td>' +
'<td>' + escapeHtml(c.intercambio || '-') + '</td>' +
'</tr>';
}).join('');
contentEl.innerHTML =
'<button class="back-btn" id="btn-back">\u2190 Volver a Facturas</button>' +
'<div class="card">' +
'<div class="card-body">' +
'<div class="detail-header">' +
'<div class="detail-meta">' +
'<div class="meta-item">' +
'<span class="meta-label">Emisor</span>' +
'<span class="meta-value">' + escapeHtml(f.nombre_emisor) + '</span>' +
'</div>' +
'<div class="meta-item">' +
'<span class="meta-label">RFC</span>' +
'<span class="meta-value">' + escapeHtml(f.rfc_emisor) + '</span>' +
'</div>' +
'<div class="meta-item">' +
'<span class="meta-label">UUID</span>' +
'<span class="meta-value" style="font-size:12px">' + escapeHtml(f.uuid) + '</span>' +
'</div>' +
'<div class="meta-item">' +
'<span class="meta-label">Fecha</span>' +
'<span class="meta-value">' + escapeHtml(formatDate(f.fecha)) + '</span>' +
'</div>' +
'<div class="meta-item">' +
'<span class="meta-label">Subtotal</span>' +
'<span class="meta-value">' + currencyFmt.format(f.subtotal || 0) + '</span>' +
'</div>' +
'<div class="meta-item">' +
'<span class="meta-label">Total</span>' +
'<span class="meta-value total">' + currencyFmt.format(f.total || 0) + '</span>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="card mt-16">' +
'<div class="card-body" style="padding-bottom:0">' +
'<h3 style="margin-bottom:12px">Conceptos</h3>' +
'</div>' +
'<div class="table-container">' +
'<table>' +
'<thead><tr>' +
'<th><input type="checkbox" id="select-all-conceptos"></th>' +
'<th>Descripci\u00f3n</th>' +
'<th>No. Parte</th>' +
'<th class="text-center">Cantidad</th>' +
'<th class="text-right">Precio Unit.</th>' +
'<th class="text-right">Importe</th>' +
'<th>Intercambio</th>' +
'</tr></thead>' +
'<tbody>' + (conceptoRows || '<tr><td colspan="7" class="text-center text-muted">Sin conceptos</td></tr>') + '</tbody>' +
'</table>' +
'</div>' +
'<div class="print-section">' +
'<label for="print-qty">Cantidad de etiquetas:</label>' +
'<input type="number" id="print-qty" min="1" value="1">' +
'<button class="btn btn-primary" id="btn-print">&#128424; Imprimir Etiquetas</button>' +
'</div>' +
'</div>';
// Store conceptos for printing
contentEl._conceptos = conceptos;
// Back button
document.getElementById('btn-back').addEventListener('click', function () {
window.location.hash = '#facturas';
});
// Select all checkbox
document.getElementById('select-all-conceptos').addEventListener('change', function () {
var checked = this.checked;
document.querySelectorAll('.concepto-check').forEach(function (cb) {
cb.checked = checked;
});
});
// Print button
document.getElementById('btn-print').addEventListener('click', function () {
handlePrint(contentEl._conceptos, '.concepto-check', 'print-qty');
});
} catch (err) {
contentEl.innerHTML =
'<button class="back-btn" id="btn-back">\u2190 Volver a Facturas</button>' +
'<div class="empty-state">' +
'<div class="empty-icon">&#9888;&#65039;</div>' +
'<p>Error al cargar la factura: ' + escapeHtml(err.message) + '</p>' +
'</div>';
document.getElementById('btn-back').addEventListener('click', function () {
window.location.hash = '#facturas';
});
}
}
// ============================================
// VIEW: Articulos Search
// ============================================
async function renderArticulos() {
contentEl.innerHTML =
'<div class="page-header">' +
'<h2>B\u00fasqueda de Art\u00edculos</h2>' +
'<p>Busca art\u00edculos por nombre, n\u00famero de parte o intercambio</p>' +
'</div>' +
'<div class="search-bar">' +
'<input type="text" id="art-query" placeholder="Buscar art\u00edculos por nombre, n\u00famero de parte o intercambio...">' +
'<button class="btn btn-primary" id="art-btn-buscar">Buscar</button>' +
'</div>' +
'<div class="card">' +
'<div class="table-container" id="articulos-table-container">' +
'<div class="empty-state">' +
'<div class="empty-icon">&#128269;</div>' +
'<p>Ingrese un t\u00e9rmino de b\u00fasqueda para encontrar art\u00edculos.</p>' +
'</div>' +
'</div>' +
'<div class="print-section" id="art-print-section" style="display:none">' +
'<label for="art-print-qty">Cantidad de etiquetas:</label>' +
'<input type="number" id="art-print-qty" min="1" value="1">' +
'<button class="btn btn-primary" id="art-btn-print">&#128424; Imprimir Etiquetas</button>' +
'</div>' +
'</div>';
document.getElementById('art-btn-buscar').addEventListener('click', loadArticulos);
document.getElementById('art-query').addEventListener('keydown', function (e) {
if (e.key === 'Enter') loadArticulos();
});
document.getElementById('art-btn-print').addEventListener('click', function () {
handlePrint(contentEl._articulos, '.articulo-check', 'art-print-qty');
});
// Focus the search input
document.getElementById('art-query').focus();
}
async function loadArticulos() {
var query = document.getElementById('art-query').value.trim();
var container = document.getElementById('articulos-table-container');
var printSection = document.getElementById('art-print-section');
if (query.length < 2) {
showToast('Ingrese al menos 2 caracteres para buscar.', 'warning');
return;
}
container.innerHTML = '<div class="loading-screen"><div class="spinner"></div><p>Buscando...</p></div>';
printSection.style.display = 'none';
try {
var articulos = await fetchJSON('/api/articulos?q=' + encodeURIComponent(query));
contentEl._articulos = articulos;
if (!articulos.length) {
container.innerHTML =
'<div class="empty-state">' +
'<div class="empty-icon">&#128269;</div>' +
'<p>No se encontraron art\u00edculos para "' + escapeHtml(query) + '".</p>' +
'</div>';
return;
}
var rows = articulos.map(function (a, index) {
return '<tr>' +
'<td><input type="checkbox" class="articulo-check" data-index="' + index + '"></td>' +
'<td>' + escapeHtml(a.descripcion) + '</td>' +
'<td><code>' + escapeHtml(a.no_identificacion || '') + '</code></td>' +
'<td>' + escapeHtml(a.intercambio || '-') + '</td>' +
'<td>' + escapeHtml(a.nombre_emisor) + '</td>' +
'<td class="currency">' + currencyFmt.format(a.valor_unitario || 0) + '</td>' +
'<td>' + escapeHtml(formatDate(a.fecha)) + '</td>' +
'</tr>';
}).join('');
container.innerHTML =
'<table>' +
'<thead><tr>' +
'<th><input type="checkbox" id="select-all-articulos"></th>' +
'<th>Descripci\u00f3n</th>' +
'<th>No. Parte</th>' +
'<th>Intercambio</th>' +
'<th>Proveedor</th>' +
'<th class="text-right">Precio</th>' +
'<th>Fecha</th>' +
'</tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>';
printSection.style.display = 'flex';
// Select all
document.getElementById('select-all-articulos').addEventListener('change', function () {
var checked = this.checked;
document.querySelectorAll('.articulo-check').forEach(function (cb) {
cb.checked = checked;
});
});
} catch (err) {
container.innerHTML =
'<div class="empty-state">' +
'<div class="empty-icon">&#9888;&#65039;</div>' +
'<p>Error al buscar: ' + escapeHtml(err.message) + '</p>' +
'</div>';
}
}
// ============================================
// VIEW: Catalogo
// ============================================
async function renderCatalogo() {
contentEl.innerHTML =
'<div class="page-header">' +
'<h2>Cat\u00e1logo de Intercambios</h2>' +
'<p>Administra la correspondencia entre n\u00fameros de parte del proveedor y n\u00fameros de intercambio</p>' +
'</div>' +
'<div class="card">' +
'<div class="import-section">' +
'<input type="file" id="cat-file" accept=".csv,.xlsx,.xls">' +
'<button class="btn btn-primary" id="cat-btn-import">Importar</button>' +
'<span class="import-result" id="cat-import-result"></span>' +
'</div>' +
'<div style="padding:12px 20px">' +
'<input type="text" id="cat-query" placeholder="Filtrar cat\u00e1logo..." style="width:100%;max-width:400px">' +
'</div>' +
'<div class="table-container" id="catalogo-table-container">' +
'<div class="loading-screen"><div class="spinner"></div><p>Cargando cat\u00e1logo...</p></div>' +
'</div>' +
'<div class="add-form">' +
'<div class="filter-group">' +
'<label for="cat-new-proveedor">No. Parte Proveedor</label>' +
'<input type="text" id="cat-new-proveedor" placeholder="Ej: ABC123">' +
'</div>' +
'<div class="filter-group">' +
'<label for="cat-new-intercambio">No. Parte Intercambio</label>' +
'<input type="text" id="cat-new-intercambio" placeholder="Ej: XYZ456">' +
'</div>' +
'<div class="filter-group">' +
'<label>&nbsp;</label>' +
'<button class="btn btn-success" id="cat-btn-add">Agregar</button>' +
'</div>' +
'</div>' +
'</div>';
// Wire up events
document.getElementById('cat-btn-import').addEventListener('click', importCatalog);
document.getElementById('cat-btn-add').addEventListener('click', addCatalogEntry);
var debounceTimer = null;
document.getElementById('cat-query').addEventListener('input', function () {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(loadCatalogo, 300);
});
await loadCatalogo();
}
async function loadCatalogo() {
var container = document.getElementById('catalogo-table-container');
if (!container) return;
var query = document.getElementById('cat-query').value.trim();
try {
var items = await fetchJSON('/api/catalogo' + (query ? '?q=' + encodeURIComponent(query) : ''));
if (!items.length) {
container.innerHTML =
'<div class="empty-state">' +
'<div class="empty-icon">&#128203;</div>' +
'<p>No se encontraron registros en el cat\u00e1logo.</p>' +
'</div>';
return;
}
var rows = items.map(function (item) {
return '<tr data-parte="' + escapeHtml(item.no_parte_proveedor) + '">' +
'<td class="cat-col-proveedor">' + escapeHtml(item.no_parte_proveedor) + '</td>' +
'<td class="cat-col-intercambio">' + escapeHtml(item.no_parte_intercambio) + '</td>' +
'<td>' +
'<button class="btn btn-secondary btn-sm cat-btn-edit">Editar</button>' +
'</td>' +
'</tr>';
}).join('');
container.innerHTML =
'<table>' +
'<thead><tr>' +
'<th>No. Parte Proveedor</th>' +
'<th>No. Parte Intercambio</th>' +
'<th>Acciones</th>' +
'</tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>';
// Attach edit handlers
container.querySelectorAll('.cat-btn-edit').forEach(function (btn) {
btn.addEventListener('click', function () {
startInlineEdit(btn.closest('tr'));
});
});
} catch (err) {
container.innerHTML =
'<div class="empty-state">' +
'<div class="empty-icon">&#9888;&#65039;</div>' +
'<p>Error al cargar el cat\u00e1logo: ' + escapeHtml(err.message) + '</p>' +
'</div>';
}
}
function startInlineEdit(row) {
var parteProveedor = row.getAttribute('data-parte');
var intercambioCell = row.querySelector('.cat-col-intercambio');
var currentValue = intercambioCell.textContent;
var actionsCell = row.querySelector('td:last-child');
intercambioCell.innerHTML =
'<input type="text" class="inline-edit-input" value="' + escapeHtml(currentValue) + '">';
actionsCell.innerHTML =
'<button class="btn btn-primary btn-sm cat-btn-save">Guardar</button> ' +
'<button class="btn btn-secondary btn-sm cat-btn-cancel">Cancelar</button>';
var input = intercambioCell.querySelector('input');
input.focus();
input.select();
actionsCell.querySelector('.cat-btn-save').addEventListener('click', async function () {
var newValue = input.value.trim();
if (!newValue) {
showToast('El valor no puede estar vac\u00edo.', 'warning');
return;
}
try {
await fetchJSON('/api/catalogo/' + encodeURIComponent(parteProveedor), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ noParteIntercambio: newValue }),
});
showToast('Registro actualizado correctamente.', 'success');
loadCatalogo();
} catch (err) {
showToast('Error al actualizar: ' + err.message, 'error');
}
});
actionsCell.querySelector('.cat-btn-cancel').addEventListener('click', function () {
loadCatalogo();
});
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') actionsCell.querySelector('.cat-btn-save').click();
if (e.key === 'Escape') actionsCell.querySelector('.cat-btn-cancel').click();
});
}
async function importCatalog() {
var fileInput = document.getElementById('cat-file');
var resultSpan = document.getElementById('cat-import-result');
if (!fileInput.files.length) {
showToast('Seleccione un archivo para importar.', 'warning');
return;
}
var formData = new FormData();
formData.append('file', fileInput.files[0]);
try {
resultSpan.textContent = 'Importando...';
var res = await fetchJSON('/api/catalogo/import', {
method: 'POST',
body: formData,
});
resultSpan.textContent = res.imported + ' registros importados';
showToast(res.imported + ' registros importados exitosamente.', 'success');
fileInput.value = '';
loadCatalogo();
} catch (err) {
resultSpan.textContent = '';
showToast('Error al importar: ' + err.message, 'error');
}
}
async function addCatalogEntry() {
var proveedorInput = document.getElementById('cat-new-proveedor');
var intercambioInput = document.getElementById('cat-new-intercambio');
var proveedor = proveedorInput.value.trim();
var intercambio = intercambioInput.value.trim();
if (!proveedor || !intercambio) {
showToast('Complete ambos campos para agregar un registro.', 'warning');
return;
}
try {
await fetchJSON('/api/catalogo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
no_parte_proveedor: proveedor,
no_parte_intercambio: intercambio,
}),
});
showToast('Registro agregado correctamente.', 'success');
proveedorInput.value = '';
intercambioInput.value = '';
loadCatalogo();
} catch (err) {
showToast('Error al agregar: ' + err.message, 'error');
}
}
// ============================================
// VIEW: Configuration
// ============================================
async function renderConfig() {
showLoading();
try {
var config = await fetchJSON('/api/config');
contentEl.innerHTML =
'<div class="page-header">' +
'<h2>Configuraci\u00f3n</h2>' +
'<p>Ajustes de la impresora Zebra y carpeta de archivos XML</p>' +
'</div>' +
'<div class="card">' +
'<div class="card-body">' +
'<div class="config-form" id="config-form">' +
'<div class="form-group">' +
'<label for="cfg-zebra-ip">IP Impresora Zebra</label>' +
'<input type="text" id="cfg-zebra-ip" value="' + escapeHtml(config.zebra_ip || '') + '" placeholder="192.168.1.100">' +
'<span class="form-hint">Direcci\u00f3n IP de la impresora de etiquetas en la red local</span>' +
'</div>' +
'<div class="form-group">' +
'<label for="cfg-zebra-port">Puerto Impresora</label>' +
'<input type="text" id="cfg-zebra-port" value="' + escapeHtml(config.zebra_port || '9100') + '" placeholder="9100">' +
'<span class="form-hint">Puerto TCP de la impresora (por defecto: 9100)</span>' +
'</div>' +
'<div class="form-group">' +
'<label for="cfg-xml-folder">Carpeta de XMLs</label>' +
'<input type="text" id="cfg-xml-folder" value="' + escapeHtml(config.xml_folder || '') + '" placeholder="./data/xmls">' +
'<span class="form-hint">Ruta donde se monitorean los archivos XML de facturas</span>' +
'</div>' +
'<div class="form-group">' +
'<label for="cfg-label-width">Ancho de etiqueta (pulgadas)</label>' +
'<input type="text" id="cfg-label-width" value="' + escapeHtml(config.label_width || '2') + '" placeholder="2">' +
'</div>' +
'<div class="form-group">' +
'<label for="cfg-label-height">Alto de etiqueta (pulgadas)</label>' +
'<input type="text" id="cfg-label-height" value="' + escapeHtml(config.label_height || '1') + '" placeholder="1">' +
'</div>' +
'<div>' +
'<button class="btn btn-primary" id="cfg-btn-save">Guardar Configuraci\u00f3n</button>' +
'</div>' +
'</div>' +
'</div>' +
'</div>';
document.getElementById('cfg-btn-save').addEventListener('click', saveConfig);
} catch (err) {
contentEl.innerHTML =
'<div class="page-header"><h2>Configuraci\u00f3n</h2></div>' +
'<div class="empty-state">' +
'<div class="empty-icon">&#9888;&#65039;</div>' +
'<p>Error al cargar la configuraci\u00f3n: ' + escapeHtml(err.message) + '</p>' +
'</div>';
}
}
async function saveConfig() {
var btn = document.getElementById('cfg-btn-save');
btn.disabled = true;
btn.textContent = 'Guardando...';
var payload = {
zebra_ip: document.getElementById('cfg-zebra-ip').value.trim(),
zebra_port: document.getElementById('cfg-zebra-port').value.trim(),
xml_folder: document.getElementById('cfg-xml-folder').value.trim(),
label_width: document.getElementById('cfg-label-width').value.trim(),
label_height: document.getElementById('cfg-label-height').value.trim(),
};
try {
await fetchJSON('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
showToast('Configuraci\u00f3n guardada exitosamente.', 'success');
} catch (err) {
showToast('Error al guardar: ' + err.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Guardar Configuraci\u00f3n';
}
}
// ============================================
// SHARED: Print Handler
// ============================================
async function handlePrint(dataArray, checkboxSelector, qtyInputId) {
var checkboxes = document.querySelectorAll(checkboxSelector + ':checked');
if (!checkboxes.length) {
showToast('Seleccione al menos un art\u00edculo para imprimir.', 'warning');
return;
}
var qty = parseInt(document.getElementById(qtyInputId).value, 10) || 1;
var articles = [];
checkboxes.forEach(function (cb) {
var index = parseInt(cb.getAttribute('data-index'), 10);
var item = dataArray[index];
if (item) {
articles.push({
descripcion: item.descripcion,
noIdentificacion: item.no_identificacion,
intercambio: item.intercambio,
quantity: qty,
});
}
});
if (!articles.length) {
showToast('No se pudieron preparar los art\u00edculos seleccionados.', 'error');
return;
}
try {
var res = await fetchJSON('/api/print', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ articles: articles }),
});
showToast('Se enviaron ' + res.labels + ' etiqueta(s) a la impresora.', 'success');
} catch (err) {
showToast('Error al imprimir: ' + err.message, 'error');
}
}
// ---- Initialize ----
window.addEventListener('hashchange', router);
window.addEventListener('DOMContentLoaded', function () {
if (!window.location.hash) {
window.location.hash = '#facturas';
} else {
router();
}
});
})();