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:
774
src/public/css/styles.css
Normal file
774
src/public/css/styles.css
Normal 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
800
src/public/js/app.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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> </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">📄</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">⚠️</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">🖨 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">⚠️</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">🔍</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">🖨 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">🔍</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">⚠️</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> </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">📋</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">⚠️</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">⚠️</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();
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user