fix(audit): corrige errores criticos y mayores, mejora UX/accesibilidad y optimiza rendimiento
- Arregla @require_auth, permisos, race conditions, locks de caja/stock - Elimina N+1 en layaway, flotilla, dashboard y global_invoice - Asegura folios atomicos para CFDI, ordenes de servicio y polizas - Protege client_secret de MercadoLibre en backend - Conecta botones/filtros de config, customers, accounting e invoicing - Mejora accesibilidad (labels/aria-label) y estados de carga/vacio - Limpia accounting.js obsoleto y consolida accounting.v9.js - Actualiza cache busting a v32 y Service Worker a v32 - Documenta todo en docs/AUDIT_Y_MEJORAS_2026-06-15.md Tests: 35 passed
This commit is contained in:
@@ -1243,3 +1243,45 @@
|
||||
.summary-strip { grid-template-columns: 1fr; }
|
||||
.finance-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
MODAL DETALLE CUENTA POR COBRAR
|
||||
========================================================================= */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: var(--overlay-backdrop);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: var(--z-modal);
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: var(--transition-normal);
|
||||
}
|
||||
.modal-overlay.open { opacity: 1; pointer-events: auto; }
|
||||
|
||||
.modal-pago {
|
||||
background: var(--color-bg-elevated); border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg); box-shadow: var(--shadow-xl);
|
||||
width: 560px; max-width: 95vw; max-height: 90vh; overflow-y: auto;
|
||||
transform: translateY(20px); transition: var(--transition-normal);
|
||||
}
|
||||
.modal-overlay.open .modal-pago { transform: translateY(0); }
|
||||
|
||||
.modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--space-5) var(--space-6); border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.modal-header h3 {
|
||||
font-family: var(--font-heading); font-size: var(--text-h4);
|
||||
font-weight: var(--heading-weight-primary); color: var(--color-text-primary);
|
||||
}
|
||||
.modal-close {
|
||||
width: 36px; height: 36px; display: flex; align-items: center; justify-content: center;
|
||||
background: transparent; border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md); cursor: pointer;
|
||||
color: var(--color-text-muted); font-size: 18px; transition: var(--transition-fast);
|
||||
}
|
||||
.modal-close:hover { background: var(--color-surface-2); color: var(--color-text-primary); }
|
||||
|
||||
.modal-footer {
|
||||
display: flex; align-items: center; justify-content: flex-end; gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-6); border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
@@ -117,6 +117,16 @@
|
||||
.empty-state__action {
|
||||
margin-top: var(--space-2, 0.5rem);
|
||||
}
|
||||
.empty-state__icon svg {
|
||||
display: block;
|
||||
}
|
||||
.empty-state__icon[aria-hidden="true"],
|
||||
.empty-state__icon .empty-state__spinner {
|
||||
animation: empty-spin 1s linear infinite;
|
||||
}
|
||||
@keyframes empty-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
4. UNIFIED INPUT FOCUS RING
|
||||
|
||||
@@ -901,8 +901,9 @@
|
||||
font-family: 'Courier New', 'Consolas', monospace;
|
||||
font-size: 11px; line-height: 1.4; padding: 12px; text-align: left;
|
||||
border: 1px dashed #ccc;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ticket-80 { width: 302px; }
|
||||
.ticket-80 { width: 80mm; max-width: 80mm; }
|
||||
.ticket .store-name { font-size: 14px; font-weight: bold; text-align: center; margin-bottom: 2px; }
|
||||
.ticket .store-tagline { font-size: 9px; text-align: center; color: #555; margin-bottom: 4px; }
|
||||
.ticket .store-info { font-size: 9px; text-align: center; color: #333; margin-bottom: 6px; line-height: 1.3; }
|
||||
@@ -921,17 +922,21 @@
|
||||
gap: 8px; align-items: baseline; font-size: 10px; margin-bottom: 3px;
|
||||
}
|
||||
.ticket-80 .item-line-wide .qty { font-weight: bold; min-width: 24px; text-align: right; }
|
||||
.ticket-80 .item-line-wide .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ticket-80 .item-line-wide .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; word-break: break-word; }
|
||||
.ticket-80 .item-line-wide .price { text-align: right; min-width: 55px; }
|
||||
.ticket-80 .item-line-wide .subtotal { text-align: right; font-weight: bold; min-width: 60px; }
|
||||
.ticket-line { page-break-inside: avoid; }
|
||||
|
||||
@media print {
|
||||
@page { margin: 0; size: 80mm auto; }
|
||||
body * { display: none !important; }
|
||||
.ticket-print-area, .ticket-print-area * { display: block !important; }
|
||||
.ticket-print-area { position: fixed; top: 0; left: 0; }
|
||||
.ticket { border: none; box-shadow: none; padding: 4px; }
|
||||
.ticket .item-line-wide { display: grid !important; }
|
||||
.ticket-print-area { position: fixed; top: 0; left: 0; width: 80mm; }
|
||||
.ticket, .ticket-80 { width: 80mm !important; max-width: 80mm !important; border: none !important; box-shadow: none !important; padding: 3mm !important; font-size: 9pt !important; }
|
||||
.ticket * { word-break: break-word !important; }
|
||||
.ticket .item-line-wide { display: grid !important; grid-template-columns: auto 1fr auto auto !important; gap: 2mm !important; font-size: 8pt !important; }
|
||||
.ticket .ticket-row, .ticket .folio-line, .ticket .total-line { display: flex !important; }
|
||||
.ticket .name { white-space: normal !important; }
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
|
||||
@@ -767,3 +767,233 @@ body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
LIST VIEW, FILTERS & TABS
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.workshop-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4) var(--space-6);
|
||||
background: var(--color-bg-elevated);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
[data-theme="industrial"] .workshop-toolbar {
|
||||
background: var(--color-surface-1);
|
||||
}
|
||||
|
||||
.workshop-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.workshop-filters .form-input {
|
||||
min-width: 160px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.toolbar-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--text-body-sm);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toolbar-toggle input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.view-switch {
|
||||
display: inline-flex;
|
||||
background: var(--color-bg-base);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-switch__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.view-switch__btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.view-switch__btn.is-active {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.workshop-list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
}
|
||||
|
||||
.workshop-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.workshop-table th,
|
||||
.workshop-table td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workshop-table td:nth-child(2) {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.list-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ── Detail tabs ── */
|
||||
|
||||
.so-detail-header {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-4);
|
||||
padding-bottom: var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.so-detail-header__row {
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.so-detail-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.so-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.so-tabs__btn {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.so-tabs__btn.is-active {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.so-tab-panel {
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.bitacora-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
BADGE STATUS COLORS
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.badge--received { background: rgba(59, 130, 246, 0.12); color: #3b82f6; }
|
||||
.badge--diagnosis { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
|
||||
.badge--waiting_parts { background: rgba(245, 166, 35, 0.12); color: #f5a623; }
|
||||
.badge--repair { background: rgba(245, 166, 35, 0.18); color: #d97706; }
|
||||
.badge--quality_check { background: rgba(139, 92, 246, 0.12); color: #8b5cf6; }
|
||||
.badge--ready { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
|
||||
.badge--delivered { background: rgba(16, 185, 129, 0.12); color: #10b981; }
|
||||
.badge--cancelled { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||
.badge--pending { background: rgba(148, 163, 184, 0.12); color: #94a3b8; }
|
||||
|
||||
.badge--normal { background: rgba(148, 163, 184, 0.12); color: #94a3b8; }
|
||||
.badge--high { background: rgba(245, 166, 35, 0.12); color: #f5a623; }
|
||||
.badge--urgent { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
MODAL FOOTER
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.modal__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modal__footer .so-detail__actions {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.so-detail-info {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.workshop-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.workshop-filters {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.workshop-filters .form-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.so-detail-info {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
1
pos/static/js/accounting.min.js
vendored
1
pos/static/js/accounting.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -1,9 +1,9 @@
|
||||
// /home/Autopartes/pos/static/js/accounting.js
|
||||
// Accounting module — wired to design-system HTML IDs
|
||||
// Tabs: panel-cxc, panel-cxp, panel-balance, panel-resultados, panel-flujo, panel-conciliacion, panel-cierre
|
||||
|
||||
const Accounting = (() => {
|
||||
const API = '/pos/api/accounting';
|
||||
let accountsList = [];
|
||||
|
||||
function token() {
|
||||
return localStorage.getItem('pos_token') || '';
|
||||
@@ -14,7 +14,8 @@ const Accounting = (() => {
|
||||
}
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(`${API}${path}`, { headers: headers(), ...opts });
|
||||
const url = path.startsWith('/pos/api/') ? path : `${API}${path}`;
|
||||
const res = await fetch(url, { headers: headers(), ...opts });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || 'Request failed');
|
||||
@@ -41,6 +42,30 @@ const Accounting = (() => {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadChartOfAccounts() {
|
||||
if (accountsList.length) return;
|
||||
try {
|
||||
const res = await api('/accounts');
|
||||
accountsList = (res.data || []).filter(a => a.is_active);
|
||||
populateEntryAccountSelects();
|
||||
} catch (e) {
|
||||
console.warn('No se pudieron cargar cuentas contables:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function accountSelectHtml(selectedId) {
|
||||
if (!accountsList.length) {
|
||||
return '<select class="entry-account" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);"><option value="">Cargando cuentas...</option></select>';
|
||||
}
|
||||
let html = '<select class="entry-account" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);"><option value="">Selecciona cuenta</option>';
|
||||
accountsList.forEach(a => {
|
||||
const selected = selectedId && String(selectedId) === String(a.id) ? ' selected' : '';
|
||||
html += `<option value="${a.id}"${selected}>${esc(a.code)} - ${esc(a.name)}</option>`;
|
||||
});
|
||||
html += '</select>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// ---- Tab switching (matches design system onclick="switchTab('xxx')") ----
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
@@ -96,7 +121,17 @@ const Accounting = (() => {
|
||||
|
||||
try {
|
||||
const res = await api('/aging');
|
||||
const rows = res.data || [];
|
||||
let rows = res.data || [];
|
||||
|
||||
const statusFilter = document.getElementById('cxc-status-filter');
|
||||
const selected = statusFilter ? statusFilter.value : 'all';
|
||||
if (selected !== 'all') {
|
||||
rows = rows.filter(r => {
|
||||
const status = r.status || (r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending');
|
||||
return status === selected;
|
||||
});
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay cuentas por cobrar.</td></tr>';
|
||||
return;
|
||||
@@ -152,7 +187,7 @@ const Accounting = (() => {
|
||||
const balance = (sale.total || 0) - paid;
|
||||
const canCancel = sale.status !== 'cancelled' && balance > 0;
|
||||
|
||||
const html = '<div class="modal-overlay" id="receivableDetailOverlay" style="display:flex;z-index:2000;">' +
|
||||
const html = '<div class="modal-overlay open" id="receivableDetailOverlay" style="z-index:2000;">' +
|
||||
'<div class="modal-pago" style="max-width:600px;width:90%;max-height:90vh;overflow:auto;">' +
|
||||
'<div class="modal-header"><h3>Detalle de Venta a Crédito</h3>' +
|
||||
'<button class="modal-close" onclick="Accounting.closeReceivableDetail()">✕</button></div>' +
|
||||
@@ -224,7 +259,17 @@ const Accounting = (() => {
|
||||
try {
|
||||
// Use accounts endpoint filtered for payables or a dedicated endpoint
|
||||
const res = await api('/aging?type=payable');
|
||||
const rows = res.data || [];
|
||||
let rows = res.data || [];
|
||||
|
||||
const statusFilter = document.getElementById('cxp-status-filter');
|
||||
const selected = statusFilter ? statusFilter.value : 'all';
|
||||
if (selected !== 'all') {
|
||||
rows = rows.filter(r => {
|
||||
const status = r.status || (r.days_overdue > 0 ? 'overdue' : r.paid > 0 && r.balance > 0 ? 'partial' : r.balance <= 0 ? 'ok' : 'pending');
|
||||
return status === selected;
|
||||
});
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay cuentas por pagar.</td></tr>';
|
||||
return;
|
||||
@@ -242,7 +287,7 @@ const Accounting = (() => {
|
||||
<td class="td--amount">$${fmt(r.paid || 0)}</td>
|
||||
<td class="td--amount">$${fmt(r.balance || r.total)}</td>
|
||||
<td>${statusBadge(status, label)}</td>
|
||||
<td><button class="btn btn--ghost btn--sm" onclick="alert('Pago a proveedor aún no está implementado')">${r.balance > 0 ? 'Pagar' : 'Ver'}</button></td>
|
||||
<td><button class="btn btn--ghost btn--sm" onclick="Accounting.registerPayablePayment(${r.id || 0})">${r.balance > 0 ? 'Pagar' : 'Ver'}</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
@@ -475,6 +520,8 @@ const Accounting = (() => {
|
||||
loadSummaryCards();
|
||||
// Load initial tab data (cxc is active by default)
|
||||
loadAging();
|
||||
// Preload chart of accounts for the manual entry modal
|
||||
loadChartOfAccounts();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
@@ -514,6 +561,27 @@ const Accounting = (() => {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function exportarCuentasPorCobrar() {
|
||||
try {
|
||||
const res = await fetch(`${API}/aging/export?type=receivable&format=pdf`, { headers: headers() });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || 'Error al exportar');
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'ventas_por_cobrar_' + new Date().toISOString().slice(0, 10) + '.pdf';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
alert('Error al exportar: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Nueva Poliza modal ----
|
||||
function showNewEntryModal() {
|
||||
const overlay = document.getElementById('newEntryModalOverlay');
|
||||
@@ -539,13 +607,20 @@ const Accounting = (() => {
|
||||
line.className = 'entry-line';
|
||||
line.style.cssText = 'display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:var(--space-2);margin-bottom:var(--space-2);align-items:center;';
|
||||
line.innerHTML =
|
||||
'<input type="text" placeholder="Cuenta contable" class="entry-account" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />' +
|
||||
accountSelectHtml() +
|
||||
'<input type="number" placeholder="Debe" class="entry-debit" step="0.01" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />' +
|
||||
'<input type="number" placeholder="Haber" class="entry-credit" step="0.01" style="padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface-2);color:var(--color-text-primary);font-size:var(--text-body-sm);" />' +
|
||||
'<button class="btn btn--ghost btn--sm" onclick="this.closest(\'.entry-line\').remove()">×</button>';
|
||||
container.appendChild(line);
|
||||
}
|
||||
|
||||
function populateEntryAccountSelects() {
|
||||
document.querySelectorAll('#entryLines .entry-account').forEach(sel => {
|
||||
const selected = sel.value;
|
||||
sel.outerHTML = accountSelectHtml(selected);
|
||||
});
|
||||
}
|
||||
|
||||
async function submitNewEntry() {
|
||||
const date = document.getElementById('entryDate').value;
|
||||
const type = document.getElementById('entryType').value;
|
||||
@@ -559,16 +634,16 @@ const Accounting = (() => {
|
||||
|
||||
const lines = [];
|
||||
document.querySelectorAll('#entryLines .entry-line').forEach(row => {
|
||||
const account = row.querySelector('.entry-account').value.trim();
|
||||
const accountId = row.querySelector('.entry-account').value;
|
||||
const debit = parseFloat(row.querySelector('.entry-debit').value) || 0;
|
||||
const credit = parseFloat(row.querySelector('.entry-credit').value) || 0;
|
||||
if (account && (debit || credit)) {
|
||||
lines.push({ account, debit, credit });
|
||||
if (accountId && (debit || credit)) {
|
||||
lines.push({ account_id: parseInt(accountId, 10), debit, credit });
|
||||
}
|
||||
});
|
||||
|
||||
if (!lines.length) {
|
||||
resultEl.innerHTML = '<span style="color:var(--color-error);">Agregue al menos una partida.</span>';
|
||||
if (lines.length < 2) {
|
||||
resultEl.innerHTML = '<span style="color:var(--color-error);">Agregue al menos dos partidas.</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -586,19 +661,23 @@ const Accounting = (() => {
|
||||
|
||||
// Expose switchTab globally for onclick handlers in HTML
|
||||
window.switchTab = switchTab;
|
||||
function registerPayablePayment(payableId) {
|
||||
// Placeholder until supplier payments module is implemented
|
||||
alert('Registrar pago a proveedor — próximamente' + (payableId ? ' (OC ' + payableId + ')' : ''));
|
||||
}
|
||||
|
||||
function runPeriodClose() {
|
||||
// Placeholder until fiscal period close module is implemented
|
||||
alert('Ejecutar cierre de período — próximamente');
|
||||
}
|
||||
|
||||
window.exportarContabilidad = exportarContabilidad;
|
||||
window.exportarCuentasPorCobrar = exportarCuentasPorCobrar;
|
||||
window.showNewEntryModal = showNewEntryModal;
|
||||
window.closeNewEntryModal = closeNewEntryModal;
|
||||
window.addEntryLine = addEntryLine;
|
||||
window.submitNewEntry = submitNewEntry;
|
||||
window.Accounting = {
|
||||
switchTab, loadAging, loadAccountsPayable, loadBalanceSheet,
|
||||
loadIncomeStatement, loadCashFlow, loadReconciliation, loadPeriodClose,
|
||||
exportarContabilidad, showNewEntryModal, closeNewEntryModal, addEntryLine, submitNewEntry,
|
||||
showReceivableDetail, closeReceivableDetail, cancelReceivable,
|
||||
};
|
||||
|
||||
return window.Accounting;
|
||||
// Register Cmd+K items
|
||||
if (typeof registerCmdKItem === "function") {
|
||||
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
|
||||
@@ -607,4 +686,13 @@ const Accounting = (() => {
|
||||
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
|
||||
}
|
||||
|
||||
window.Accounting = {
|
||||
switchTab, loadAging, loadAccountsPayable, loadBalanceSheet,
|
||||
loadIncomeStatement, loadCashFlow, loadReconciliation, loadPeriodClose,
|
||||
exportarContabilidad, showNewEntryModal, closeNewEntryModal, addEntryLine, submitNewEntry,
|
||||
showReceivableDetail, closeReceivableDetail, cancelReceivable,
|
||||
registerPayablePayment, runPeriodClose,
|
||||
};
|
||||
|
||||
return window.Accounting;
|
||||
})();
|
||||
27
pos/static/js/api.js
Normal file
27
pos/static/js/api.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// /home/Autopartes/pos/static/js/api.js
|
||||
// Minimal shared API helper used by standalone pages (e.g. historical_sales).
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const token = localStorage.getItem('pos_token') || '';
|
||||
const url = path.startsWith('http') ? path : path;
|
||||
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Authorization': token ? 'Bearer ' + token : '',
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {})
|
||||
}
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
window.location.href = '/pos/login';
|
||||
throw new Error('Sesión expirada');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || data.message || 'Error ' + res.status);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -304,7 +304,7 @@ const Config = (() => {
|
||||
|
||||
async function saveEmployee(data) {
|
||||
// Check if we're editing (modal has editId) or creating
|
||||
var modal = document.getElementById('employee-modal');
|
||||
var modal = document.getElementById('modal-employee');
|
||||
var editId = modal ? modal.dataset.editId : null;
|
||||
var url = API + '/employees';
|
||||
var method = 'POST';
|
||||
@@ -368,24 +368,25 @@ const Config = (() => {
|
||||
var emp = (json.data || []).find(function(e) { return e.id === empId; });
|
||||
if (!emp) { toast('Empleado no encontrado', 'error'); return; }
|
||||
|
||||
// Pre-fill the "new employee" modal with existing data for editing
|
||||
setVal('new-emp-name', emp.name);
|
||||
setVal('new-emp-email', emp.email || '');
|
||||
var roleSelect = document.getElementById('new-emp-role');
|
||||
// Pre-fill the employee modal with existing data for editing
|
||||
setVal('emp-name', emp.name);
|
||||
setVal('emp-email', emp.email || '');
|
||||
setVal('emp-phone', emp.phone || '');
|
||||
var roleSelect = document.getElementById('emp-role');
|
||||
if (roleSelect) roleSelect.value = emp.role || 'cashier';
|
||||
var branchSelect = document.getElementById('new-emp-branch');
|
||||
var branchSelect = document.getElementById('emp-branch');
|
||||
if (branchSelect) branchSelect.value = emp.branch_id || '';
|
||||
setVal('new-emp-discount', emp.max_discount_pct || '');
|
||||
setVal('new-emp-pin', ''); // Don't pre-fill PIN for security
|
||||
setVal('emp-discount', emp.max_discount_pct || '');
|
||||
setVal('emp-pin', ''); // Don't pre-fill PIN for security
|
||||
|
||||
// Store the ID so saveEmployee knows it's an update
|
||||
var modal = document.getElementById('employee-modal');
|
||||
var modal = document.getElementById('modal-employee');
|
||||
if (modal) {
|
||||
modal.dataset.editId = empId;
|
||||
var title = modal.querySelector('.modal-title, h3');
|
||||
if (title) title.textContent = 'Editar Empleado';
|
||||
}
|
||||
openModal('employee-modal');
|
||||
openModal('modal-employee');
|
||||
} catch (e) {
|
||||
toast('Error: ' + e.message, 'error');
|
||||
}
|
||||
@@ -442,6 +443,25 @@ const Config = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAll() {
|
||||
if (!checkAuth()) return;
|
||||
var btn = document.getElementById('btn-save-all');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Guardando...'; }
|
||||
try {
|
||||
await saveBusiness();
|
||||
await saveTaxParams();
|
||||
await saveCurrency();
|
||||
await saveVehicleCompatSource();
|
||||
await saveAllowedBrands();
|
||||
await saveModules();
|
||||
toast('Configuración guardada', 'ok');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Guardar Cambios'; }
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Event bindings
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -506,6 +526,12 @@ const Config = (() => {
|
||||
var btnNewEmp = document.getElementById('btn-new-employee');
|
||||
if (btnNewEmp) {
|
||||
btnNewEmp.addEventListener('click', function() {
|
||||
var modal = document.getElementById('modal-employee');
|
||||
if (modal) {
|
||||
delete modal.dataset.editId;
|
||||
var title = modal.querySelector('.modal-title, h3');
|
||||
if (title) title.textContent = 'Nuevo Empleado';
|
||||
}
|
||||
openModal('modal-employee');
|
||||
});
|
||||
}
|
||||
@@ -529,6 +555,7 @@ const Config = (() => {
|
||||
|
||||
btnSaveEmp.disabled = true;
|
||||
btnSaveEmp.textContent = 'Guardando...';
|
||||
var isEdit = !!document.getElementById('modal-employee').dataset.editId;
|
||||
try {
|
||||
await saveEmployee({
|
||||
name: name,
|
||||
@@ -539,7 +566,7 @@ const Config = (() => {
|
||||
branch_id: branchId ? parseInt(branchId, 10) : null,
|
||||
max_discount_pct: parseFloat(document.getElementById('emp-discount').value) || 0
|
||||
});
|
||||
toast('Empleado creado');
|
||||
toast(isEdit ? 'Empleado actualizado' : 'Empleado creado');
|
||||
closeModal('modal-employee');
|
||||
// Reset form
|
||||
document.getElementById('emp-name').value = '';
|
||||
@@ -815,6 +842,10 @@ const Config = (() => {
|
||||
// Bind UI events
|
||||
bindEvents();
|
||||
|
||||
// Global save button
|
||||
var btnSaveAll = document.getElementById('btn-save-all');
|
||||
if (btnSaveAll) btnSaveAll.addEventListener('click', saveAll);
|
||||
|
||||
// Vehicle compat source save button
|
||||
var btnCompat = document.getElementById('btn-save-compat-source');
|
||||
if (btnCompat) {
|
||||
@@ -854,14 +885,6 @@ const Config = (() => {
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
return {
|
||||
init, setTheme, selectThemeOption, loadAllowedBrands, saveAllowedBrands,
|
||||
loadBranches, loadEmployees, saveBranch, saveEmployee, editEmployee,
|
||||
loadBusiness, saveBusiness, saveTaxParams,
|
||||
loadCurrency, saveCurrency,
|
||||
loadModules, saveModules,
|
||||
openModal, closeModal, openBranchModal, editBranch
|
||||
};
|
||||
// Register Cmd+K items
|
||||
if (typeof registerCmdKItem === "function") {
|
||||
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
|
||||
@@ -870,4 +893,14 @@ const Config = (() => {
|
||||
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
|
||||
}
|
||||
|
||||
return {
|
||||
init, setTheme, selectThemeOption, loadAllowedBrands, saveAllowedBrands,
|
||||
loadBranches, loadEmployees, saveBranch, saveEmployee, editEmployee,
|
||||
loadBusiness, saveBusiness, saveTaxParams, saveAll,
|
||||
loadCurrency, saveCurrency,
|
||||
loadVehicleCompatSource, saveVehicleCompatSource,
|
||||
loadModules, saveModules,
|
||||
openModal, closeModal, openBranchModal, editBranch
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
@@ -43,6 +43,9 @@ const Customers = (() => {
|
||||
const tierClass = { 1: 'mostrador', 2: 'taller', 3: 'mayoreo' };
|
||||
|
||||
function statusBadge(c) {
|
||||
if (c.is_active === false) {
|
||||
return '<span class="badge badge--inactive"><span class="badge-dot"></span>Inactivo</span>';
|
||||
}
|
||||
// Derive status: if credit_balance > credit_limit => Mora, else Activo
|
||||
if (c.credit_balance > 0 && c.credit_limit > 0 && c.credit_balance > c.credit_limit) {
|
||||
return '<span class="badge badge--warning"><span class="badge-dot"></span>Mora</span>';
|
||||
@@ -82,14 +85,27 @@ const Customers = (() => {
|
||||
const searchEl = document.getElementById('searchInput');
|
||||
q = q !== undefined ? q : (searchEl ? searchEl.value || '' : '');
|
||||
|
||||
const tipoEl = document.getElementById('tipoFilter');
|
||||
const estadoEl = document.getElementById('estadoFilter');
|
||||
const tipo = tipoEl ? tipoEl.value : '';
|
||||
// Map UI status labels to backend values
|
||||
const estadoMap = { 'Activo': 'active', 'Inactivo': 'inactive', 'Mora': 'overdue' };
|
||||
const estado = estadoEl ? (estadoMap[estadoEl.value] || 'all') : 'active';
|
||||
|
||||
const tbody = document.getElementById('customersBody');
|
||||
if (tbody) tbody.innerHTML = '<tr><td colspan="11">' + renderLoadingState({ message: 'Cargando clientes...' }) + '</td></tr>';
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ page, per_page: 50 });
|
||||
if (q) params.append('q', q);
|
||||
if (tipo) params.append('price_tier', tipo);
|
||||
if (estado) params.append('status', estado);
|
||||
|
||||
const data = await api(`/pos/api/customers?${params}`);
|
||||
renderTable(data.data || []);
|
||||
renderPagination(data.pagination || {});
|
||||
} catch (e) {
|
||||
if (tbody) tbody.innerHTML = '<tr><td colspan="11">' + renderEmptyState({ title: 'Error', subtitle: 'No se pudieron cargar los clientes.' }) + '</td></tr>';
|
||||
console.error('Load customers failed:', e);
|
||||
}
|
||||
}
|
||||
@@ -136,7 +152,7 @@ const Customers = (() => {
|
||||
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
|
||||
title: 'Sin clientes',
|
||||
subtitle: 'No se encontraron clientes registrados.',
|
||||
action: '<button class="btn btn--primary btn--sm" onclick="Customers.openCreateModal()">Nuevo cliente</button>'
|
||||
action: '<button class="btn btn--primary btn--sm" onclick="Customers.showCreateModal()">Nuevo cliente</button>'
|
||||
}) + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
@@ -650,7 +666,7 @@ const Customers = (() => {
|
||||
try {
|
||||
await api(`/pos/api/customers/${currentCustomer.id}/payment`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ amount, method, reference }),
|
||||
body: JSON.stringify({ amount, payment_method: method, reference }),
|
||||
});
|
||||
closePayment();
|
||||
selectCustomer(currentCustomer.id);
|
||||
@@ -892,6 +908,14 @@ const Customers = (() => {
|
||||
showCustomerHistory, closeCustomerHistoryModal,
|
||||
};
|
||||
|
||||
// Register Cmd+K items
|
||||
if (typeof registerCmdKItem === "function") {
|
||||
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
|
||||
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
|
||||
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
|
||||
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
|
||||
}
|
||||
|
||||
// Bulk selection
|
||||
publicApi.toggleCustomerSelection = function(id) {
|
||||
if (selectedCustomers.has(id)) selectedCustomers.delete(id);
|
||||
@@ -927,15 +951,15 @@ const Customers = (() => {
|
||||
updateBulkToolbar();
|
||||
};
|
||||
|
||||
publicApi.featureProximamente = function(name) {
|
||||
if (typeof window.featureProximamente === 'function') {
|
||||
window.featureProximamente(name);
|
||||
} else {
|
||||
alert(name + ' — próximamente');
|
||||
}
|
||||
};
|
||||
|
||||
// Expose globally for inline HTML onclick handlers
|
||||
window.Customers = publicApi;
|
||||
return publicApi;
|
||||
// Register Cmd+K items
|
||||
if (typeof registerCmdKItem === "function") {
|
||||
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
|
||||
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
|
||||
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
|
||||
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
@@ -367,16 +367,51 @@ const Dashboard = (() => {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4. Top Products (from today's sales detail)
|
||||
// 4. Credit alerts
|
||||
// -------------------------------------------------------------------------
|
||||
async function loadCreditAlerts() {
|
||||
const data = await apiFetch('/pos/api/dashboard/credit-alerts');
|
||||
const tbody = document.getElementById('credit-alerts-tbody');
|
||||
const meta = document.getElementById('credit-alerts-meta');
|
||||
if (!tbody) return;
|
||||
|
||||
if (!data || !data.data || data.data.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:var(--space-4);color:var(--color-text-muted);">No hay créditos por vencer.</td></tr>';
|
||||
if (meta) meta.textContent = 'Vencidos: 0 / Por vencer: 0';
|
||||
return;
|
||||
}
|
||||
|
||||
if (meta) {
|
||||
meta.innerHTML = `<span style="color:var(--color-error);font-weight:600;">Vencidos: ${data.overdue_count || 0}</span> | <span style="color:var(--color-warning);font-weight:600;">Por vencer: ${data.due_soon_count || 0}</span>`;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.data.map(function(r) {
|
||||
const dueDate = r.due_date ? new Date(r.due_date).toLocaleDateString('es-MX') : '-';
|
||||
const daysText = r.days_until_due < 0 ? `${Math.abs(r.days_until_due)} días vencido` : `${r.days_until_due} días restantes`;
|
||||
const statusClass = r.status === 'overdue' ? 'error' : (r.status === 'due_soon' ? 'warning' : 'success');
|
||||
return `<tr>
|
||||
<td><span class="td-client">${escHtml(r.customer_name)}</span></td>
|
||||
<td><span class="td-mono">${escHtml(r.folio)}</span></td>
|
||||
<td>${dueDate}</td>
|
||||
<td>${daysText}</td>
|
||||
<td class="align-right"><span class="td-mono">${fmt(r.balance)}</span></td>
|
||||
<td><span class="badge badge--${statusClass}">${r.status_label}</span></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5. Top Products (from today's sales detail)
|
||||
// -------------------------------------------------------------------------
|
||||
async function loadTopProducts() {
|
||||
const today = todayStr();
|
||||
// Fetch all today's sales with pagination
|
||||
const data = await apiFetch(`/pos/api/sales?date_from=${today}&date_to=${today}&status=completed&per_page=200`);
|
||||
// Single optimized endpoint: returns today's top products already aggregated
|
||||
const data = await apiFetch('/pos/api/dashboard/stats');
|
||||
const container = document.getElementById('top-products-list');
|
||||
if (!container) return;
|
||||
|
||||
if (!data || !data.data || data.data.length === 0) {
|
||||
const top = data && data.top_products ? data.top_products : [];
|
||||
|
||||
if (!top.length) {
|
||||
container.innerHTML = renderEmptyState({
|
||||
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M8 21h8M12 17v4"/></svg>',
|
||||
title: 'Sin ventas hoy',
|
||||
@@ -386,37 +421,7 @@ const Dashboard = (() => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch detail for each sale to get items (up to 20 sales for performance)
|
||||
const salesToFetch = data.data.slice(0, 20);
|
||||
const details = await Promise.all(
|
||||
salesToFetch.map(s => apiFetch(`/pos/api/sales/${s.id}`))
|
||||
);
|
||||
|
||||
// Aggregate items
|
||||
const productMap = {};
|
||||
for (const sale of details) {
|
||||
if (!sale || !sale.items) continue;
|
||||
for (const item of sale.items) {
|
||||
const key = item.part_number || item.name;
|
||||
if (!productMap[key]) {
|
||||
productMap[key] = { name: item.name, part_number: item.part_number || '', qty: 0, revenue: 0 };
|
||||
}
|
||||
productMap[key].qty += item.quantity || 0;
|
||||
productMap[key].revenue += item.subtotal || 0;
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = Object.values(productMap).sort((a, b) => b.revenue - a.revenue).slice(0, 5);
|
||||
|
||||
if (sorted.length === 0) {
|
||||
container.innerHTML = renderEmptyState({
|
||||
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 01-8 0"/></svg>',
|
||||
title: 'Sin productos vendidos',
|
||||
subtitle: 'No hay suficiente información para mostrar el ranking.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const sorted = top.slice(0, 5);
|
||||
const maxRev = sorted[0].revenue || 1;
|
||||
container.innerHTML = sorted.map((p, i) => {
|
||||
const pct = Math.round((p.revenue / maxRev) * 100);
|
||||
@@ -425,7 +430,7 @@ const Dashboard = (() => {
|
||||
<div class="rank-num ${i === 0 ? 'rank-num--1' : i === 1 ? 'rank-num--2' : ''}">${i + 1}</div>
|
||||
<div class="rank-item__info">
|
||||
<div class="rank-item__name">${escHtml(p.name)}</div>
|
||||
<div class="rank-item__sub">${escHtml(p.part_number)} · ${p.qty} pzas vendidas</div>
|
||||
<div class="rank-item__sub">${p.quantity} pzas vendidas</div>
|
||||
<div class="rank-item__bar-bg">
|
||||
<div class="rank-item__bar-fill" style="width:${pct}%"></div>
|
||||
</div>
|
||||
@@ -619,11 +624,12 @@ const Dashboard = (() => {
|
||||
// -------------------------------------------------------------------------
|
||||
async function loadRecentSales() {
|
||||
const today = todayStr();
|
||||
const data = await apiFetch(`/pos/api/sales?date_from=${today}&date_to=${today}&per_page=10`);
|
||||
const data = await apiFetch(`/pos/api/sales/recent?date_from=${today}&date_to=${today}&limit=10`);
|
||||
const tbody = document.getElementById('recent-sales-tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
if (!data || !data.data || data.data.length === 0) {
|
||||
const sales = data && data.data ? data.data : [];
|
||||
if (!sales.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5">' + renderEmptyState({
|
||||
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M8 21h8M12 17v4"/></svg>',
|
||||
title: 'Sin ventas hoy',
|
||||
@@ -633,26 +639,22 @@ const Dashboard = (() => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch items for first 5 sales
|
||||
const salesToShow = data.data.slice(0, 5);
|
||||
const details = await Promise.all(
|
||||
salesToShow.map(s => apiFetch(`/pos/api/sales/${s.id}`))
|
||||
);
|
||||
const salesToShow = sales.slice(0, 5);
|
||||
|
||||
tbody.innerHTML = salesToShow.map((sale, idx) => {
|
||||
const detail = details[idx];
|
||||
tbody.innerHTML = salesToShow.map((sale) => {
|
||||
const time = sale.created_at ? sale.created_at.slice(11, 16) : '--:--';
|
||||
const client = sale.customer_name || 'Publico General';
|
||||
const total = sale.total || 0;
|
||||
const method = sale.payment_method || 'efectivo';
|
||||
|
||||
// Build products summary from detail items
|
||||
// Build products summary from items already included in the response
|
||||
let productsSummary = '';
|
||||
if (detail && detail.items && detail.items.length > 0) {
|
||||
productsSummary = detail.items.slice(0, 3).map(it =>
|
||||
const items = sale.items || [];
|
||||
if (items.length > 0) {
|
||||
productsSummary = items.slice(0, 3).map(it =>
|
||||
`${escHtml(it.name)}${it.quantity > 1 ? ' (x' + it.quantity + ')' : ''}`
|
||||
).join(', ');
|
||||
if (detail.items.length > 3) productsSummary += '...';
|
||||
if (items.length > 3) productsSummary += '...';
|
||||
}
|
||||
|
||||
const methodClass = getPaymentBadgeClass(method);
|
||||
@@ -701,6 +703,7 @@ const Dashboard = (() => {
|
||||
loadDailySummary();
|
||||
loadHistoricalSummary();
|
||||
loadAlerts();
|
||||
loadCreditAlerts();
|
||||
loadTopProducts();
|
||||
loadChart('semana');
|
||||
loadRecentSales();
|
||||
@@ -709,6 +712,7 @@ const Dashboard = (() => {
|
||||
setInterval(() => {
|
||||
loadDailySummary();
|
||||
loadRecentSales();
|
||||
loadCreditAlerts();
|
||||
}, 120000);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,16 @@ var Fleet = (function() {
|
||||
var currentPage = 1;
|
||||
var searchTimeout = null;
|
||||
|
||||
var user = window.POS_USER || {};
|
||||
var role = (user.role || '').toLowerCase();
|
||||
var perms = user.permissions || [];
|
||||
function hasPerm(p) {
|
||||
return role === 'owner' || perms.indexOf(p) !== -1;
|
||||
}
|
||||
var canCreate = hasPerm('fleet.create');
|
||||
var canEdit = hasPerm('fleet.edit');
|
||||
var canDelete = hasPerm('fleet.delete');
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
function headers() {
|
||||
@@ -75,9 +85,16 @@ var Fleet = (function() {
|
||||
}
|
||||
|
||||
// New vehicle button
|
||||
document.getElementById('btnNewVehicle').addEventListener('click', function() {
|
||||
openVehicleModal();
|
||||
});
|
||||
var btnNewVehicle = document.getElementById('btnNewVehicle');
|
||||
if (btnNewVehicle) {
|
||||
if (!canCreate) {
|
||||
btnNewVehicle.style.display = 'none';
|
||||
} else {
|
||||
btnNewVehicle.addEventListener('click', function() {
|
||||
openVehicleModal();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Load initial data
|
||||
loadStats();
|
||||
@@ -105,6 +122,8 @@ var Fleet = (function() {
|
||||
var url = API + '/vehicles?page=' + currentPage + '&per_page=50';
|
||||
if (q) url += '&q=' + encodeURIComponent(q);
|
||||
|
||||
document.getElementById('vehicleGrid').innerHTML = renderLoadingState({ message: 'Cargando vehiculos...' });
|
||||
|
||||
fetch(url, {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
@@ -115,7 +134,7 @@ var Fleet = (function() {
|
||||
})
|
||||
.catch(function() {
|
||||
document.getElementById('vehicleGrid').innerHTML =
|
||||
'<div class="empty-state"><div class="empty-state__text">Error al cargar vehiculos</div></div>';
|
||||
renderEmptyState({ title: 'Error', subtitle: 'No se pudieron cargar los vehiculos.' });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -125,7 +144,7 @@ var Fleet = (function() {
|
||||
grid.innerHTML = '<div class="empty-state">' +
|
||||
'<div class="empty-state__icon">🚚</div>' +
|
||||
'<div class="empty-state__text">No hay vehiculos registrados</div>' +
|
||||
'<button class="btn btn--primary" onclick="Fleet.openVehicleModal()">+ Agregar Vehiculo</button>' +
|
||||
(canCreate ? '<button class="btn btn--primary" onclick="Fleet.openVehicleModal()">+ Agregar Vehiculo</button>' : '') +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
@@ -135,7 +154,7 @@ var Fleet = (function() {
|
||||
var label = (v.make || '') + ' ' + (v.model || '');
|
||||
if (v.year) label += ' ' + v.year;
|
||||
|
||||
html += '<div class="vehicle-card" onclick="Fleet.viewVehicle(' + v.id + ')">' +
|
||||
html += '<div class="vehicle-card" ' + (canEdit ? 'onclick="Fleet.viewVehicle(' + v.id + ')" style="cursor:pointer;"' : '') + '>' +
|
||||
'<div class="vehicle-card__header">' +
|
||||
'<span class="vehicle-card__plate">' + esc(v.plate || 'SIN PLACA') + '</span>' +
|
||||
'<span class="badge ' + (v.is_active ? 'badge--active' : 'badge--inactive') + '">' +
|
||||
@@ -200,9 +219,11 @@ var Fleet = (function() {
|
||||
function openVehicleModal(data) {
|
||||
var modal = document.getElementById('vehicleModal');
|
||||
var title = document.getElementById('vehicleModalTitle');
|
||||
var saveBtn = document.getElementById('btnSaveVehicle');
|
||||
|
||||
if (data && data.id) {
|
||||
title.textContent = 'Editar Vehiculo';
|
||||
if (saveBtn) saveBtn.style.display = canEdit ? '' : 'none';
|
||||
document.getElementById('vehEditId').value = data.id;
|
||||
document.getElementById('vehPlate').value = data.plate || '';
|
||||
document.getElementById('vehVin').value = data.vin || '';
|
||||
@@ -216,6 +237,7 @@ var Fleet = (function() {
|
||||
document.getElementById('vehNotes').value = data.notes || '';
|
||||
} else {
|
||||
title.textContent = 'Nuevo Vehiculo';
|
||||
if (saveBtn) saveBtn.style.display = canCreate ? '' : 'none';
|
||||
document.getElementById('vehEditId').value = '';
|
||||
['vehPlate','vehVin','vehMake','vehModel','vehYear','vehColor','vehOwner','vehNotes']
|
||||
.forEach(function(id) { document.getElementById(id).value = ''; });
|
||||
@@ -232,6 +254,8 @@ var Fleet = (function() {
|
||||
|
||||
function saveVehicle() {
|
||||
var editId = document.getElementById('vehEditId').value;
|
||||
if (editId && !canEdit) { alert('No tienes permiso para editar vehiculos'); return; }
|
||||
if (!editId && !canCreate) { alert('No tienes permiso para crear vehiculos'); return; }
|
||||
var payload = {
|
||||
plate: document.getElementById('vehPlate').value.trim(),
|
||||
vin: document.getElementById('vehVin').value.trim(),
|
||||
@@ -275,26 +299,36 @@ var Fleet = (function() {
|
||||
// ─── Maintenance Tab ───
|
||||
|
||||
function loadMaintenance() {
|
||||
// Load all vehicles with their schedules
|
||||
fetch(API + '/vehicles?per_page=200', {headers: headers()})
|
||||
// Single bulk endpoint replaces N+1 per-vehicle schedule requests
|
||||
document.getElementById('maintBody').innerHTML =
|
||||
'<tr><td colspan="7" style="padding:var(--space-6);">' + renderLoadingState({ message: 'Cargando programas...' }) + '</td></tr>';
|
||||
|
||||
fetch(API + '/vehicles/schedules', {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
var allVehicles = d.data || [];
|
||||
var promises = allVehicles.map(function(v) {
|
||||
return fetch(API + '/vehicles/' + v.id + '/schedules', {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(s) {
|
||||
return {vehicle: v, schedules: s.data || []};
|
||||
});
|
||||
var schedules = d.data || [];
|
||||
var results = schedules.map(function(s) {
|
||||
return {
|
||||
vehicle: s.vehicle || {},
|
||||
schedules: [{
|
||||
id: s.id,
|
||||
maintenance_type: s.maintenance_type,
|
||||
interval_km: s.interval_km,
|
||||
interval_months: s.interval_months,
|
||||
last_done_at: s.last_done_at,
|
||||
last_done_km: s.last_done_km,
|
||||
next_due_at: s.next_due_at,
|
||||
next_due_km: s.next_due_km,
|
||||
notes: s.notes
|
||||
}]
|
||||
};
|
||||
});
|
||||
return Promise.all(promises);
|
||||
})
|
||||
.then(function(results) {
|
||||
renderMaintenance(results);
|
||||
})
|
||||
.catch(function() {
|
||||
.catch(function(e) {
|
||||
console.error('loadMaintenance error:', e);
|
||||
document.getElementById('maintBody').innerHTML =
|
||||
'<tr><td colspan="7" style="text-align:center;color:var(--color-text-muted);">Error al cargar</td></tr>';
|
||||
'<tr><td colspan="7" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: 'No se pudieron cargar los programas.' }) + '</td></tr>';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -326,7 +360,7 @@ var Fleet = (function() {
|
||||
'<td>' + (next || '—') + '</td>' +
|
||||
'<td><span class="badge ' + (isOverdue ? 'badge--overdue' : 'badge--active') + '">' +
|
||||
(isOverdue ? 'Vencido' : 'Al dia') + '</span></td>' +
|
||||
'<td><button class="btn btn--sm btn--ghost" onclick="Fleet.openLogModalFor(' + v.id + ',' + s.id + ',\'' + esc(s.maintenance_type) + '\')">Registrar</button></td>' +
|
||||
'<td>' + (canCreate ? '<button class="btn btn--sm btn--ghost" onclick="Fleet.openLogModalFor(' + v.id + ',' + s.id + ',\'' + esc(s.maintenance_type) + '\')">Registrar</button>' : '') + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
@@ -344,7 +378,9 @@ var Fleet = (function() {
|
||||
|
||||
if (!items.length) {
|
||||
body.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">' +
|
||||
'No hay programas de mantenimiento.<br><button class="btn btn--primary btn--sm" style="margin-top:var(--space-3);" onclick="Fleet.openScheduleModal()">+ Crear Programa</button></td></tr>';
|
||||
'No hay programas de mantenimiento.<br>' +
|
||||
(canCreate ? '<button class="btn btn--primary btn--sm" style="margin-top:var(--space-3);" onclick="Fleet.openScheduleModal()">+ Crear Programa</button>' : '') +
|
||||
'</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -363,25 +399,25 @@ var Fleet = (function() {
|
||||
// ─── History Tab ───
|
||||
|
||||
function loadHistory() {
|
||||
fetch(API + '/vehicles?per_page=200', {headers: headers()})
|
||||
// Single bulk endpoint replaces N+1 per-vehicle detail requests
|
||||
document.getElementById('historyBody').innerHTML =
|
||||
'<tr><td colspan="7" style="padding:var(--space-6);">' + renderLoadingState({ message: 'Cargando historial...' }) + '</td></tr>';
|
||||
|
||||
fetch(API + '/vehicles/history', {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
var allVehicles = d.data || [];
|
||||
var promises = allVehicles.map(function(v) {
|
||||
return fetch(API + '/vehicles/' + v.id, {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(detail) {
|
||||
return {vehicle: v, logs: detail.recent_logs || []};
|
||||
});
|
||||
var logs = d.data || [];
|
||||
var results = logs.map(function(l) {
|
||||
l._plate = (l.vehicle && l.vehicle.plate) || 'S/P';
|
||||
l._make = ((l.vehicle && l.vehicle.make) || '') + ' ' + ((l.vehicle && l.vehicle.model) || '');
|
||||
return {vehicle: l.vehicle || {}, logs: [l]};
|
||||
});
|
||||
return Promise.all(promises);
|
||||
})
|
||||
.then(function(results) {
|
||||
renderHistory(results);
|
||||
})
|
||||
.catch(function() {
|
||||
.catch(function(e) {
|
||||
console.error('loadHistory error:', e);
|
||||
document.getElementById('historyBody').innerHTML =
|
||||
'<tr><td colspan="7" style="text-align:center;color:var(--color-text-muted);">Error al cargar</td></tr>';
|
||||
'<tr><td colspan="7" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: 'No se pudo cargar el historial.' }) + '</td></tr>';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -437,6 +473,8 @@ var Fleet = (function() {
|
||||
// ─── Alerts Tab ───
|
||||
|
||||
function loadAlerts() {
|
||||
document.getElementById('alertsList').innerHTML = renderLoadingState({ message: 'Cargando alertas...' });
|
||||
|
||||
fetch(API + '/alerts', {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
@@ -444,7 +482,7 @@ var Fleet = (function() {
|
||||
})
|
||||
.catch(function() {
|
||||
document.getElementById('alertsList').innerHTML =
|
||||
'<div class="empty-state"><div class="empty-state__text">Error al cargar alertas</div></div>';
|
||||
renderEmptyState({ title: 'Error', subtitle: 'No se pudieron cargar las alertas.' });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -475,7 +513,7 @@ var Fleet = (function() {
|
||||
' ' + detail +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<button class="btn btn--sm btn--primary" onclick="Fleet.openLogModalFor(' + a.vehicle_id + ',' + a.schedule_id + ',\'' + esc(a.maintenance_type) + '\')">Registrar Mant.</button>' +
|
||||
(canCreate ? '<button class="btn btn--sm btn--primary" onclick="Fleet.openLogModalFor(' + a.vehicle_id + ',' + a.schedule_id + ',\'' + esc(a.maintenance_type) + '\')">Registrar Mant.</button>' : '') +
|
||||
'</div>';
|
||||
});
|
||||
el.innerHTML = html;
|
||||
@@ -504,6 +542,7 @@ var Fleet = (function() {
|
||||
}
|
||||
|
||||
function saveSchedule() {
|
||||
if (!canCreate) { alert('No tienes permiso para crear programas'); return; }
|
||||
var vehicleId = document.getElementById('schedVehicleSelect').value;
|
||||
if (!vehicleId) { alert('Seleccione un vehiculo'); return; }
|
||||
|
||||
@@ -574,6 +613,7 @@ var Fleet = (function() {
|
||||
}
|
||||
|
||||
function saveLog() {
|
||||
if (!canCreate) { alert('No tienes permiso para registrar mantenimiento'); return; }
|
||||
var vehicleId = document.getElementById('logVehicleSelect').value;
|
||||
if (!vehicleId) { alert('Seleccione un vehiculo'); return; }
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
var draftCountId = null;
|
||||
var inventoryVS = null;
|
||||
var compatSource = 'both'; // default, loaded from config
|
||||
var inventorySearchController = null;
|
||||
|
||||
// Load compatibility source setting
|
||||
(function loadCompatSource() {
|
||||
@@ -234,20 +235,27 @@
|
||||
var tbody = document.getElementById('productTableBody');
|
||||
if (tbody) tbody.innerHTML = renderSkeletonRows(12, 8);
|
||||
|
||||
apiFetch(API + '/items?' + params.toString()).then(function (data) {
|
||||
if (!data) return;
|
||||
if (inventorySearchController) {
|
||||
inventorySearchController.abort();
|
||||
}
|
||||
inventorySearchController = new AbortController();
|
||||
|
||||
var items = data.data || [];
|
||||
if (!items.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="12">' + renderEmptyState({
|
||||
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>',
|
||||
title: 'Sin productos',
|
||||
subtitle: currentSearch ? 'No se encontraron resultados para "' + esc(currentSearch) + '". Intenta con otro término.' : 'El inventario está vacío. Crea tu primer producto para empezar.',
|
||||
action: currentSearch ? '<button class="btn btn--ghost btn--sm" onclick="document.getElementById(\'productSearch\').value=\'\';loadItems(1,\'\')">Limpiar búsqueda</button>' : '<button class="btn btn--primary btn--sm" onclick="openCreateModal()">Crear producto</button>'
|
||||
}) + '</td></tr>';
|
||||
document.getElementById('productPagination').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
apiFetch(API + '/items?' + params.toString(), { signal: inventorySearchController.signal })
|
||||
.then(function (data) {
|
||||
inventorySearchController = null;
|
||||
if (!data) return;
|
||||
|
||||
var items = data.data || [];
|
||||
if (!items.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="12">' + renderEmptyState({
|
||||
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>',
|
||||
title: 'Sin productos',
|
||||
subtitle: currentSearch ? 'No se encontraron resultados para "' + esc(currentSearch) + '". Intenta con otro término.' : 'El inventario está vacío. Crea tu primer producto para empezar.',
|
||||
action: currentSearch ? '<button class="btn btn--ghost btn--sm" onclick="document.getElementById(\'productSearch\').value=\'\';loadItems(1,\'\')">Limpiar búsqueda</button>' : '<button class="btn btn--primary btn--sm" onclick="openCreateModal()">Crear producto</button>'
|
||||
}) + '</td></tr>';
|
||||
document.getElementById('productPagination').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!inventoryVS) {
|
||||
inventoryVS = new VirtualScroll({
|
||||
@@ -277,6 +285,9 @@
|
||||
} else {
|
||||
pgEl.innerHTML = '<span style="font-size:var(--text-body-sm);color:var(--color-text-muted);">' + (pg.total || 0) + ' productos</span>';
|
||||
}
|
||||
}).catch(function (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
console.error('Inventory load error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -86,46 +86,93 @@ const Invoicing = (() => {
|
||||
}
|
||||
|
||||
// ---- Facturas (Tab 1) — loads from CFDI queue with type=Ingreso ----
|
||||
let facturasCache = [];
|
||||
|
||||
async function loadFacturas() {
|
||||
const panel = document.getElementById('panel-facturas');
|
||||
if (!panel) return;
|
||||
const tbody = panel.querySelector('.data-table tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
try {
|
||||
const res = await api('/queue?per_page=50&type=Ingreso');
|
||||
const items = res.data || [];
|
||||
if (!items.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay facturas en este periodo.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = items.map(item => `<tr>
|
||||
<td class="td--mono">${item.provisional_folio || item.id || '-'}</td>
|
||||
<td class="td--primary">${item.serie || '-'}</td>
|
||||
<td class="td--primary">${item.customer_name || '-'}</td>
|
||||
<td class="td--mono">${item.rfc || '-'}</td>
|
||||
<td class="td--amount">$${fmt(item.subtotal)}</td>
|
||||
<td class="td--amount">$${fmt(item.tax)}</td>
|
||||
<td class="td--amount">$${fmt(item.total)}</td>
|
||||
<td style="font-size:var(--text-caption);">${item.uso_cfdi || '-'}</td>
|
||||
<td>${statusBadge(item.status)}</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">Ver</button>
|
||||
${item.sale_id ? `<a href="${API}/${item.sale_id}/pdf" target="_blank" class="btn btn--ghost btn--sm">PDF</a>` : ''}
|
||||
${item.status === 'stamped' ? `<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">XML</button>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
const statusFilter = document.getElementById('facturas-status-filter');
|
||||
const status = statusFilter ? statusFilter.value : '';
|
||||
const url = status ? `/queue?per_page=50&type=ingreso&status=${status}` : '/queue?per_page=50&type=ingreso';
|
||||
|
||||
// Update footer count
|
||||
const footer = panel.querySelector('.table-footer span');
|
||||
if (footer) footer.textContent = `Mostrando 1\u2013${items.length} de ${res.pagination?.total || items.length} facturas`;
|
||||
tbody.innerHTML = '<tr><td colspan="10" style="padding:var(--space-6);">' + renderLoadingState({ message: 'Cargando facturas...' }) + '</td></tr>';
|
||||
|
||||
try {
|
||||
const res = await api(url);
|
||||
facturasCache = res.data || [];
|
||||
renderFacturas(facturasCache, res.pagination?.total || facturasCache.length);
|
||||
} catch (e) {
|
||||
tbody.innerHTML = `<tr><td colspan="10" style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</td></tr>`;
|
||||
tbody.innerHTML = '<tr><td colspan="10" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: e.message }) + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderFacturas(items, total) {
|
||||
const panel = document.getElementById('panel-facturas');
|
||||
const tbody = panel.querySelector('.data-table tbody');
|
||||
if (!items.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Sin facturas', subtitle: 'No hay facturas en este periodo.' }) + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = items.map(item => `<tr>
|
||||
<td class="td--mono">${item.provisional_folio || item.id || '-'}</td>
|
||||
<td class="td--primary">${item.serie || '-'}</td>
|
||||
<td class="td--primary">${item.customer_name || '-'}</td>
|
||||
<td class="td--mono">${item.rfc || '-'}</td>
|
||||
<td class="td--amount">$${fmt(item.subtotal)}</td>
|
||||
<td class="td--amount">$${fmt(item.tax_total)}</td>
|
||||
<td class="td--amount">$${fmt(item.total)}</td>
|
||||
<td style="font-size:var(--text-caption);">${item.payment_method || '-'}</td>
|
||||
<td>${statusBadge(item.status)}</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">Ver</button>
|
||||
${item.sale_id ? `<a href="${API}/${item.sale_id}/pdf" target="_blank" class="btn btn--ghost btn--sm">PDF</a>` : ''}
|
||||
${item.status === 'stamped' ? `<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">XML</button>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
|
||||
const footer = panel.querySelector('.table-footer span');
|
||||
if (footer) footer.textContent = `Mostrando 1\u2013${items.length} de ${total} facturas`;
|
||||
}
|
||||
|
||||
function filterFacturas() {
|
||||
const q = (document.getElementById('facturas-search')?.value || '').toLowerCase();
|
||||
const filtered = facturasCache.filter(item =>
|
||||
(item.provisional_folio || '').toLowerCase().includes(q) ||
|
||||
(item.customer_name || '').toLowerCase().includes(q) ||
|
||||
(item.rfc || '').toLowerCase().includes(q)
|
||||
);
|
||||
renderFacturas(filtered, facturasCache.length);
|
||||
}
|
||||
|
||||
function exportFacturasCSV() {
|
||||
if (typeof window.exportVisibleTableCSV === 'function') {
|
||||
window.exportVisibleTableCSV('facturas');
|
||||
} else {
|
||||
alert('Exportador no disponible');
|
||||
}
|
||||
}
|
||||
|
||||
function exportNotasCSV() {
|
||||
if (typeof window.exportVisibleTableCSV === 'function') {
|
||||
window.exportVisibleTableCSV('notas_de_credito');
|
||||
} else {
|
||||
alert('Exportador no disponible');
|
||||
}
|
||||
}
|
||||
|
||||
function newCreditNote() {
|
||||
alert('Nueva nota de crédito — próximamente');
|
||||
}
|
||||
|
||||
function newPaymentComplement() {
|
||||
alert('Nuevo complemento de pago — próximamente');
|
||||
}
|
||||
|
||||
// ---- Notas de Credito (Tab 2) — loads from CFDI queue with type=Egreso ----
|
||||
async function loadNotas() {
|
||||
const panel = document.getElementById('panel-notas');
|
||||
@@ -133,18 +180,20 @@ const Invoicing = (() => {
|
||||
const tbody = panel.querySelector('.data-table tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="padding:var(--space-6);">' + renderLoadingState({ message: 'Cargando notas de credito...' }) + '</td></tr>';
|
||||
|
||||
try {
|
||||
const res = await api('/queue?per_page=50&type=Egreso');
|
||||
const res = await api('/queue?per_page=50&type=egreso');
|
||||
const items = res.data || [];
|
||||
if (!items.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay notas de credito.</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Sin notas de credito', subtitle: 'No hay notas de credito registradas.' }) + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = items.map(item => `<tr>
|
||||
<td class="td--mono">${item.provisional_folio || '-'}</td>
|
||||
<td class="td--mono" style="color:var(--color-text-accent);">${item.related_folio || '-'}</td>
|
||||
<td class="td--primary">${item.customer_name || '-'}</td>
|
||||
<td>${item.description || '-'}</td>
|
||||
<td>${item.cancel_motive || '-'}</td>
|
||||
<td class="td--amount">$${fmt(item.total)}</td>
|
||||
<td>${statusBadge(item.status)}</td>
|
||||
<td>
|
||||
@@ -155,7 +204,7 @@ const Invoicing = (() => {
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
} catch (e) {
|
||||
tbody.innerHTML = `<tr><td colspan="7" style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</td></tr>`;
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: e.message }) + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,33 +215,47 @@ const Invoicing = (() => {
|
||||
const tbody = panel.querySelector('.data-table tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="padding:var(--space-6);">' + renderLoadingState({ message: 'Cargando complementos...' }) + '</td></tr>';
|
||||
|
||||
try {
|
||||
const res = await api('/queue?per_page=50&type=Pago');
|
||||
const items = res.data || [];
|
||||
if (!items.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:var(--space-6);color:var(--color-text-muted);">No hay complementos de pago.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = items.map(item => `<tr>
|
||||
<td class="td--mono">${item.provisional_folio || '-'}</td>
|
||||
<td class="td--mono" style="color:var(--color-text-accent);">${item.related_folio || '-'}</td>
|
||||
<td class="td--primary">${item.customer_name || '-'}</td>
|
||||
<td class="td--amount">$${fmt(item.total)}</td>
|
||||
<td style="font-size:var(--text-caption);">${item.payment_method || '-'}</td>
|
||||
<td>${item.created_at ? new Date(item.created_at).toLocaleDateString('es-MX') : '-'}</td>
|
||||
<td>${statusBadge(item.status)}</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">Ver</button>
|
||||
${item.status === 'stamped' ? `<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">XML</button>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
const res = await api('/queue?per_page=50&type=pago');
|
||||
complementosCache = res.data || [];
|
||||
renderComplementos(complementosCache);
|
||||
} catch (e) {
|
||||
tbody.innerHTML = `<tr><td colspan="8" style="color:var(--color-error);padding:var(--space-4);">Error: ${e.message}</td></tr>`;
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Error', subtitle: e.message }) + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
let complementosCache = [];
|
||||
|
||||
function renderComplementos(items) {
|
||||
const panel = document.getElementById('panel-complementos');
|
||||
const tbody = panel.querySelector('.data-table tbody');
|
||||
const methodFilter = document.getElementById('complementos-method-filter');
|
||||
const method = methodFilter ? methodFilter.value : '';
|
||||
const filtered = method ? items.filter(item => (item.payment_method || '').startsWith(method)) : items;
|
||||
|
||||
if (!filtered.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="padding:var(--space-6);">' + renderEmptyState({ title: 'Sin complementos', subtitle: 'No hay complementos de pago registrados.' }) + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = filtered.map(item => `<tr>
|
||||
<td class="td--mono">${item.provisional_folio || '-'}</td>
|
||||
<td class="td--mono" style="color:var(--color-text-accent);">${item.related_folio || '-'}</td>
|
||||
<td class="td--primary">${item.customer_name || '-'}</td>
|
||||
<td class="td--amount">$${fmt(item.total)}</td>
|
||||
<td style="font-size:var(--text-caption);">${item.payment_method || '-'}</td>
|
||||
<td>${item.created_at ? new Date(item.created_at).toLocaleDateString('es-MX') : '-'}</td>
|
||||
<td>${statusBadge(item.status)}</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:4px;">
|
||||
<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">Ver</button>
|
||||
${item.status === 'stamped' ? `<button class="btn btn--ghost btn--sm" onclick="Invoicing.showDetail(${item.id})">XML</button>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
// ---- Cancelaciones (Tab 4) — loads cancelled/cancelling CFDIs ----
|
||||
async function loadCancelaciones() {
|
||||
const panel = document.getElementById('panel-cancelaciones');
|
||||
@@ -674,13 +737,6 @@ const Invoicing = (() => {
|
||||
window.submitNewInvoice = submitNewInvoice;
|
||||
window.notaCreditoPlaceholder = notaCreditoPlaceholder;
|
||||
|
||||
return {
|
||||
switchTab, loadFacturas, loadNotas, loadComplementos, loadCancelaciones, loadFacturapiStatus,
|
||||
showDetail, showCancelModal, confirmCancel, processQueue,
|
||||
showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder,
|
||||
openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice, setupFacturapi,
|
||||
uploadCsd, resetCsdForm,
|
||||
};
|
||||
// Register Cmd+K items
|
||||
if (typeof registerCmdKItem === "function") {
|
||||
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
|
||||
@@ -689,4 +745,14 @@ const Invoicing = (() => {
|
||||
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
|
||||
}
|
||||
|
||||
return {
|
||||
switchTab, loadFacturas, loadNotas, loadComplementos, loadCancelaciones, loadFacturapiStatus,
|
||||
showDetail, showCancelModal, confirmCancel, processQueue,
|
||||
showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder,
|
||||
openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice, setupFacturapi,
|
||||
uploadCsd, resetCsdForm,
|
||||
filterFacturas, exportFacturasCSV, exportNotasCSV,
|
||||
newCreditNote, newPaymentComplement,
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
window.startOAuth = function() {
|
||||
window.startOAuth = async function() {
|
||||
var clientId = document.getElementById('cfgClientId').value.trim();
|
||||
var clientSecret = document.getElementById('cfgClientSecret').value.trim();
|
||||
var category = document.getElementById('cfgCategory').value.trim();
|
||||
@@ -75,15 +75,34 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Save config locally for the callback
|
||||
localStorage.setItem('meli_client_id', clientId);
|
||||
localStorage.setItem('meli_client_secret', clientSecret);
|
||||
// Purge any previously stored secret from older versions
|
||||
localStorage.removeItem('meli_client_secret');
|
||||
localStorage.removeItem('meli_client_id');
|
||||
localStorage.setItem('meli_category', category);
|
||||
localStorage.setItem('meli_shipping', shipping);
|
||||
|
||||
var redirectUri = window.location.origin + '/pos/marketplace-external/callback';
|
||||
var authUrl = 'https://auth.mercadolibre.com.mx/authorization?response_type=code&client_id=' + encodeURIComponent(clientId) + '&redirect_uri=' + encodeURIComponent(redirectUri) + '&scope=read+write+offline_access';
|
||||
window.location.href = authUrl;
|
||||
try {
|
||||
var res = await fetch(API + '/connect/init', {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
category: category,
|
||||
shipping: shipping
|
||||
})
|
||||
});
|
||||
var data = await res.json();
|
||||
if (!res.ok) {
|
||||
alert('Error iniciando conexión: ' + (data.error || 'Unknown'));
|
||||
return;
|
||||
}
|
||||
if (data.auth_url) {
|
||||
window.location.href = data.auth_url;
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
window.disconnectMeli = async function() {
|
||||
@@ -363,8 +382,6 @@
|
||||
var authCode = urlParams.get('code');
|
||||
if (authCode && window.location.pathname.includes('marketplace-external')) {
|
||||
(async function() {
|
||||
var clientId = localStorage.getItem('meli_client_id');
|
||||
var clientSecret = localStorage.getItem('meli_client_secret');
|
||||
var redirectUri = window.location.origin + '/pos/marketplace-external/callback';
|
||||
try {
|
||||
var res = await fetch(API + '/connect', {
|
||||
@@ -372,8 +389,6 @@
|
||||
headers: headers(),
|
||||
body: JSON.stringify({
|
||||
code: authCode,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
})
|
||||
});
|
||||
|
||||
@@ -160,7 +160,15 @@
|
||||
delete el.dataset.originalContent;
|
||||
};
|
||||
|
||||
// ── Empty state helper ────────────────────────────────────────
|
||||
// ── Loading / Empty state helpers ────────────────────────────────────────
|
||||
window.renderLoadingState = function(opts) {
|
||||
opts = opts || {};
|
||||
var message = opts.message || 'Cargando...';
|
||||
return '<div class="empty-state empty-state--loading" role="status" aria-live="polite">' +
|
||||
'<div class="empty-state__icon"><svg class="empty-state__spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg></div>' +
|
||||
'<div class="empty-state__title">' + message + '</div>' +
|
||||
'</div>';
|
||||
};
|
||||
window.renderEmptyState = function(opts) {
|
||||
opts = opts || {};
|
||||
var icon = opts.icon || '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="9" y1="9" x2="15" y2="15"/><line x1="15" y1="9" x2="9" y2="15"/></svg>';
|
||||
|
||||
@@ -21,10 +21,15 @@ const POS = (() => {
|
||||
let paymentMethod = 'efectivo';
|
||||
let canViewCost = false;
|
||||
let employeeMaxDiscount = 100;
|
||||
let lastSaleId = null;
|
||||
let lastSaleId = sessionStorage.getItem('pos_last_sale_id') || null;
|
||||
let lastSaleData = null;
|
||||
let searchTimeout = null;
|
||||
let customerSearchTimeout = null;
|
||||
let canCancel = false;
|
||||
let canDiscount = false;
|
||||
let canEditPrice = false;
|
||||
let canCreateWorkshopOrder = false;
|
||||
let canCreateLayaway = false;
|
||||
|
||||
// Currency-aware formatter: reads pos_currency from localStorage
|
||||
const _posCurrency = localStorage.getItem('pos_currency') || 'MXN';
|
||||
@@ -54,6 +59,25 @@ const POS = (() => {
|
||||
setTimeout(() => el.remove(), 2100);
|
||||
}
|
||||
|
||||
// ─── Permission-based UI toggles ─────
|
||||
function applyPermissionsUI() {
|
||||
function hide(selector) {
|
||||
const el = document.querySelector(selector);
|
||||
if (el) el.style.display = 'none';
|
||||
}
|
||||
if (!canCancel) {
|
||||
hide('#btnCancelSale');
|
||||
hide('#fkeyEsc');
|
||||
}
|
||||
if (!canDiscount) hide('[onclick="POS.applyDiscount()"]');
|
||||
if (!canEditPrice) hide('[onclick="POS.modifyPrice()"]');
|
||||
if (!canCreateWorkshopOrder) {
|
||||
hide('[onclick="POS.createServiceOrder()"]');
|
||||
hide('[title="Orden de servicio"]');
|
||||
}
|
||||
if (!canCreateLayaway) hide('[onclick="POS.createLayaway()"]');
|
||||
}
|
||||
|
||||
// ─── Init ────────────────────────────
|
||||
async function init() {
|
||||
// Parse JWT to get employee info
|
||||
@@ -61,7 +85,13 @@ const POS = (() => {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
document.getElementById('employeeName').textContent = payload.name || 'Empleado';
|
||||
document.getElementById('branchName').textContent = payload.branch_name || '';
|
||||
canViewCost = (payload.permissions || []).includes('pos.view_cost');
|
||||
const perms = payload.permissions || [];
|
||||
canViewCost = perms.includes('pos.view_cost');
|
||||
canCancel = perms.includes('pos.cancel');
|
||||
canDiscount = perms.includes('pos.discount');
|
||||
canEditPrice = perms.includes('config.edit_prices');
|
||||
canCreateWorkshopOrder = perms.includes('workshop.edit');
|
||||
canCreateLayaway = perms.includes('pos.sell');
|
||||
employeeMaxDiscount = payload.max_discount_pct || 100;
|
||||
|
||||
// Show cost/margin columns and toggle button if permission
|
||||
@@ -78,6 +108,8 @@ const POS = (() => {
|
||||
const parts = payload.name.split(' ');
|
||||
avatar.textContent = parts.map(p => p[0]).join('').substring(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
applyPermissionsUI();
|
||||
} catch (e) {
|
||||
console.warn('Could not parse token:', e);
|
||||
}
|
||||
@@ -278,6 +310,7 @@ const POS = (() => {
|
||||
}
|
||||
|
||||
function openCancelModal() {
|
||||
if (!canCancel) { showToast('No tienes permiso para cancelar'); return; }
|
||||
const overlay = document.getElementById('overlay-cancelar-venta');
|
||||
const dialog = document.getElementById('modal-cancelar-venta');
|
||||
if (overlay) overlay.classList.add('active');
|
||||
@@ -307,6 +340,7 @@ const POS = (() => {
|
||||
}
|
||||
|
||||
function applyDiscount() {
|
||||
if (!canDiscount) { showToast('No tienes permiso para aplicar descuentos'); return; }
|
||||
if (selectedRow < 0 || selectedRow >= cart.length) {
|
||||
showToast('Selecciona un articulo primero', 'warn');
|
||||
return;
|
||||
@@ -322,6 +356,7 @@ const POS = (() => {
|
||||
}
|
||||
|
||||
function modifyPrice() {
|
||||
if (!canEditPrice) { showToast('No tienes permiso para modificar precios'); return; }
|
||||
if (selectedRow < 0 || selectedRow >= cart.length) {
|
||||
showToast('Selecciona un articulo primero', 'warn');
|
||||
return;
|
||||
@@ -952,6 +987,7 @@ const POS = (() => {
|
||||
|
||||
lastSaleId = sale.id;
|
||||
lastSaleData = sale;
|
||||
try { sessionStorage.setItem('pos_last_sale_id', sale.id); } catch(e) {}
|
||||
closePaymentModal();
|
||||
showTicket(sale);
|
||||
|
||||
@@ -1006,6 +1042,7 @@ const POS = (() => {
|
||||
|
||||
lastSaleId = sale.id;
|
||||
lastSaleData = sale;
|
||||
try { sessionStorage.setItem('pos_last_sale_id', sale.id); } catch(e) {}
|
||||
showTicket(sale);
|
||||
cart = [];
|
||||
selectedRow = -1;
|
||||
@@ -1057,6 +1094,7 @@ const POS = (() => {
|
||||
|
||||
// ─── Layaway ─────────────────────────
|
||||
async function createLayaway() {
|
||||
if (!canCreateLayaway) { showToast('No tienes permiso para crear apartados'); return; }
|
||||
if (cart.length === 0) { alert('Carrito vacio'); return; }
|
||||
if (!currentCustomer) { alert('Seleccione un cliente para apartado'); return; }
|
||||
|
||||
@@ -1097,6 +1135,132 @@ const POS = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Service Order from POS ──────────
|
||||
function createServiceOrder() {
|
||||
if (!canCreateWorkshopOrder) { showToast('No tienes permiso para ordenes de taller'); return; }
|
||||
if (cart.length === 0) { showToast('Carrito vacio'); return; }
|
||||
const custInput = document.getElementById('soCustomer');
|
||||
if (custInput) custInput.value = currentCustomer ? currentCustomer.name : 'Publico General';
|
||||
document.getElementById('serviceOrderModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeServiceOrderModal() {
|
||||
document.getElementById('serviceOrderModal').classList.remove('open');
|
||||
}
|
||||
|
||||
async function confirmServiceOrder() {
|
||||
const customerId = currentCustomer ? currentCustomer.id : null;
|
||||
if (!customerId) { alert('Seleccione un cliente para la orden de servicio'); return; }
|
||||
|
||||
const vehicleText = document.getElementById('soVehicle').value.trim();
|
||||
const delivery = document.getElementById('soDelivery').value;
|
||||
const notes = document.getElementById('soNotes').value.trim();
|
||||
const isDirect = document.getElementById('soDirect').checked;
|
||||
|
||||
const items = cart.map(item => ({
|
||||
inventory_id: item.inventory_id,
|
||||
part_number: item.part_number,
|
||||
name: item.name,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unit_price,
|
||||
status: 'pending'
|
||||
}));
|
||||
|
||||
try {
|
||||
const order = await api('/pos/api/service-orders/from-pos', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
customer_id: customerId,
|
||||
priority: 'normal',
|
||||
reception_notes: (vehicleText ? 'Vehiculo: ' + vehicleText + '. ' : '') + (notes || ''),
|
||||
delivery_method: delivery || null,
|
||||
is_direct: isDirect,
|
||||
items: items
|
||||
})
|
||||
});
|
||||
|
||||
showToast(`Orden de servicio ${order.order_number} creada`);
|
||||
closeServiceOrderModal();
|
||||
cart = [];
|
||||
selectedRow = -1;
|
||||
renderCart();
|
||||
showServiceOrderTicket(order);
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showServiceOrderTicket(order) {
|
||||
const dateStr = new Date(order.created_at).toLocaleString('es-MX', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
|
||||
const customerName = order.customer_name || (currentCustomer ? currentCustomer.name : 'Publico General');
|
||||
let itemsHtml = '';
|
||||
(order.items || []).forEach(item => {
|
||||
const itemTotal = item.unit_price * item.quantity;
|
||||
itemsHtml += `
|
||||
<div class="item-line-wide ticket-line">
|
||||
<span class="qty">${item.quantity}</span>
|
||||
<span class="name">${item.name || ''}</span>
|
||||
<span class="price">${fmt(item.unit_price)}</span>
|
||||
<span class="subtotal">${fmt(itemTotal)}</span>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
const ticketHtml = `
|
||||
<div class="store-name">NEXUS AUTOPARTS</div>
|
||||
<div class="store-tagline">Orden de servicio</div>
|
||||
<div class="store-info">
|
||||
Sucursal: ${currentRegister ? currentRegister.branch_name || '' : ''}<br>
|
||||
RFC: NAU210315XX1
|
||||
</div>
|
||||
<hr class="divider-double">
|
||||
<div class="folio-line">
|
||||
<span>ORDEN: ${order.order_number}</span>
|
||||
<span>${dateStr}</span>
|
||||
</div>
|
||||
<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;">
|
||||
<span>Cliente: ${customerName}</span>
|
||||
</div>
|
||||
<hr class="divider">
|
||||
<div class="item-line-wide" style="font-weight: bold; font-size: 9px; color: #555; text-transform: uppercase;">
|
||||
<span class="qty">Cant</span>
|
||||
<span class="name">Descripcion</span>
|
||||
<span class="price">P. Unit</span>
|
||||
<span class="subtotal">Importe</span>
|
||||
</div>
|
||||
<hr class="divider" style="margin: 2px 0;">
|
||||
${itemsHtml}
|
||||
<hr class="divider-double">
|
||||
<div class="total-section">
|
||||
<div class="total-line">
|
||||
<span>Refacciones:</span><span>${fmt(order.total_parts || 0)}</span>
|
||||
</div>
|
||||
<div class="total-line">
|
||||
<span>Mano de obra:</span><span>${fmt(order.total_labor || 0)}</span>
|
||||
</div>
|
||||
<div class="total-line grand">
|
||||
<span>TOTAL:</span><span>${fmt(order.total || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="divider">
|
||||
<div class="footer-section">
|
||||
<div class="thanks">Gracias por su preferencia!</div>
|
||||
<div>Conserve su ticket como comprobante.</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const printArea = document.getElementById('ticketContent');
|
||||
if (printArea) printArea.innerHTML = ticketHtml;
|
||||
const preview = document.getElementById('ticketPreviewContent');
|
||||
if (preview) preview.innerHTML = ticketHtml;
|
||||
const modalHeader = document.querySelector('#ticketModal .modal-header h3');
|
||||
if (modalHeader) modalHeader.textContent = 'Ticket de Orden de Servicio';
|
||||
document.getElementById('ticketModal').classList.add('open');
|
||||
}
|
||||
|
||||
// ─── Ticket ──────────────────────────
|
||||
function showTicket(sale) {
|
||||
const dateStr = new Date(sale.created_at).toLocaleString('es-MX', {
|
||||
@@ -1111,7 +1275,7 @@ const POS = (() => {
|
||||
(sale.items || []).forEach(item => {
|
||||
const itemTotal = (item.unit_price * item.quantity * (1 - (item.discount_pct || 0) / 100));
|
||||
itemsHtml += `
|
||||
<div class="item-line-wide">
|
||||
<div class="item-line-wide ticket-line">
|
||||
<span class="qty">${item.quantity}</span>
|
||||
<span class="name">${item.name || ''}</span>
|
||||
<span class="price">${fmt(item.unit_price)}</span>
|
||||
@@ -1182,6 +1346,8 @@ const POS = (() => {
|
||||
if (printArea) printArea.innerHTML = ticketHtml;
|
||||
const preview = document.getElementById('ticketPreviewContent');
|
||||
if (preview) preview.innerHTML = ticketHtml;
|
||||
const modalHeader = document.querySelector('#ticketModal .modal-header h3');
|
||||
if (modalHeader) modalHeader.textContent = 'Ticket de Venta';
|
||||
|
||||
document.getElementById('ticketModal').classList.add('open');
|
||||
}
|
||||
@@ -1286,6 +1452,10 @@ const POS = (() => {
|
||||
e.preventDefault();
|
||||
openDrawer();
|
||||
break;
|
||||
case 'F7':
|
||||
e.preventDefault();
|
||||
createServiceOrder();
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
if (document.getElementById('paymentModal').classList.contains('open')) {
|
||||
@@ -1294,6 +1464,8 @@ const POS = (() => {
|
||||
closeNewCustomerModal();
|
||||
} else if (document.getElementById('ticketModal').classList.contains('open')) {
|
||||
closeTicketModal();
|
||||
} else if (document.getElementById('serviceOrderModal').classList.contains('open')) {
|
||||
closeServiceOrderModal();
|
||||
} else if (document.querySelector('.confirm-overlay.active')) {
|
||||
// Close cancel modal
|
||||
const overlay = document.getElementById('overlay-cancelar-venta');
|
||||
@@ -1361,6 +1533,14 @@ const POS = (() => {
|
||||
});
|
||||
}
|
||||
|
||||
// Register Cmd+K items
|
||||
if (typeof registerCmdKItem === "function") {
|
||||
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
|
||||
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
|
||||
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
|
||||
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────
|
||||
init();
|
||||
|
||||
@@ -1373,6 +1553,7 @@ const POS = (() => {
|
||||
checkout, confirmPayment, closePaymentModal,
|
||||
selectPaymentMethod, updateChange, updateMixedTotal,
|
||||
creditSale, saveQuotation, createLayaway,
|
||||
createServiceOrder, closeServiceOrderModal, confirmServiceOrder, showServiceOrderTicket,
|
||||
showLastSale, openDrawer,
|
||||
showTicket, closeTicketModal, printTicket,
|
||||
connectThermal, thermalPrint,
|
||||
@@ -1380,12 +1561,5 @@ const POS = (() => {
|
||||
showCutZModal, closeCutZModal, loadCutX, confirmCutZ,
|
||||
openCancelModal, closeCancelModal, changeQuantity, applyDiscount, modifyPrice,
|
||||
};
|
||||
// Register Cmd+K items
|
||||
if (typeof registerCmdKItem === "function") {
|
||||
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
|
||||
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
|
||||
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
|
||||
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
@@ -31,30 +31,52 @@ window.renderSidebar = function(modulesOverride) {
|
||||
return modules[key] !== false;
|
||||
}
|
||||
|
||||
var role = (u.role || '').toLowerCase();
|
||||
var perms = u.permissions || [];
|
||||
function hasPerm(p) {
|
||||
return role === 'owner' || perms.indexOf(p) !== -1;
|
||||
}
|
||||
|
||||
// Role-based section filtering. Owner/admin bypasses; other roles get
|
||||
// only the sections relevant to their job.
|
||||
function itemAllowed(id) {
|
||||
if (role === 'owner' || role === 'admin') return true;
|
||||
if (role === 'workshop') {
|
||||
return id === 'workshop';
|
||||
}
|
||||
if (role === 'cashier') {
|
||||
return ['dashboard','pos','catalog','inventory','customers'].indexOf(id) !== -1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
var navSections = [
|
||||
{ label: _t('nav_main'), items: [
|
||||
{ name: _t('dashboard'), href: '/pos/dashboard', icon: '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>' },
|
||||
{ name: _t('pos'), href: '/pos/sale', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' },
|
||||
moduleEnabled('catalog') ? { name: _t('catalog'), href: '/pos/catalog', icon: '<path d="M4 6h16M4 10h16M4 14h16M4 18h16"/>' } : null,
|
||||
{ name: _t('inventory'), href: '/pos/inventory', icon: '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>' },
|
||||
].filter(Boolean)},
|
||||
{ id: 'dashboard', name: _t('dashboard'), href: '/pos/dashboard', icon: '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>' },
|
||||
{ id: 'pos', name: _t('pos'), href: '/pos/sale', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' },
|
||||
moduleEnabled('catalog') ? { id: 'catalog', name: _t('catalog'), href: '/pos/catalog', icon: '<path d="M4 6h16M4 10h16M4 14h16M4 18h16"/>' } : null,
|
||||
{ id: 'inventory', name: _t('inventory'), href: '/pos/inventory', icon: '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>' },
|
||||
].filter(Boolean).filter(function(i){ return itemAllowed(i.id); })},
|
||||
{ label: _t('nav_management'), items: [
|
||||
{ name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' },
|
||||
{ name: 'Taller', href: '/pos/workshop', icon: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>' },
|
||||
{ name: 'Cotizaciones', href: '/pos/quotations', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/><line x1="12" y1="12" x2="12" y2="18"/>' },
|
||||
moduleEnabled('marketplace') ? { name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' } : null,
|
||||
moduleEnabled('meli') ? { name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' } : null,
|
||||
{ name: _t('invoicing'), href: '/pos/invoicing', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' },
|
||||
{ name: _t('accounting'), href: '/pos/accounting', icon: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>' },
|
||||
{ name: _t('reports'), href: '/pos/reports', icon: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>' },
|
||||
{ name: _t('fleet'), href: '/pos/fleet', icon: '<path d="M1 13h22M1 13l2-6h6l2 6M9 7h6l2 6M15 13l2-6M5 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4zM19 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/>' },
|
||||
moduleEnabled('whatsapp') ? { name: _t('whatsapp'), href: '/pos/whatsapp', icon: '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>' } : null,
|
||||
].filter(Boolean)},
|
||||
{ id: 'customers', name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' },
|
||||
{ id: 'workshop', name: 'Taller', href: '/pos/workshop', icon: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>' },
|
||||
{ id: 'quotations', name: 'Cotizaciones', href: '/pos/quotations', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/><line x1="12" y1="12" x2="12" y2="18"/>' },
|
||||
moduleEnabled('marketplace') ? { id: 'marketplace', name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' } : null,
|
||||
moduleEnabled('meli') ? { id: 'meli', name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' } : null,
|
||||
{ id: 'invoicing', name: _t('invoicing'), href: '/pos/invoicing', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' },
|
||||
{ id: 'accounting', name: _t('accounting'), href: '/pos/accounting', icon: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>' },
|
||||
{ id: 'reports', name: _t('reports'), href: '/pos/reports', icon: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>' },
|
||||
hasPerm('fleet.view') ? { id: 'fleet', name: _t('fleet'), href: '/pos/fleet', icon: '<path d="M1 13h22M1 13l2-6h6l2 6M9 7h6l2 6M15 13l2-6M5 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4zM19 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/>' } : null,
|
||||
moduleEnabled('whatsapp') ? { id: 'whatsapp', name: _t('whatsapp'), href: '/pos/whatsapp', icon: '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>' } : null,
|
||||
].filter(Boolean).filter(function(i){ return itemAllowed(i.id); })},
|
||||
{ label: _t('nav_system'), items: [
|
||||
{ name: _t('config'), href: '/pos/config', icon: '<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>' },
|
||||
]},
|
||||
{ id: 'config', name: _t('config'), href: '/pos/config', icon: '<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>' },
|
||||
].filter(function(i){ return itemAllowed(i.id); })},
|
||||
];
|
||||
|
||||
// Remove sections whose items were all filtered out
|
||||
navSections = navSections.filter(function(sec) { return sec.items.length > 0; });
|
||||
|
||||
function svgIcon(paths) {
|
||||
return '<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">' + paths + '</svg>';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* workshop.js — Taller / Service Orders Kanban for Nexus POS
|
||||
* workshop.js — Taller / Service Orders for Nexus POS
|
||||
* Supports list + kanban views, filters, bitacora and role-based price hiding.
|
||||
*/
|
||||
var Workshop = (function() {
|
||||
'use strict';
|
||||
@@ -11,7 +12,20 @@ var Workshop = (function() {
|
||||
var customers = [];
|
||||
var vehicles = [];
|
||||
var employees = [];
|
||||
var couriers = [];
|
||||
var branches = [];
|
||||
var currentOrderId = null;
|
||||
var currentOrder = null;
|
||||
var currentView = 'list';
|
||||
var currentPage = 1;
|
||||
var perPage = 25;
|
||||
|
||||
var user = window.POS_USER || {};
|
||||
var role = (user.role || '').toLowerCase();
|
||||
var hidePrices = role === 'workshop';
|
||||
var perms = user.permissions || [];
|
||||
var canEdit = role === 'owner' || role === 'admin' || perms.indexOf('workshop.edit') !== -1;
|
||||
var canSell = role === 'owner' || role === 'admin' || perms.indexOf('pos.sell') !== -1;
|
||||
|
||||
var COLUMNS = [
|
||||
{key: 'received', label: 'Recibido'},
|
||||
@@ -23,6 +37,23 @@ var Workshop = (function() {
|
||||
{key: 'delivered', label: 'Entregado'},
|
||||
];
|
||||
|
||||
var STATUS_LABELS = {
|
||||
received: 'Recibido',
|
||||
diagnosis: 'Diagnóstico',
|
||||
waiting_parts: 'Espera refacciones',
|
||||
repair: 'En reparación',
|
||||
quality_check: 'Control calidad',
|
||||
ready: 'Listo',
|
||||
delivered: 'Entregado',
|
||||
cancelled: 'Cancelado'
|
||||
};
|
||||
|
||||
var DELIVERY_LABELS = {
|
||||
pickup: 'Pasa cliente',
|
||||
delivery: 'Envío a domicilio',
|
||||
courier: 'Motociclista'
|
||||
};
|
||||
|
||||
function headers() {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -43,11 +74,13 @@ var Workshop = (function() {
|
||||
function fmtDate(d) {
|
||||
if (!d) return '—';
|
||||
var dt = new Date(d);
|
||||
return dt.toLocaleDateString('es-MX', {day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit'});
|
||||
if (isNaN(dt.getTime())) return d;
|
||||
return dt.toLocaleDateString('es-MX', {day: '2-digit', month: '2-digit', year: 'numeric'}) + ' ' +
|
||||
dt.toLocaleTimeString('es-MX', {hour: '2-digit', minute: '2-digit', hour12: false});
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (!s) return '';
|
||||
if (s == null) return '';
|
||||
var el = document.createElement('div');
|
||||
el.textContent = s;
|
||||
return el.innerHTML;
|
||||
@@ -67,13 +100,51 @@ var Workshop = (function() {
|
||||
// ─── Init ───
|
||||
|
||||
function init() {
|
||||
var savedView = localStorage.getItem('workshop_view');
|
||||
if (savedView) currentView = savedView;
|
||||
bindFilters();
|
||||
bindDeliverySelect();
|
||||
if (hidePrices) {
|
||||
var btnCatalog = document.getElementById('btnCatalog');
|
||||
if (btnCatalog) btnCatalog.style.display = 'none';
|
||||
document.querySelectorAll('.price-col').forEach(function(el) { el.style.display = 'none'; });
|
||||
}
|
||||
loadReferenceData();
|
||||
loadSummary();
|
||||
setView(currentView);
|
||||
loadOrders();
|
||||
loadCatalog();
|
||||
loadReferenceData();
|
||||
}
|
||||
|
||||
// ─── Summary / Kanban ───
|
||||
function bindFilters() {
|
||||
['filterBranch','filterStatus','filterDelivery','filterDirect','filterSearch'].forEach(function(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.addEventListener('change', function() { currentPage = 1; loadOrders(); });
|
||||
if (el.tagName === 'INPUT' && id === 'filterSearch') {
|
||||
el.addEventListener('keyup', debounce(function() { currentPage = 1; loadOrders(); }, 350));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindDeliverySelect() {
|
||||
var sel = document.getElementById('noDelivery');
|
||||
if (!sel) return;
|
||||
sel.addEventListener('change', function() {
|
||||
var cf = document.getElementById('courierField');
|
||||
if (cf) cf.style.display = sel.value === 'courier' ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function debounce(fn, ms) {
|
||||
var t;
|
||||
return function() {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(fn, ms);
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Summary ───
|
||||
|
||||
function loadSummary() {
|
||||
fetch(API + '/kanban/summary', {headers: headers()})
|
||||
@@ -87,19 +158,110 @@ var Workshop = (function() {
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
// ─── Orders loading ───
|
||||
|
||||
function getFilterQuery() {
|
||||
var params = [];
|
||||
var status = document.getElementById('filterStatus').value;
|
||||
var delivery = document.getElementById('filterDelivery').value;
|
||||
var branch = document.getElementById('filterBranch').value;
|
||||
var direct = document.getElementById('filterDirect').checked;
|
||||
var q = document.getElementById('filterSearch').value.trim();
|
||||
if (status) params.push('status=' + encodeURIComponent(status));
|
||||
if (delivery) params.push('delivery_method=' + encodeURIComponent(delivery));
|
||||
if (branch) params.push('branch_id=' + encodeURIComponent(branch));
|
||||
if (direct) params.push('is_direct=true');
|
||||
if (q) params.push('q=' + encodeURIComponent(q));
|
||||
params.push('page=' + currentPage);
|
||||
params.push('per_page=' + perPage);
|
||||
return '?' + params.join('&');
|
||||
}
|
||||
|
||||
function loadOrders() {
|
||||
fetch(API + '?per_page=200', {headers: headers()})
|
||||
fetch(API + getFilterQuery(), {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
orders = d.data || [];
|
||||
renderKanban();
|
||||
var pagination = d.pagination || {};
|
||||
totalPages = pagination.total_pages || 1;
|
||||
if (currentView === 'list') {
|
||||
renderList();
|
||||
renderPagination();
|
||||
} else {
|
||||
renderKanban();
|
||||
}
|
||||
})
|
||||
.catch(function(e) {
|
||||
console.error(e);
|
||||
document.getElementById('kanbanBoard').innerHTML = '<div class="empty-state"><div class="empty-state__title">Error cargando órdenes</div><div class="empty-state__subtitle">No se pudieron cargar las órdenes de servicio.</div></div>';
|
||||
document.getElementById('listBody').innerHTML = '<tr><td colspan="7" style="text-align:center;padding:var(--space-4);">Error cargando órdenes</td></tr>';
|
||||
});
|
||||
}
|
||||
|
||||
var totalPages = 1;
|
||||
|
||||
// ─── View switching ───
|
||||
|
||||
function setView(view) {
|
||||
currentView = view;
|
||||
localStorage.setItem('workshop_view', view);
|
||||
document.querySelectorAll('.view-switch__btn').forEach(function(b) {
|
||||
b.classList.toggle('is-active', b.dataset.view === view);
|
||||
});
|
||||
document.getElementById('listView').style.display = view === 'list' ? 'block' : 'none';
|
||||
document.getElementById('kanbanBoard').style.display = view === 'kanban' ? 'flex' : 'none';
|
||||
if (view === 'list') {
|
||||
renderList();
|
||||
renderPagination();
|
||||
} else {
|
||||
renderKanban();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── List view ───
|
||||
|
||||
function renderList() {
|
||||
var body = document.getElementById('listBody');
|
||||
if (!orders.length) {
|
||||
body.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:var(--space-4);">No se encontraron órdenes</td></tr>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = orders.map(function(o) {
|
||||
var vehicle = esc((o.vehicle_plate || '—') + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || ''));
|
||||
return '<tr>' +
|
||||
'<td>' + esc(o.branch_name || '—') + '</td>' +
|
||||
'<td><strong>' + esc(o.order_number) + '</strong></td>' +
|
||||
'<td>' + esc(o.customer_name || 'Cliente general') + '</td>' +
|
||||
'<td>' + vehicle + '</td>' +
|
||||
'<td><span class="badge badge--' + esc(o.status) + '">' + esc(STATUS_LABELS[o.status] || o.status) + '</span></td>' +
|
||||
'<td class="price-col" style="text-align:right;">' + fmtMoney(o.total) + '</td>' +
|
||||
'<td><button class="btn btn--sm btn--secondary" onclick="Workshop.openDetail(' + o.id + ')">Ver</button></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
if (hidePrices) {
|
||||
document.querySelectorAll('.price-col').forEach(function(el) { el.style.display = 'none'; });
|
||||
}
|
||||
}
|
||||
|
||||
function renderPagination() {
|
||||
var el = document.getElementById('listPagination');
|
||||
if (totalPages <= 1) {
|
||||
el.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
var html = '<button class="btn btn--sm btn--ghost" ' + (currentPage === 1 ? 'disabled' : '') + ' onclick="Workshop.goPage(' + (currentPage - 1) + ')">Anterior</button>' +
|
||||
'<span class="pagination-info">Página ' + currentPage + ' de ' + totalPages + '</span>' +
|
||||
'<button class="btn btn--sm btn--ghost" ' + (currentPage === totalPages ? 'disabled' : '') + ' onclick="Workshop.goPage(' + (currentPage + 1) + ')">Siguiente</button>';
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function goPage(p) {
|
||||
if (p < 1 || p > totalPages) return;
|
||||
currentPage = p;
|
||||
loadOrders();
|
||||
}
|
||||
|
||||
// ─── Kanban view ───
|
||||
|
||||
function renderKanban() {
|
||||
var board = document.getElementById('kanbanBoard');
|
||||
board.innerHTML = '';
|
||||
@@ -119,9 +281,7 @@ var Workshop = (function() {
|
||||
if (!colOrders.length) {
|
||||
body.innerHTML = '<div class="empty-state" style="padding:var(--space-4);"><div class="empty-state__subtitle">Sin órdenes</div></div>';
|
||||
} else {
|
||||
colOrders.forEach(function(o) {
|
||||
body.appendChild(renderCard(o));
|
||||
});
|
||||
colOrders.forEach(function(o) { body.appendChild(renderCard(o)); });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -132,31 +292,14 @@ var Workshop = (function() {
|
||||
}
|
||||
|
||||
function statusBadgeClass(status) {
|
||||
var map = {
|
||||
pending: 'badge--pending',
|
||||
reserved: 'badge--reserved',
|
||||
installed: 'badge--installed',
|
||||
cancelled: 'badge--cancelled',
|
||||
complete: 'badge--complete'
|
||||
};
|
||||
return map[status] || 'badge--pending';
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
var map = {
|
||||
pending: 'Pendiente',
|
||||
reserved: 'Reservado',
|
||||
installed: 'Instalado',
|
||||
cancelled: 'Cancelado',
|
||||
complete: 'Completado'
|
||||
};
|
||||
return map[status] || status;
|
||||
return 'badge--' + (status || 'pending');
|
||||
}
|
||||
|
||||
function renderCard(o) {
|
||||
var card = document.createElement('div');
|
||||
card.className = 'kanban-card';
|
||||
card.onclick = function() { openDetail(o.id); };
|
||||
var priceHtml = hidePrices ? '' : '<span>' + fmtMoney(o.estimated_cost || o.total) + '</span>';
|
||||
card.innerHTML =
|
||||
'<div class="kanban-card__header">' +
|
||||
' <span class="kanban-card__id">' + esc(o.order_number) + '</span>' +
|
||||
@@ -166,7 +309,7 @@ var Workshop = (function() {
|
||||
'<div class="kanban-card__vehicle">' + esc(o.vehicle_plate || 'Sin vehículo') + '</div>' +
|
||||
'<div class="kanban-card__meta">' +
|
||||
' <span class="kanban-card__mechanic">🔧 ' + esc(o.employee_name || 'Sin asignar') + '</span>' +
|
||||
' <span>' + fmtMoney(o.estimated_cost) + '</span>' +
|
||||
priceHtml +
|
||||
'</div>';
|
||||
return card;
|
||||
}
|
||||
@@ -178,7 +321,8 @@ var Workshop = (function() {
|
||||
fetch(API + '/' + id, {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(o) {
|
||||
document.getElementById('detailTitle').textContent = 'Orden ' + esc(o.order_number);
|
||||
currentOrder = o;
|
||||
document.getElementById('detailTitle').textContent = 'Bitácora Orden: ' + esc(o.order_number);
|
||||
renderDetailBody(o);
|
||||
document.getElementById('detailModal').classList.add('is-open');
|
||||
})
|
||||
@@ -186,108 +330,171 @@ var Workshop = (function() {
|
||||
}
|
||||
|
||||
function renderDetailBody(o) {
|
||||
var activeTab = document.querySelector('.so-tabs__btn.is-active');
|
||||
var selectedTab = activeTab ? activeTab.dataset.tab : 'service';
|
||||
|
||||
var html =
|
||||
'<div class="so-detail">' +
|
||||
' <div class="so-detail__section">' +
|
||||
' <h3>Información general</h3>' +
|
||||
' <div class="so-detail__grid">' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Cliente</span><span class="so-detail__value">' + esc(o.customer_name || '—') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Vehículo</span><span class="so-detail__value">' + esc((o.vehicle_plate || '—') + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || '')) + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Mecánico</span><span class="so-detail__value">' + esc(o.employee_name || 'Sin asignar') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Estado</span><span class="so-detail__value">' + esc(o.status) + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Entrega estimada</span><span class="so-detail__value">' + fmtDate(o.estimated_completion) + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Kilometraje entrada</span><span class="so-detail__value">' + fmt(o.mileage_in) + '</span></div>' +
|
||||
' </div>' +
|
||||
' <div style="margin-top:var(--space-3);"><span class="so-detail__label">Notas recepción</span><p>' + esc(o.reception_notes || '—') + '</p></div>' +
|
||||
' </div>' +
|
||||
'<div class="so-detail-header">' +
|
||||
' <div class="so-detail-header__row"><span class="so-detail__label">Estatus:</span> <span class="badge badge--' + esc(o.status) + '">' + esc(STATUS_LABELS[o.status] || o.status) + '</span></div>' +
|
||||
' <div class="so-detail-header__row"><span class="so-detail__label">Fecha:</span> ' + fmtDate(o.created_at) + '</div>' +
|
||||
' <div class="so-detail-header__row"><span class="so-detail__label">Registrado por:</span> ' + esc(o.created_by_name || '—') + '</div>' +
|
||||
' <div class="so-detail-header__row"><span class="so-detail__label">Sucursal:</span> ' + esc(o.branch_name || '—') + '</div>' +
|
||||
'</div>' +
|
||||
|
||||
' <div class="so-detail__section">' +
|
||||
' <h3>Refacciones</h3>' +
|
||||
' <table class="data-table"><thead><tr><th>Concepto</th><th>Cant.</th><th>Precio</th><th>Estado</th><th></th></tr></thead><tbody>' +
|
||||
(o.items || []).map(function(it) {
|
||||
var itemStatus = it.reserved_quantity >= it.quantity ? 'reserved' : it.status;
|
||||
return '<tr>' +
|
||||
'<td>' + esc(it.name) + '<br><small>' + esc(it.part_number || '') + '</small></td>' +
|
||||
'<td>' + fmt(it.quantity) + '</td>' +
|
||||
'<td>' + fmtMoney(it.unit_price) + '</td>' +
|
||||
'<td><span class="badge ' + statusBadgeClass(itemStatus) + '">' + statusLabel(itemStatus) + '</span></td>' +
|
||||
'<td>' + (it.reserved_quantity < it.quantity && it.status !== 'cancelled' ? '<button class="btn btn--sm btn--secondary" onclick="event.stopPropagation();Workshop.reserveItem(' + it.id + ')">Reservar</button>' : '') + '</td>' +
|
||||
'</tr>';
|
||||
}).join('') +
|
||||
'</tbody></table>' +
|
||||
' <div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);">' +
|
||||
' <input class="form-input" id="newItemSearch" placeholder="Buscar refacción por nombre/numero" style="flex:1;" />' +
|
||||
' <button class="btn btn--secondary" onclick="Workshop.addItemPlaceholder()">Agregar</button>' +
|
||||
' </div>' +
|
||||
' </div>' +
|
||||
'<div class="so-detail-info">' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Cliente</span><span class="so-detail__value">' + esc(o.customer_name || '—') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Dirección</span><span class="so-detail__value">' + esc(o.customer_address || '—') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Teléfono</span><span class="so-detail__value">' + esc(o.customer_phone || '—') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Vehículo</span><span class="so-detail__value">' + esc((o.vehicle_plate || '—') + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || '')) + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Vía de entrega</span><span class="so-detail__value">' + esc(DELIVERY_LABELS[o.delivery_method] || o.delivery_method || '—') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Motociclista</span><span class="so-detail__value">' + esc(o.courier_name || '—') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Mecánico</span><span class="so-detail__value">' + esc(o.employee_name || 'Sin asignar') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Entrega estimada</span><span class="so-detail__value">' + fmtDate(o.estimated_completion) + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Kilometraje entrada</span><span class="so-detail__value">' + fmt(o.mileage_in) + '</span></div>' +
|
||||
(hidePrices ? '' : '<div class="so-detail__field"><span class="so-detail__label">Presupuesto</span><span class="so-detail__value">' + fmtMoney(o.estimated_cost) + '</span></div>') +
|
||||
(hidePrices ? '' : '<div class="so-detail__field"><span class="so-detail__label">Total</span><span class="so-detail__value">' + fmtMoney(o.total) + '</span></div>') +
|
||||
'</div>' +
|
||||
|
||||
' <div class="so-detail__section">' +
|
||||
' <h3>Mano de obra</h3>' +
|
||||
' <table class="data-table"><thead><tr><th>Concepto</th><th>Horas</th><th>Precio/hr</th><th>Total</th><th>Estado</th></tr></thead><tbody>' +
|
||||
(o.labor || []).map(function(l) {
|
||||
return '<tr>' +
|
||||
'<td>' + esc(l.description) + '</td>' +
|
||||
'<td>' + fmt(l.hours) + '</td>' +
|
||||
'<td>' + fmtMoney(l.hourly_rate) + '</td>' +
|
||||
'<td>' + fmtMoney(l.total_cost) + '</td>' +
|
||||
'<td><span class="badge ' + statusBadgeClass(l.status) + '">' + statusLabel(l.status) + '</span></td>' +
|
||||
'</tr>';
|
||||
}).join('') +
|
||||
'</tbody></table>' +
|
||||
' <div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);">' +
|
||||
' <select class="form-input" id="laborCatalogSelect"><option value="">Concepto manual</option></select>' +
|
||||
' <input class="form-input" id="laborDesc" placeholder="Descripción" style="flex:1;" />' +
|
||||
' <input class="form-input" id="laborHours" type="number" step="0.1" placeholder="Hrs" style="width:80px;" />' +
|
||||
' <input class="form-input" id="laborRate" type="number" step="0.01" placeholder="$/hr" style="width:100px;" />' +
|
||||
' <button class="btn btn--secondary" onclick="Workshop.addLabor()">Agregar</button>' +
|
||||
' </div>' +
|
||||
' </div>' +
|
||||
'<div class="so-tabs">' +
|
||||
' <button class="so-tabs__btn ' + (selectedTab === 'service' ? 'is-active' : '') + '" data-tab="service" onclick="Workshop.switchTab(\'service\')">Orden de servicio</button>' +
|
||||
' <button class="so-tabs__btn ' + (selectedTab === 'articles' ? 'is-active' : '') + '" data-tab="articles" onclick="Workshop.switchTab(\'articles\')">Artículos</button>' +
|
||||
'</div>' +
|
||||
|
||||
'<div class="so-tab-panel" id="tab-service" ' + (selectedTab === 'service' ? '' : 'style="display:none;"') + '>' +
|
||||
' <div class="so-detail__section">' +
|
||||
' <h3>Cambiar estado</h3>' +
|
||||
' <div class="so-detail__actions">' +
|
||||
' <select class="form-input" id="statusSelect" style="width:auto;">' +
|
||||
COLUMNS.map(function(c) { return '<option value="' + c.key + '"' + (c.key === o.status ? ' selected' : '') + '>' + c.label + '</option>'; }).join('') +
|
||||
' </select>' +
|
||||
' <button class="btn btn--primary" onclick="Workshop.changeStatus()">Actualizar estado</button>' +
|
||||
' </div>' +
|
||||
' <h3>Observaciones</h3>' +
|
||||
' <p>' + esc(o.reception_notes || 'Sin observaciones') + '</p>' +
|
||||
' </div>' +
|
||||
' <div class="so-detail__section">' +
|
||||
' <h3>Bitácora</h3>' +
|
||||
renderBitacora(o.status_history || []) +
|
||||
' </div>' +
|
||||
'</div>' +
|
||||
|
||||
'<div class="so-tab-panel" id="tab-articles" ' + (selectedTab === 'articles' ? '' : 'style="display:none;"') + '>' +
|
||||
renderArticles(o) +
|
||||
'</div>';
|
||||
|
||||
document.getElementById('detailBody').innerHTML = html;
|
||||
|
||||
// Populate labor catalog select
|
||||
var sel = document.getElementById('laborCatalogSelect');
|
||||
if (sel) {
|
||||
// Footer actions
|
||||
var footer = document.getElementById('detailFooter');
|
||||
var statusHtml = canEdit ?
|
||||
'<div class="so-detail__actions" style="margin-right:auto;">' +
|
||||
' <select class="form-input" id="statusSelect" style="width:auto;">' +
|
||||
COLUMNS.map(function(c) { return '<option value="' + c.key + '"' + (c.key === o.status ? ' selected' : '') + '>' + c.label + '</option>'; }).join('') +
|
||||
' </select>' +
|
||||
' <button class="btn btn--primary" onclick="Workshop.changeStatus()">Actualizar estado</button>' +
|
||||
'</div>' : '';
|
||||
footer.innerHTML = statusHtml +
|
||||
'<button class="btn btn--ghost" onclick="Workshop.closeDetailModal()">Cerrar</button>' +
|
||||
'<button class="btn btn--secondary" onclick="Workshop.printOrder()">' +
|
||||
'<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>' +
|
||||
'Imprimir orden</button>' +
|
||||
(canEdit && canSell && o.status === 'ready' && !o.sale_id ? '<button class="btn btn--primary" onclick="Workshop.convertToSale()">Convertir a venta</button>' : '') +
|
||||
(o.sale_id ? '<a class="btn btn--secondary" href="/pos/invoicing?sale_id=' + o.sale_id + '">Ver venta #' + o.sale_id + '</a>' : '');
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.so-tabs__btn').forEach(function(b) { b.classList.toggle('is-active', b.dataset.tab === tab); });
|
||||
document.getElementById('tab-service').style.display = tab === 'service' ? 'block' : 'none';
|
||||
document.getElementById('tab-articles').style.display = tab === 'articles' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function renderBitacora(history) {
|
||||
if (!history.length) return '<p style="color:var(--color-text-muted);">Sin movimientos</p>';
|
||||
var rows = history.map(function(h) {
|
||||
return '<tr>' +
|
||||
'<td><span class="badge badge--' + esc(h.new_status) + '">' + esc(STATUS_LABELS[h.new_status] || h.new_status) + '</span></td>' +
|
||||
'<td>' + fmtDate(h.created_at) + '</td>' +
|
||||
'<td>' + esc(h.changed_by_name || '—') + '</td>' +
|
||||
'<td>' + esc(h.notes || '—') + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
return '<table class="data-table bitacora-table"><thead><tr><th>Estatus</th><th>Fecha</th><th>Usuario</th><th>Observaciones</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
||||
}
|
||||
|
||||
function renderArticles(o) {
|
||||
var partsRows = (o.items || []).map(function(it) {
|
||||
var priceCells = hidePrices ? '' :
|
||||
'<td>' + fmtMoney(it.unit_price) + '</td>';
|
||||
var actionCell = canEdit && it.status !== 'cancelled' ?
|
||||
'<td><button class="btn btn--sm btn--secondary" onclick="event.stopPropagation();Workshop.reserveItem(' + it.id + ')">Reservar</button></td>' : '<td></td>';
|
||||
return '<tr>' +
|
||||
'<td>' + esc(it.name) + '<br><small>' + esc(it.part_number || '') + '</small></td>' +
|
||||
'<td>' + fmt(it.quantity) + '</td>' +
|
||||
priceCells +
|
||||
'<td><span class="badge ' + statusBadgeClass(it.status) + '">' + esc(STATUS_LABELS[it.status] || it.status) + '</span></td>' +
|
||||
actionCell +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
var partsHeader = '<tr><th>Concepto</th><th>Cant.</th>' + (hidePrices ? '' : '<th>Precio</th>') + '<th>Estado</th><th></th></tr>';
|
||||
|
||||
var laborRows = (o.labor || []).map(function(l) {
|
||||
var priceCells = hidePrices ? '' : '<td>' + fmtMoney(l.hourly_rate) + '</td><td>' + fmtMoney(l.total_cost) + '</td>';
|
||||
return '<tr>' +
|
||||
'<td>' + esc(l.description) + '</td>' +
|
||||
'<td>' + fmt(l.hours) + '</td>' +
|
||||
priceCells +
|
||||
'<td><span class="badge ' + statusBadgeClass(l.status) + '">' + esc(STATUS_LABELS[l.status] || l.status) + '</span></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
var laborHeader = '<tr><th>Concepto</th><th>Horas</th>' + (hidePrices ? '' : '<th>Precio/hr</th><th>Total</th>') + '<th>Estado</th></tr>';
|
||||
|
||||
var addParts = canEdit ?
|
||||
'<div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);">' +
|
||||
' <input class="form-input" id="newItemSearch" placeholder="Buscar refacción por nombre/número" style="flex:1;" />' +
|
||||
' <button class="btn btn--secondary" onclick="Workshop.addItemPlaceholder()">Agregar</button>' +
|
||||
'</div>' : '';
|
||||
|
||||
var addLabor = canEdit ?
|
||||
'<div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);flex-wrap:wrap;">' +
|
||||
' <select class="form-input" id="laborCatalogSelect"><option value="">Concepto manual</option></select>' +
|
||||
' <input class="form-input" id="laborDesc" placeholder="Descripción" style="flex:1;min-width:160px;" />' +
|
||||
' <input class="form-input" id="laborHours" type="number" step="0.1" placeholder="Hrs" style="width:80px;" />' +
|
||||
(hidePrices ? '' : '<input class="form-input" id="laborRate" type="number" step="0.01" placeholder="$/hr" style="width:100px;" />') +
|
||||
' <button class="btn btn--secondary" onclick="Workshop.addLabor()">Agregar</button>' +
|
||||
'</div>' : '';
|
||||
|
||||
var html =
|
||||
'<div class="so-detail__section">' +
|
||||
' <h3>Refacciones</h3>' +
|
||||
' <table class="data-table"><thead>' + partsHeader + '</thead><tbody>' + (partsRows || '<tr><td colspan="' + (hidePrices ? 4 : 5) + '" style="text-align:center;">Sin refacciones</td></tr>') + '</tbody></table>' +
|
||||
addParts +
|
||||
'</div>' +
|
||||
'<div class="so-detail__section">' +
|
||||
' <h3>Mano de obra</h3>' +
|
||||
' <table class="data-table"><thead>' + laborHeader + '</thead><tbody>' + (laborRows || '<tr><td colspan="' + (hidePrices ? 3 : 5) + '" style="text-align:center;">Sin mano de obra</td></tr>') + '</tbody></table>' +
|
||||
addLabor +
|
||||
'</div>';
|
||||
|
||||
// schedule labor catalog select population after DOM insertion
|
||||
setTimeout(function() {
|
||||
var sel = document.getElementById('laborCatalogSelect');
|
||||
if (!sel || sel.dataset.populated) return;
|
||||
catalog.forEach(function(c) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = JSON.stringify(c);
|
||||
opt.textContent = c.name + ' ($' + fmtMoney(c.suggested_hours * c.suggested_rate).replace('$', '') + ')';
|
||||
opt.textContent = c.name + (hidePrices ? '' : ' ($' + fmtMoney(c.suggested_hours * c.suggested_rate).replace('$', '') + ')');
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
sel.dataset.populated = '1';
|
||||
sel.onchange = function() {
|
||||
if (!sel.value) return;
|
||||
var c = JSON.parse(sel.value);
|
||||
document.getElementById('laborDesc').value = c.name;
|
||||
document.getElementById('laborHours').value = c.suggested_hours;
|
||||
document.getElementById('laborRate').value = c.suggested_rate;
|
||||
if (!hidePrices) document.getElementById('laborRate').value = c.suggested_rate;
|
||||
};
|
||||
}
|
||||
}, 0);
|
||||
|
||||
// Footer actions
|
||||
var footer = document.getElementById('detailFooter');
|
||||
footer.innerHTML =
|
||||
'<button class="btn btn--ghost" onclick="Workshop.closeDetailModal()">Cerrar</button>' +
|
||||
'<button class="btn btn--secondary" onclick="Workshop.printOrder()">' +
|
||||
'<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>' +
|
||||
'Imprimir orden</button>' +
|
||||
(o.status === 'ready' && !o.sale_id ? '<button class="btn btn--primary" onclick="Workshop.convertToSale()">Convertir a venta</button>' : '') +
|
||||
(o.sale_id ? '<a class="btn btn--secondary" href="/pos/invoicing?sale_id=' + o.sale_id + '">Ver venta #' + o.sale_id + '</a>' : '');
|
||||
return html;
|
||||
}
|
||||
|
||||
function closeDetailModal() {
|
||||
document.getElementById('detailModal').classList.remove('is-open');
|
||||
currentOrderId = null;
|
||||
currentOrder = null;
|
||||
}
|
||||
|
||||
// ─── Actions ───
|
||||
@@ -330,7 +537,7 @@ var Workshop = (function() {
|
||||
function addLabor() {
|
||||
var desc = document.getElementById('laborDesc').value.trim();
|
||||
var hours = parseFloat(document.getElementById('laborHours').value) || 0;
|
||||
var rate = parseFloat(document.getElementById('laborRate').value) || 0;
|
||||
var rate = hidePrices ? 0 : parseFloat(document.getElementById('laborRate').value) || 0;
|
||||
if (!desc) return alert('Escribe una descripción');
|
||||
api('POST', '/' + currentOrderId + '/labor', {
|
||||
description: desc,
|
||||
@@ -340,7 +547,7 @@ var Workshop = (function() {
|
||||
}).then(function() {
|
||||
document.getElementById('laborDesc').value = '';
|
||||
document.getElementById('laborHours').value = '';
|
||||
document.getElementById('laborRate').value = '';
|
||||
if (!hidePrices) document.getElementById('laborRate').value = '';
|
||||
openDetail(currentOrderId);
|
||||
}).catch(function(e) { alert('Error: ' + e.message); });
|
||||
}
|
||||
@@ -388,26 +595,34 @@ var Workshop = (function() {
|
||||
populateSelect('noCustomer', customers, function(c) { return {value: c.id, text: c.name + ' (' + (c.phone || '') + ')'}; });
|
||||
populateSelect('noVehicle', vehicles, function(v) { return {value: v.id, text: v.plate + ' ' + v.make + ' ' + v.model}; });
|
||||
populateSelect('noMechanic', employees, function(e) { return {value: e.id, text: e.name}; });
|
||||
populateSelect('noCourier', couriers, function(c) { return {value: c.id, text: c.name}; });
|
||||
document.getElementById('newOrderModal').classList.add('is-open');
|
||||
}
|
||||
|
||||
function closeNewOrderModal() {
|
||||
document.getElementById('newOrderModal').classList.remove('is-open');
|
||||
document.getElementById('newOrderForm').reset();
|
||||
var cf = document.getElementById('courierField');
|
||||
if (cf) cf.style.display = 'none';
|
||||
}
|
||||
|
||||
function submitNewOrder() {
|
||||
var customerId = document.getElementById('noCustomer').value;
|
||||
if (!customerId) return alert('Selecciona un cliente');
|
||||
api('POST', '', {
|
||||
var delivery = document.getElementById('noDelivery').value;
|
||||
var payload = {
|
||||
customer_id: parseInt(customerId, 10),
|
||||
vehicle_id: parseInt(document.getElementById('noVehicle').value, 10) || null,
|
||||
employee_id: parseInt(document.getElementById('noMechanic').value, 10) || null,
|
||||
priority: document.getElementById('noPriority').value,
|
||||
estimated_completion: document.getElementById('noEstimatedCompletion').value || null,
|
||||
mileage_in: parseInt(document.getElementById('noMileage').value, 10) || null,
|
||||
reception_notes: document.getElementById('noNotes').value
|
||||
}).then(function() {
|
||||
reception_notes: document.getElementById('noNotes').value,
|
||||
delivery_method: delivery || null,
|
||||
courier_id: delivery === 'courier' ? (parseInt(document.getElementById('noCourier').value, 10) || null) : null,
|
||||
is_direct: document.getElementById('noDirect').checked
|
||||
};
|
||||
api('POST', '', payload).then(function() {
|
||||
closeNewOrderModal();
|
||||
loadSummary();
|
||||
loadOrders();
|
||||
@@ -497,12 +712,42 @@ var Workshop = (function() {
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) { employees = (d.data || d.employees || []); })
|
||||
.catch(function() { employees = []; });
|
||||
// Couriers
|
||||
fetch('/pos/api/couriers?per_page=500', {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
couriers = (d.data || d.couriers || []);
|
||||
populateSelect('noCourier', couriers, function(c) { return {value: c.id, text: c.name}; });
|
||||
})
|
||||
.catch(function() { couriers = []; });
|
||||
// Branches
|
||||
fetch('/pos/api/config/branches', {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
branches = (d.data || []);
|
||||
populateBranchFilter();
|
||||
})
|
||||
.catch(function() { branches = []; });
|
||||
}
|
||||
|
||||
function populateBranchFilter() {
|
||||
var sel = document.getElementById('filterBranch');
|
||||
if (!sel) return;
|
||||
var current = sel.value;
|
||||
sel.innerHTML = '<option value="">Todas las sucursales</option>';
|
||||
branches.forEach(function(b) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = b.id;
|
||||
opt.textContent = b.name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
sel.value = current;
|
||||
}
|
||||
|
||||
function populateSelect(id, items, mapper) {
|
||||
var sel = document.getElementById(id);
|
||||
if (!sel) return;
|
||||
sel.innerHTML = id === 'noCustomer' ? '' : '<option value="">—</option>';
|
||||
sel.innerHTML = id === 'noCustomer' || id === 'noCourier' ? '' : '<option value="">—</option>';
|
||||
items.forEach(function(it) {
|
||||
var opt = mapper(it);
|
||||
var el = document.createElement('option');
|
||||
@@ -516,8 +761,11 @@ var Workshop = (function() {
|
||||
|
||||
return {
|
||||
init: init,
|
||||
setView: setView,
|
||||
goPage: goPage,
|
||||
openDetail: openDetail,
|
||||
closeDetailModal: closeDetailModal,
|
||||
switchTab: switchTab,
|
||||
changeStatus: changeStatus,
|
||||
reserveItem: reserveItem,
|
||||
addItemPlaceholder: addItemPlaceholder,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// /home/Autopartes/pos/static/pwa/sw.js
|
||||
// Nexus POS — Service Worker v17
|
||||
// Nexus POS — Service Worker
|
||||
// Self-contained vanilla JS. No external imports.
|
||||
//
|
||||
// Bump CACHE_NAME whenever static assets change significantly.
|
||||
// Bump VERSION whenever static assets change significantly.
|
||||
// The fetch handler normalizes static asset URLs (strips ?v= query strings)
|
||||
// so templates can use cache-busting query params freely.
|
||||
|
||||
const CACHE_NAME = 'nexus-pos-v26';
|
||||
const VERSION = 32;
|
||||
const CACHE_NAME = 'nexus-pos-v' + VERSION;
|
||||
|
||||
const APP_SHELL = [
|
||||
'/pos/static/css/tokens.css',
|
||||
|
||||
Reference in New Issue
Block a user