fix(audit): corrige errores criticos y mayores, mejora UX/accesibilidad y optimiza rendimiento
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- 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:
2026-06-29 23:54:58 +00:00
parent 59a4893e84
commit 2bdeb2973a
61 changed files with 2879 additions and 706 deletions

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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; }
}
/* =====================================================================

View File

@@ -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;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -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()">&#x2715;</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()">&times;</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
View 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;
}

View File

@@ -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
};
})();

View File

@@ -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: "📊" });
}
})();

View File

@@ -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> &nbsp;|&nbsp; <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)} &nbsp;&middot;&nbsp; ${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);
}

View File

@@ -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">&#x1F69A;</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() {
' &nbsp; ' + 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; }

View File

@@ -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);
});
}

View File

@@ -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,
};
})();

View File

@@ -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,
})
});

View File

@@ -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>';

View File

@@ -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: "📊" });
}
})();

View File

@@ -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>';
}

View File

@@ -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,

View File

@@ -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',