feat: cashier/counter reports, service-order & remission flows, Rached migration utils
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- Add "Mis cortes de caja" report for cashiers/counters with sales detail.
- Cash register history scoped to own cuts for non-admin roles; new /register/<id>/sales endpoint.
- Remove dashboard from cashier menu; add Reports to cashier/counter.
- Service orders: assign mechanic, budget field, invoice flag, counter/cashier can add items/remissions, convert to remission.
- Remission notes module (UI, CSS, courier, counter remissions).
- Customer hard-delete and vehicle/customer linkage in workshop.
- POS: always show search results, compact payment grid, credit validation, tier pricing (5%/10%), ticket with customer/folio.
- Inventory: CSV template with sku_secondary, alias import.
- Rached migration scripts and DB migrations.
- Version-bump cached JS/CSS query strings.

Excludes local Rached session tokens/captures (rached_*.json / rached_*.txt).
This commit is contained in:
2026-07-02 12:51:56 +00:00
parent 483498cfcc
commit f42910f4f6
71 changed files with 5388 additions and 626 deletions

View File

@@ -713,18 +713,23 @@
}
.pago-tabs {
display: flex; border-bottom: 2px solid var(--color-border); padding: 0 var(--space-6);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
gap: var(--space-2);
padding: var(--space-4) var(--space-6);
border-bottom: none;
}
.pago-tab {
padding: var(--space-3) var(--space-5); font-family: var(--font-body);
font-size: var(--text-body-sm); font-weight: var(--font-weight-semibold);
background: transparent; border: none; color: var(--color-text-muted);
cursor: pointer; border-bottom: 2px solid transparent;
margin-bottom: -2px; transition: var(--transition-fast);
display: flex; align-items: center; gap: var(--space-2);
padding: var(--space-3) var(--space-2); font-family: var(--font-body);
font-size: var(--text-caption); font-weight: var(--font-weight-semibold);
background: var(--color-surface); border: 1px solid var(--color-border);
border-radius: var(--radius-md); color: var(--color-text-muted);
cursor: pointer; transition: var(--transition-fast);
display: flex; align-items: center; justify-content: center;
text-align: center; min-height: 44px;
}
.pago-tab:hover { color: var(--color-text-primary); }
.pago-tab.active { color: var(--color-text-accent); border-bottom-color: var(--color-primary); }
.pago-tab:hover { background: var(--color-surface-2); color: var(--color-text-primary); }
.pago-tab.active { background: var(--color-primary); border-color: var(--color-primary); color: #fff; }
.tab-content { padding: var(--space-6); display: none; }
.tab-content.active { display: block; }

View File

@@ -0,0 +1,195 @@
/* Remission notes page — matches the Nexus POS design system */
/* Scrollable page content area */
.page-content {
flex: 1;
overflow-y: auto;
padding: var(--space-5) var(--space-6);
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
.page-content::-webkit-scrollbar { width: 6px; }
.page-content::-webkit-scrollbar-track { background: var(--scrollbar-track); }
.page-content::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: var(--radius-full); }
/* Filters card */
.filters-card {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
margin-bottom: var(--space-5);
box-shadow: var(--shadow-sm);
}
[data-theme="modern"] .filters-card {
background: var(--color-bg-overlay);
}
/* Date input reuse select-filter styling */
.select-filter[type="date"] {
padding: 0 var(--space-3);
}
/* Status badges for remission notes */
.badge--pending_payment { background: var(--color-primary-muted); color: var(--color-primary); }
.badge--completed { background: rgba(34, 197, 94, 0.15); color: var(--color-success); }
.badge--cancelled { background: rgba(115, 115, 115, 0.15); color: var(--color-text-muted); }
/* Action buttons in table */
.action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
padding: 0 var(--space-2);
height: 28px;
border-radius: var(--radius-md);
font-size: var(--text-caption);
font-weight: var(--font-weight-semibold);
cursor: pointer;
border: 1px solid transparent;
transition: var(--transition-fast);
white-space: nowrap;
}
.action-btn svg {
width: 13px;
height: 13px;
stroke: currentColor;
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.action-btn--ghost {
background: var(--btn-ghost-bg);
color: var(--btn-ghost-text);
border-color: var(--btn-ghost-border);
}
.action-btn--ghost:hover {
background: var(--color-surface-2);
border-color: var(--color-border-strong);
color: var(--color-text-primary);
}
.action-btn--primary {
background: var(--btn-primary-bg);
color: var(--btn-primary-text);
border-color: var(--btn-primary-border);
}
.action-btn--primary:hover { background: var(--btn-primary-bg-hover); }
/* Empty state */
.empty-state {
padding: var(--space-10) var(--space-6);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: none;
align-items: center;
justify-content: center;
z-index: var(--z-modal, 1050);
padding: var(--space-4);
}
.modal-overlay.is-open { display: flex; }
.modal {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
width: 100%;
max-width: 420px;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-xl);
display: flex;
flex-direction: column;
}
[data-theme="modern"] .modal {
background: var(--color-bg-overlay);
}
.modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-border);
}
.modal__title {
font-family: var(--font-heading);
font-size: var(--text-h6);
font-weight: var(--heading-weight-primary);
color: var(--color-text-primary);
margin: 0;
}
.modal__close {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 1.25rem;
cursor: pointer;
line-height: 1;
padding: var(--space-1);
}
.modal__close:hover { color: var(--color-text-primary); }
.modal__body {
padding: var(--space-5);
}
.modal__footer {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
border-top: 1px solid var(--color-border);
}
/* Ticket preview (monospace) */
.ticket-preview {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.8125rem;
line-height: 1.45;
color: var(--color-text-primary);
}
.ticket-preview__center { text-align: center; }
.ticket-preview__bold { font-weight: 700; }
.ticket-preview__line {
display: flex;
justify-content: space-between;
gap: var(--space-3);
}
.ticket-preview__divider {
border-top: 1px dashed var(--color-border-strong);
margin: var(--space-3) 0;
}
.ticket-preview__items { margin: var(--space-3) 0; }
.ticket-preview__item {
display: flex;
justify-content: space-between;
gap: var(--space-3);
}
.ticket-preview__footer {
text-align: center;
margin-top: var(--space-3);
}
/* Responsive */
@media (max-width: 1024px) {
.page-content {
padding: var(--space-4);
}
.toolbar {
flex-direction: column;
align-items: stretch;
}
.search-box, .select-filter {
max-width: 100%;
width: 100%;
}
.toolbar__spacer { display: none; }
}

View File

@@ -947,6 +947,30 @@ body {
.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--por_revisar { background: rgba(59, 130, 246, 0.12); color: #3b82f6; }
.badge--en_revision { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
.badge--revisada { background: rgba(139, 92, 246, 0.12); color: #8b5cf6; }
.badge--cotizada { background: rgba(245, 166, 35, 0.12); color: #f5a623; }
.badge--por_autorizar { background: rgba(249, 115, 22, 0.12); color: #f97316; }
.badge--autorizada { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
.badge--autorizacion_parcial { background: rgba(16, 185, 129, 0.12); color: #10b981; }
.badge--en_reparacion { background: rgba(245, 166, 35, 0.18); color: #d97706; }
.badge--reparada { background: rgba(20, 184, 166, 0.12); color: #14b8a6; }
.badge--por_entregar { background: rgba(59, 130, 246, 0.16); color: #2563eb; }
.badge--entregado { background: rgba(16, 185, 129, 0.12); color: #10b981; }
.badge--por_enviar { background: rgba(99, 102, 241, 0.16); color: #4f46e5; }
.badge--enviado { background: rgba(14, 165, 233, 0.12); color: #0ea5e9; }
.badge--por_facturar { background: rgba(168, 85, 247, 0.12); color: #a855f7; }
.badge--facturada { background: rgba(236, 72, 153, 0.12); color: #ec4899; }
.badge--por_recolectar { background: rgba(100, 116, 139, 0.12); color: #64748b; }
.badge--cancelada { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
.badge--revisando { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
.badge--revisado { background: rgba(139, 92, 246, 0.12); color: #8b5cf6; }
.badge--cotizado { background: rgba(245, 166, 35, 0.12); color: #f5a623; }
.badge--por_autorizar { background: rgba(249, 115, 22, 0.12); color: #f97316; }
.badge--autorizado { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
.badge--reparado { background: rgba(20, 184, 166, 0.12); color: #14b8a6; }
.badge--cancelado { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
.badge--normal { background: rgba(148, 163, 184, 0.12); color: #94a3b8; }
.badge--high { background: rgba(245, 166, 35, 0.12); color: #f5a623; }

View File

@@ -182,10 +182,85 @@
permissions: payload.permissions || []
};
// ─── Restrict workshop/mechanic users to the workshop view only ───
if ((role === 'workshop' || role === 'mechanic') && window.location.pathname !== '/pos/workshop') {
window.location.replace('/pos/workshop');
return;
// ─── Page guard based on role + permissions ───
function isPageAllowed(pagePath, userRole, userPerms) {
if (userRole === 'owner' || userRole === 'admin') return true;
// Workshop/mechanic accounts always see Taller; extra modules depend on permissions.
if (userRole === 'workshop' || userRole === 'mechanic') {
var allowed = ['/pos/workshop'];
var permMap = {
'customers.view': '/pos/customers',
'inventory.view': '/pos/inventory',
'catalog.view': '/pos/catalog',
'pos.sell': '/pos/sale',
'pos.view': '/pos/sale',
'pos.remission': '/pos/remission-notes',
'invoicing.view': '/pos/invoicing',
'quotations.view': '/pos/quotations',
'accounting.view': '/pos/accounting',
'reports.view': '/pos/reports',
'config.view': '/pos/config',
'config.edit': '/pos/config'
};
for (var p in permMap) {
if (userPerms.indexOf(p) !== -1 && allowed.indexOf(permMap[p]) === -1) {
allowed.push(permMap[p]);
}
}
return allowed.indexOf(pagePath) !== -1;
}
// Counter: fixed module set (no dashboard).
if (userRole === 'counter') {
return ['/pos/sale','/pos/catalog','/pos/inventory','/pos/customers','/pos/workshop','/pos/remission-notes','/pos/reports'].indexOf(pagePath) !== -1;
}
// Cashier: fixed module set (no dashboard).
if (userRole === 'cashier') {
return ['/pos/sale','/pos/catalog','/pos/inventory','/pos/customers','/pos/workshop','/pos/remission-notes','/pos/invoicing','/pos/reports'].indexOf(pagePath) !== -1;
}
// Any other role (accountant, warehouse, sales, etc.) keeps the previous permissive behavior.
return true;
}
function enforcePageGuard(userRole, userPerms) {
if (isPageAllowed(path, userRole, userPerms)) return true;
var fallback = '/pos/catalog';
if (userRole === 'counter' || userRole === 'cashier') fallback = '/pos/sale';
else if (userRole === 'workshop' || userRole === 'mechanic') fallback = '/pos/workshop';
window.location.replace(fallback);
return false;
}
// ─── Refresh permissions/token from server before enforcing the guard ───
// This makes permission changes effective without requiring a full re-login.
try {
fetch('/pos/api/auth/refresh', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token }
}).then(function(r) {
if (r.ok) return r.json();
return null;
}).then(function(data) {
if (data && data.token) {
localStorage.setItem('pos_token', data.token);
localStorage.setItem('pos_employee', JSON.stringify(data.employee));
token = data.token;
window.POS_USER.token = data.token;
window.POS_USER.permissions = data.permissions || [];
window.POS_USER.branchId = data.employee.branch_id;
}
if (!enforcePageGuard(window.POS_USER.role, window.POS_USER.permissions)) return;
if (typeof window.renderSidebar === 'function') {
window.renderSidebar(window.POS_USER.modules || JSON.parse(localStorage.getItem('pos_modules') || '{}'));
}
}).catch(function() {
enforcePageGuard(role, payload.permissions || []);
});
} catch(e) {
enforcePageGuard(role, payload.permissions || []);
}
// ─── Preload enabled modules for sidebar filtering ───

View File

@@ -9,6 +9,8 @@ const Config = (() => {
// Cache for branches (used by employee modal selector)
let _branches = [];
let _rolePermissions = {};
let _availablePermissions = [];
function token() {
return localStorage.getItem('pos_token') || '';
@@ -108,6 +110,7 @@ const Config = (() => {
owner: 'Dueno',
admin: 'Admin',
cashier: 'Cajero',
counter: 'Mostrador',
warehouse: 'Almacenista',
accountant: 'Contador',
workshop: 'Taller',
@@ -118,6 +121,7 @@ const Config = (() => {
owner: 'badge--owner',
admin: 'badge--blue',
cashier: 'badge--green',
counter: 'badge--gray',
warehouse: 'badge--yellow',
accountant: 'badge--purple',
workshop: 'badge--orange',
@@ -961,6 +965,15 @@ const Config = (() => {
} catch (e) {
console.error('Config.loadModules:', e);
}
try {
var res2 = await fetch(API + '/counter-remission', { headers: headers() });
if (!res2.ok) return;
var d2 = await res2.json();
var cbCr = document.getElementById('cfg-module-counter-remission');
if (cbCr) cbCr.checked = d2.enabled === true;
} catch (e) {
console.error('Config.loadCounterRemission:', e);
}
}
async function saveModules() {
@@ -968,7 +981,8 @@ const Config = (() => {
var cbMp = document.getElementById('cfg-module-marketplace');
var cbMeli = document.getElementById('cfg-module-meli');
var cbCat = document.getElementById('cfg-module-catalog');
if (!cbWa && !cbMp && !cbMeli && !cbCat) return;
var cbCr = document.getElementById('cfg-module-counter-remission');
if (!cbWa && !cbMp && !cbMeli && !cbCat && !cbCr) return;
try {
var data = {
whatsapp: cbWa ? cbWa.checked : true,
@@ -986,12 +1000,112 @@ const Config = (() => {
throw new Error(err.error || 'Save failed');
}
localStorage.setItem('pos_modules', JSON.stringify(data));
if (cbCr) {
var res2 = await fetch(API + '/counter-remission', {
method: 'PUT',
headers: headers(),
body: JSON.stringify({ enabled: cbCr.checked })
});
if (!res2.ok) {
var err2 = await res2.json().catch(function() { return { error: res2.statusText }; });
throw new Error(err2.error || 'Save failed');
}
}
toast('Módulos actualizados');
} catch (e) {
toast(e.message, 'error');
}
}
// -------------------------------------------------------------------------
// Tab navigation
// -------------------------------------------------------------------------
function switchTab(tab) {
document.querySelectorAll('.cfg-tab-btn').forEach(function(btn) {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
document.querySelectorAll('.settings-section[data-tab]').forEach(function(sec) {
var isActive = sec.dataset.tab === tab;
sec.classList.toggle('active', isActive);
sec.style.display = isActive ? '' : 'none';
});
try { localStorage.setItem('pos_config_tab', tab); } catch(e) {}
}
// -------------------------------------------------------------------------
// Role permissions editor
// -------------------------------------------------------------------------
async function loadRolePermissions() {
try {
var res = await fetch(API + '/role-permissions', { headers: headers() });
if (!res.ok) return;
var data = await res.json();
_rolePermissions = data.roles || {};
_availablePermissions = data.available || [];
renderRolePermissions();
} catch (e) {
console.error('Config.loadRolePermissions:', e);
}
}
function renderRolePermissions() {
var container = document.getElementById('role-permissions-container');
var roleSel = document.getElementById('cfg-perm-role');
if (!container || !roleSel) return;
var role = roleSel.value;
if (!role) {
container.innerHTML = '<p style="color:var(--color-text-muted);">Selecciona un rol para ver y editar sus permisos.</p>';
return;
}
var current = _rolePermissions[role] || [];
var html = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-4);">';
_availablePermissions.forEach(function(group) {
html += '<div style="border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-3);background:var(--color-surface-2);">';
html += '<h4 style="margin:0 0 var(--space-3);font-size:var(--text-body-sm);color:var(--color-text-primary);">' + escapeHtml(group.module) + '</h4>';
group.permissions.forEach(function(p) {
var checked = current.indexOf(p.key) !== -1 ? 'checked' : '';
html += '<label style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2);cursor:pointer;font-size:var(--text-body-sm);">';
html += '<input type="checkbox" data-perm-key="' + escapeHtml(p.key) + '" ' + checked + ' style="width:auto;" />';
html += '<span>' + escapeHtml(p.label) + '</span>';
html += '</label>';
});
html += '</div>';
});
html += '</div>';
container.innerHTML = html;
}
async function saveRolePermissions() {
var roleSel = document.getElementById('cfg-perm-role');
var status = document.getElementById('role-permissions-status');
if (!roleSel || !roleSel.value) {
if (status) status.textContent = 'Selecciona un rol';
return;
}
var role = roleSel.value;
var selected = [];
document.querySelectorAll('#role-permissions-container input[data-perm-key]').forEach(function(cb) {
if (cb.checked) selected.push(cb.dataset.permKey);
});
var payload = { roles: {} };
payload.roles[role] = selected;
try {
var res = await fetch(API + '/role-permissions', {
method: 'PUT',
headers: headers(),
body: JSON.stringify(payload)
});
var data = await res.json().catch(function() { return { error: res.statusText }; });
if (!res.ok) throw new Error(data.error || 'Error al guardar');
_rolePermissions[role] = selected;
if (status) status.textContent = 'Permisos guardados y aplicados a empleados existentes';
setTimeout(function() { if (status) status.textContent = ''; }, 4000);
} catch (e) {
if (status) status.textContent = e.message;
toast(e.message, 'error');
}
}
// -------------------------------------------------------------------------
// Init
// -------------------------------------------------------------------------
@@ -1044,6 +1158,11 @@ const Config = (() => {
});
}
// Show permissions tab only for owner/admin
var isAdmin = user.role === 'owner' || user.role === 'admin';
var permTabBtn = document.querySelector('.cfg-tab-btn--permissions');
if (permTabBtn) permTabBtn.style.display = isAdmin ? '' : 'none';
// Load real data in parallel
loadBranches();
loadEmployees();
@@ -1053,6 +1172,15 @@ const Config = (() => {
loadAllowedBrands();
loadModules();
loadReceiptConfig();
if (isAdmin) loadRolePermissions();
// Activate default or stored tab
var defaultTab = 'general';
try {
var storedTab = localStorage.getItem('pos_config_tab');
if (storedTab) defaultTab = storedTab;
} catch(e) {}
switchTab(defaultTab);
}
document.addEventListener('DOMContentLoaded', init);
@@ -1074,7 +1202,8 @@ const Config = (() => {
loadVehicleCompatSource, saveVehicleCompatSource,
loadModules, saveModules,
loadReceiptConfig, saveReceiptConfig, handleReceiptLogo, removeReceiptLogo,
openModal, closeModal, openBranchModal, editBranch
openModal, closeModal, openBranchModal, editBranch,
switchTab, loadRolePermissions, renderRolePermissions, saveRolePermissions
};
})();

View File

@@ -10,6 +10,7 @@ var I18N = {
'catalog': 'Catalogo',
'inventory': 'Inventario',
'diagrams': 'Diagramas',
'remission_notes': 'Notas de Remisión',
'customers': 'Clientes',
'invoicing': 'Facturacion',
'accounting': 'Contabilidad',
@@ -166,6 +167,7 @@ var I18N = {
'catalog': 'Catalog',
'inventory': 'Inventory',
'diagrams': 'Diagrams',
'remission_notes': 'Remission Notes',
'customers': 'Customers',
'invoicing': 'Invoicing',
'accounting': 'Accounting',

View File

@@ -21,6 +21,19 @@
var userRole = (user.role || '').toLowerCase();
var userPerms = user.permissions || [];
var canEditPrices = userRole === 'owner' || userRole === 'admin' || userPerms.indexOf('config.edit_prices') !== -1;
var canCreateItem = userRole === 'owner' || userRole === 'admin' || userRole === 'counter' || userRole === 'cashier' || userRole === 'warehouse' || userPerms.indexOf('inventory.create') !== -1;
var canEditItem = userRole === 'owner' || userRole === 'admin' || userRole === 'warehouse' || userPerms.indexOf('inventory.edit') !== -1;
var canImportItems = userRole === 'owner' || userRole === 'admin' || userRole === 'warehouse' || userPerms.indexOf('inventory.edit') !== -1;
// Hide toolbar actions the user is not allowed to use
(function applyInventoryPermissions() {
var headerNew = document.getElementById('btnHeaderNewProduct');
var stockNew = document.getElementById('btnStockNewProduct');
var headerImport = document.getElementById('btnHeaderImport');
if (headerNew) headerNew.style.display = canCreateItem ? '' : 'none';
if (stockNew) stockNew.style.display = canCreateItem ? '' : 'none';
if (headerImport) headerImport.style.display = canImportItems ? '' : 'none';
})();
// Load compatibility source setting
(function loadCompatSource() {
@@ -186,11 +199,11 @@
'<td>' + esc(it.location) + '</td>' +
'<td>' +
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();viewHistory(' + it.id + ')">Historial</button> ' +
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();showEditItemModal(' + it.id + ')">Editar</button> ' +
'<button class="btn btn--ghost btn--sm" style="color:var(--color-accent);" onclick="event.stopPropagation();showPurchaseModalForItem(' + it.id + ')">Entrada</button> ' +
'<button class="btn btn--sm btn--meli" onclick="event.stopPropagation();publishToMeli(' + it.id + ')">ML</button> ' +
(canEditItem ? '<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();showEditItemModal(' + it.id + ')">Editar</button> ' : '') +
(canCreateItem ? '<button class="btn btn--ghost btn--sm" style="color:var(--color-accent);" onclick="event.stopPropagation();showPurchaseModalForItem(' + it.id + ')">Entrada</button> ' : '') +
(userPerms.indexOf('marketplace.manage') !== -1 || userRole === 'owner' || userRole === 'admin' ? '<button class="btn btn--sm btn--meli" onclick="event.stopPropagation();publishToMeli(' + it.id + ')">ML</button> ' : '') +
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();printBarcode(\'' + esc(it.barcode) + '\',\'' + esc(it.part_number) + '\',\'' + esc(it.name) + '\')">Etiqueta</button> ' +
'<button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="event.stopPropagation();deleteItem(' + it.id + ')">Eliminar</button>' +
(canEditItem ? '<button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="event.stopPropagation();deleteItem(' + it.id + ')">Eliminar</button>' : '') +
'</td></tr>';
}
@@ -544,6 +557,28 @@
}
window.submitBulkImport = submitBulkImport;
function downloadBulkImportTemplate() {
var headers = [
'numero_de_parte','nombre','marca','precio','cantidad','costo',
'sku_secundario','descripcion','categoria','fabricante','modelo','anio','motor','codigo_motor'
];
var example = [
'EJ-001','Filtro de aceite','ACDelco','150.00','10','90.00',
'EJ001-ALT','Filtro para sedan','Filtros','Nissan','Sentra','2020','1.8','MR18DE'
];
var csv = [headers.join(','), example.join(',')].join('\n');
var blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'plantilla_inventario.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
window.downloadBulkImportTemplate = downloadBulkImportTemplate;
// =====================================================================
// PURCHASE / ENTRADA (purchaseModal)
// =====================================================================

View File

@@ -83,7 +83,12 @@
localStorage.setItem('pos_employee', JSON.stringify(result.data.employee));
localStorage.setItem('pos_tenant_id', tenantId);
document.cookie = 'pos_role=' + (result.data.employee.role || '') + '; path=/pos; SameSite=Lax';
window.location.href = '/pos/catalog';
var role = (result.data.employee.role || '').toLowerCase();
if (role === 'workshop' || role === 'mechanic' || role === 'counter') {
window.location.href = '/pos/workshop';
} else {
window.location.href = '/pos/catalog';
}
})
.catch(function() {
errorEl.textContent = 'Error de conexion';

View File

@@ -30,7 +30,12 @@ const POS = (() => {
let canEditPrice = false;
let canCreateWorkshopOrder = false;
let canCreateLayaway = false;
let canCreateRemission = false;
let counterRemissionEnabled = false;
let currentPerms = [];
let receiptConfig = {};
let couriers = [];
let selectedCourierId = null;
// Currency-aware formatter: reads pos_currency from localStorage
const _posCurrency = localStorage.getItem('pos_currency') || 'MXN';
@@ -38,6 +43,16 @@ const POS = (() => {
const _currLocale = _posCurrency === 'USD' ? 'en-US' : 'es-MX';
const fmt = (n) => (_currSymbols[_posCurrency] || '$') + parseFloat(n || 0).toLocaleString(_currLocale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
// Price based on customer tier: base price (price_1) with tier discount.
// Tier 2 = Taller (5% off), Tier 3 = Mayoreo (10% off), Tier 1 = base.
function priceForTier(basePrice, tier) {
const p = parseFloat(basePrice) || 0;
const t = parseInt(tier, 10) || 1;
if (t === 2) return Math.round(p * 0.95 * 100) / 100;
if (t === 3) return Math.round(p * 0.90 * 100) / 100;
return p;
}
function headers() {
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
}
@@ -66,6 +81,10 @@ const POS = (() => {
const el = document.querySelector(selector);
if (el) el.style.display = 'none';
}
function show(selector) {
const el = document.querySelector(selector);
if (el) el.style.display = '';
}
if (!canCancel) {
hide('#btnCancelSale');
hide('#fkeyEsc');
@@ -77,6 +96,25 @@ const POS = (() => {
hide('[title="Orden de servicio"]');
}
if (!canCreateLayaway) hide('[onclick="POS.createLayaway()"]');
// Counter remission workflow
if (canCreateRemission) {
hide('#btnCobrar');
hide('.fkey[onclick="POS.checkout()"]');
hide('[onclick="POS.createLayaway()"]');
hide('[onclick="POS.saveQuotation()"]');
show('#btnRemission');
show('#courierSelectField');
} else {
hide('#btnRemission');
hide('#courierSelectField');
}
if (!currentPerms.includes('pos.sell')) {
hide('#btnPayRemission');
} else {
show('#btnPayRemission');
}
}
// ─── Init ────────────────────────────
@@ -87,14 +125,26 @@ const POS = (() => {
document.getElementById('employeeName').textContent = payload.name || 'Empleado';
document.getElementById('branchName').textContent = payload.branch_name || '';
const perms = payload.permissions || [];
currentPerms = perms;
const employeeRole = payload.role || '';
canViewCost = perms.includes('pos.view_cost');
canCancel = perms.includes('pos.cancel');
canDiscount = perms.includes('pos.discount');
canEditPrice = perms.includes('config.edit_prices');
canEditPrice = perms.includes('config.edit_prices') || employeeRole === 'cashier' || employeeRole === 'counter';
canCreateWorkshopOrder = perms.includes('workshop.edit');
canCreateLayaway = perms.includes('pos.sell');
employeeMaxDiscount = payload.max_discount_pct || 100;
// Counter remission feature
try {
const crCfg = await api('/pos/api/config/counter-remission');
counterRemissionEnabled = crCfg.enabled === true;
} catch (e) {
counterRemissionEnabled = false;
}
// Counter remission workflow applies only to the counter role.
canCreateRemission = counterRemissionEnabled && employeeRole === 'counter' && perms.includes('pos.remission');
// Show cost/margin columns and toggle button if permission
if (canViewCost) {
document.getElementById('thCost').style.display = '';
@@ -138,6 +188,7 @@ const POS = (() => {
// Load current register and receipt config
await loadRegister();
await loadReceiptConfig();
await loadCouriers();
// Setup event listeners
setupKeyboard();
@@ -157,8 +208,10 @@ const POS = (() => {
currentRegister = null;
document.getElementById('registerInfo').innerHTML =
'<span style="color:var(--color-error);cursor:pointer;" onclick="POS.showOpenRegisterModal()" title="Clic para abrir caja">&#x26A0; Sin caja abierta — Clic para abrir</span>';
// Force open register modal on first load
showOpenRegisterModal();
// Force open register modal on first load only for users who can sell
if (currentPerms.includes('pos.sell')) {
showOpenRegisterModal();
}
}
} catch (e) {
console.warn('Register check failed:', e);
@@ -174,6 +227,24 @@ const POS = (() => {
}
}
async function loadCouriers() {
try {
const data = await api('/pos/api/logistics/couriers');
couriers = data.couriers || [];
const sel = document.getElementById('remissionCourier');
if (sel) {
sel.innerHTML = '<option value="">-- Sin repartidor --</option>' +
couriers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
sel.addEventListener('change', () => {
selectedCourierId = sel.value ? parseInt(sel.value, 10) : null;
});
}
} catch (e) {
console.warn('Could not load couriers:', e);
couriers = [];
}
}
function rc(key, fallback) {
const v = receiptConfig[key];
return v !== undefined && v !== null && v !== '' ? v : fallback;
@@ -570,26 +641,11 @@ const POS = (() => {
if (data.data.length === 0) {
container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--color-text-muted);">Sin resultados</div>';
if (window.BarcodeFeedback) BarcodeFeedback.error();
} else if (data.data.length === 1 && q.length >= 8) {
// Auto-select single result on barcode scan (long codes)
const item = data.data[0];
let price = item.price_1;
if (currentCustomer) {
const tier = currentCustomer.price_tier || 1;
price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
}
addFromSearch(item, price);
input.value = '';
hideSearchResults();
return;
} else {
let html = '';
data.data.forEach(item => {
let price = item.price_1;
if (currentCustomer) {
const tier = currentCustomer.price_tier || 1;
price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
}
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
const price = priceForTier(item.price_1, tier);
html += `<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;display:flex;justify-content:space-between;align-items:center;transition:var(--transition-fast);" onmouseover="this.style.background='var(--color-primary-muted)'" onmouseout="this.style.background=''" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "&#39;")}, ${price})'>
<div>
<div style="font-weight:var(--font-weight-semibold);">${item.name}</div>
@@ -684,7 +740,7 @@ const POS = (() => {
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
cart.forEach(item => {
if (item.price_1 > 0) {
item.unit_price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
item.unit_price = priceForTier(item.price_1, tier);
}
});
}
@@ -880,9 +936,12 @@ const POS = (() => {
transferencia: 'refPayment',
tarjeta: 'refPayment',
mixto: 'mixedPayment',
credito: 'creditPayment',
cheque: 'chequePayment',
pendiente: 'pendingPayment',
};
['cashPayment', 'refPayment', 'mixedPayment'].forEach(id => {
['cashPayment', 'refPayment', 'mixedPayment', 'creditPayment', 'chequePayment', 'pendingPayment'].forEach(id => {
const el = document.getElementById(id);
if (el) {
const isActive = el.id === tabs[method];
@@ -896,6 +955,12 @@ const POS = (() => {
const ref = document.getElementById('paymentRef');
if (ref) ref.focus();
}
if (method === 'cheque') {
const chequeAmount = document.getElementById('chequeAmount');
if (chequeAmount) chequeAmount.value = fmt(getTotal());
const chequeRef = document.getElementById('chequeRef');
if (chequeRef) chequeRef.focus();
}
}
function updateChange() {
@@ -933,6 +998,7 @@ const POS = (() => {
let amountPaid = 0;
let paymentDetails = [];
let reference = '';
let saleType = 'cash';
if (paymentMethod === 'efectivo') {
amountPaid = parseFloat(document.getElementById('cashReceived').value) || 0;
@@ -952,6 +1018,20 @@ const POS = (() => {
}
});
if (amountPaid < total) { alert(`Monto total insuficiente. Falta: ${fmt(total - amountPaid)}`); return; }
} else if (paymentMethod === 'credito') {
if (!currentCustomer) { alert('Seleccione un cliente para venta a crédito'); return; }
const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0);
if (total > available) {
alert(`Crédito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}`);
return;
}
saleType = 'credit';
amountPaid = 0;
} else if (paymentMethod === 'cheque') {
amountPaid = total;
reference = document.getElementById('chequeRef').value.trim();
} else if (paymentMethod === 'pendiente') {
amountPaid = 0;
}
const saleData = {
@@ -964,7 +1044,7 @@ const POS = (() => {
})),
customer_id: currentCustomer ? currentCustomer.id : null,
payment_method: paymentMethod,
sale_type: 'cash',
sale_type: saleType,
register_id: currentRegister ? currentRegister.id : null,
amount_paid: amountPaid,
payment_details: paymentDetails,
@@ -983,9 +1063,10 @@ const POS = (() => {
const convertData = {
register_id: currentRegister ? currentRegister.id : null,
payment_method: paymentMethod,
sale_type: 'cash',
sale_type: saleType,
amount_paid: amountPaid,
payment_details: paymentDetails,
reference: reference,
};
sale = await api('/pos/api/quotations/' + convertQuoteId + '/convert', {
method: 'POST',
@@ -1020,22 +1101,13 @@ const POS = (() => {
}
}
// ─── Credit Sale ─────────────────────
async function creditSale() {
if (cart.length === 0) { alert('Carrito vacio'); return; }
if (!currentCustomer) { alert('Seleccione un cliente para venta a credito'); return; }
if (!currentRegister) { alert('No hay caja abierta.'); return; }
// ─── Counter Remission Note ────────────
async function createRemissionNote() {
if (cart.length === 0) { showToast('Carrito vacio'); return; }
if (!canCreateRemission) { showToast('No tienes permiso para generar notas de remision'); return; }
const total = getTotal();
const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0);
if (currentCustomer.credit_limit > 0 && total > available) {
if (!confirm(`Credito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}. Continuar?`)) {
return;
}
}
const saleData = {
const noteData = {
items: cart.map(item => ({
inventory_id: item.inventory_id,
quantity: item.quantity,
@@ -1043,19 +1115,21 @@ const POS = (() => {
discount_pct: item.discount_pct,
tax_rate: item.tax_rate,
})),
customer_id: currentCustomer.id,
payment_method: 'credito',
sale_type: 'credit',
customer_id: currentCustomer ? currentCustomer.id : null,
notes: 'Nota de remision generada desde mostrador',
register_id: currentRegister ? currentRegister.id : null,
amount_paid: 0,
courier_id: selectedCourierId,
};
try {
const sale = await api('/pos/api/sales', {
const sale = await api('/pos/api/sales/remission', {
method: 'POST',
body: JSON.stringify(saleData),
body: JSON.stringify(noteData),
});
if (selectedCourierId) {
const courier = couriers.find(c => c.id === selectedCourierId);
sale.courier_name = courier ? courier.name : '';
}
lastSaleId = sale.id;
lastSaleData = sale;
try { sessionStorage.setItem('pos_last_sale_id', sale.id); } catch(e) {}
@@ -1063,12 +1137,128 @@ const POS = (() => {
cart = [];
selectedRow = -1;
clearCustomer();
selectedCourierId = null;
const sel = document.getElementById('remissionCourier');
if (sel) sel.value = '';
renderCart();
showToast(`Nota de remision NR-${sale.id} generada`);
} catch (e) {
alert('Error: ' + e.message);
alert('Error al generar nota de remision: ' + e.message);
}
}
async function openPayRemissionModal() {
if (!currentPerms.includes('pos.sell')) { showToast('No tienes permiso para cobrar notas'); return; }
document.getElementById('payRemissionFolio').value = '';
document.getElementById('payRemissionDetail').innerHTML = '';
document.getElementById('payRemissionResult').innerHTML = '';
document.getElementById('payRemissionActions').style.display = 'none';
document.getElementById('payRemissionModal').classList.add('open');
setTimeout(() => document.getElementById('payRemissionFolio').focus(), 100);
}
function closePayRemissionModal() {
document.getElementById('payRemissionModal').classList.remove('open');
}
let pendingRemissionToPay = null;
async function searchRemissionToPay() {
const folio = parseInt(document.getElementById('payRemissionFolio').value, 10);
if (!folio) { showToast('Ingresa un folio valido'); return; }
pendingRemissionToPay = null;
try {
const sale = await api('/pos/api/sales/' + folio);
if (sale.status !== 'pending_payment') {
document.getElementById('payRemissionDetail').innerHTML = `<div class="error-msg">La venta ${folio} no esta pendiente de pago</div>`;
document.getElementById('payRemissionActions').style.display = 'none';
return;
}
pendingRemissionToPay = sale;
let itemsHtml = (sale.items || []).map(it => `
<div class="ticket-line">
<span class="qty">${it.quantity}</span>
<span class="name">${it.name || ''}</span>
<span class="subtotal">${fmt(it.subtotal || 0)}</span>
</div>
`).join('');
document.getElementById('payRemissionDetail').innerHTML = `
<div class="info-row"><span>Cliente:</span><span>${sale.customer_name || 'Publico General'}</span></div>
<div class="info-row"><span>Vendedor:</span><span>${sale.employee_name || ''}</span></div>
<div class="info-row"><span>Total:</span><span class="grand">${fmt(sale.total)}</span></div>
<hr class="divider">
${itemsHtml}
`;
document.getElementById('payRemissionActions').style.display = '';
} catch (e) {
document.getElementById('payRemissionDetail').innerHTML = `<div class="error-msg">${e.message}</div>`;
document.getElementById('payRemissionActions').style.display = 'none';
}
}
async function confirmPayRemission() {
if (!pendingRemissionToPay) return;
const sale = pendingRemissionToPay;
const paymentMethod = document.getElementById('payRemissionMethod').value;
let amountPaid = parseFloat(sale.total);
let paymentDetails = [];
let reference = '';
if (paymentMethod === 'efectivo') {
const received = parseFloat(document.getElementById('payRemissionReceived').value) || 0;
if (received < sale.total) { alert('Monto insuficiente'); return; }
amountPaid = received;
} else if (paymentMethod === 'mixto') {
const rows = document.querySelectorAll('#payRemissionMixed .mixed-row');
let sum = 0;
rows.forEach(row => {
const method = row.querySelector('select').value;
const amount = parseFloat(row.querySelector('.mixed-amount').value) || 0;
const ref = row.querySelectorAll('input')[1]?.value || '';
if (amount > 0) {
paymentDetails.push({ method, amount, reference: ref });
sum += amount;
}
});
if (sum < sale.total) { alert(`Monto total insuficiente. Falta: ${fmt(sale.total - sum)}`); return; }
amountPaid = sum;
} else {
reference = document.getElementById('payRemissionReference').value.trim();
}
try {
const result = await api('/pos/api/sales/' + sale.id + '/pay', {
method: 'POST',
body: JSON.stringify({
payment_method: paymentMethod,
amount_paid: amountPaid,
payment_details: paymentDetails,
register_id: currentRegister ? currentRegister.id : null,
reference: reference,
}),
});
closePayRemissionModal();
showToast(`Nota NR-${sale.id} pagada`);
// Refresh sale object to print paid ticket
const updated = await api('/pos/api/sales/' + sale.id);
lastSaleId = updated.id;
lastSaleData = updated;
showTicket(updated);
} catch (e) {
alert('Error al cobrar nota: ' + e.message);
}
}
function updatePayRemissionMethod() {
const method = document.getElementById('payRemissionMethod').value;
const cashEl = document.getElementById('payRemissionCash');
const refEl = document.getElementById('payRemissionRef');
const mixedEl = document.getElementById('payRemissionMixed');
if (cashEl) cashEl.style.display = method === 'efectivo' ? '' : 'none';
if (refEl) refEl.style.display = (method === 'transferencia' || method === 'tarjeta') ? '' : 'none';
if (mixedEl) mixedEl.style.display = method === 'mixto' ? '' : 'none';
}
// ─── Quotation ───────────────────────
async function saveQuotation() {
if (cart.length === 0) { showToast('Carrito vacio'); return; }
@@ -1284,8 +1474,9 @@ const POS = (() => {
hour: '2-digit', minute: '2-digit'
});
const customerName = currentCustomer ? currentCustomer.name : 'Publico General';
const customerRfc = currentCustomer && currentCustomer.rfc ? currentCustomer.rfc : '';
const isRemission = sale.status === 'pending_payment';
const customerName = sale.customer_name || (currentCustomer ? currentCustomer.name : 'Publico General');
const customerRfc = sale.customer_rfc || (currentCustomer && currentCustomer.rfc ? currentCustomer.rfc : '');
let itemsHtml = '';
(sale.items || []).forEach(item => {
@@ -1333,13 +1524,14 @@ const POS = (() => {
</div>
<hr class="divider-double">
<div class="folio-line">
<span>VENTA: V-${sale.id}</span>
<span>${isRemission ? 'NOTA DE REMISION' : 'VENTA'}: ${isRemission ? 'NR' : 'V'}-${sale.id}</span>
<span>${dateStr}</span>
</div>
<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;">
<span>Cliente: ${customerName}</span>
${customerRfc ? `<span>RFC: ${customerRfc}</span>` : ''}
</div>
${isRemission && sale.courier_name ? `<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;"><span>Repartidor: ${sale.courier_name}</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>
@@ -1363,6 +1555,12 @@ const POS = (() => {
${showPayment ? `
<hr class="divider">
<div class="payment-section">
${isRemission ? `
<div class="ticket-row" style="font-weight: bold; color: #b91c1c;">
<span>Estado:</span><span>PENDIENTE DE PAGO</span>
</div>
<div style="font-size: 9px; text-align: center; margin-top: 4px;">Presente esta nota en caja para pagar</div>
` : `
<div class="ticket-row">
<span>Forma de pago:</span><span>${sale.payment_method || paymentMethod}</span>
</div>
@@ -1373,11 +1571,13 @@ const POS = (() => {
<div class="ticket-row" style="font-weight: bold;">
<span>Cambio:</span><span>${fmt(sale.change_given || 0)}</span>
</div>` : ''}
`}
</div>` : ''}
<hr class="divider">
<div class="footer-section">
<div class="thanks">${thanksMsg}</div>
${footerMsg ? `<div>${footerMsg}</div>` : ''}
<div class="thanks">${isRemission ? 'Gracias por su preferencia' : thanksMsg}</div>
${footerMsg && !isRemission ? `<div>${footerMsg}</div>` : ''}
${isRemission ? '<div style="font-size: 9px;">Conserve esta nota para el pago</div>' : ''}
</div>
`;
@@ -1387,7 +1587,7 @@ const POS = (() => {
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';
if (modalHeader) modalHeader.textContent = isRemission ? 'Nota de Remision' : 'Ticket de Venta';
document.getElementById('ticketModal').classList.add('open');
}
@@ -1592,7 +1792,9 @@ const POS = (() => {
showNewCustomerModal, closeNewCustomerModal, saveNewCustomer,
checkout, confirmPayment, closePaymentModal,
selectPaymentMethod, updateChange, updateMixedTotal,
creditSale, saveQuotation, createLayaway,
createRemissionNote, openPayRemissionModal, closePayRemissionModal,
searchRemissionToPay, confirmPayRemission, updatePayRemissionMethod,
saveQuotation, createLayaway,
createServiceOrder, closeServiceOrderModal, confirmServiceOrder, showServiceOrderTicket,
showLastSale, openDrawer,
showTicket, closeTicketModal, printTicket,

View File

@@ -54,7 +54,20 @@ const Reports = (() => {
}
// Track which tabs have been loaded
var loaded = { ventas: false, inventario: false, clientes: false, financieros: false, historico: false };
var loaded = { ventas: false, inventario: false, clientes: false, financieros: false, historico: false, cortes: false };
function currentUser() {
return (typeof window.POS_USER !== 'undefined') ? window.POS_USER : {};
}
function hasPerm(p) {
var u = currentUser();
return (u.role === 'owner' || u.role === 'admin') || (u.permissions || []).indexOf(p) !== -1;
}
function isLimitedUser() {
var u = currentUser();
var r = (u.role || '').toLowerCase();
return (r === 'cashier' || r === 'counter') && !hasPerm('pos.view');
}
// -------------------------------------------------------------------------
// Theme switcher
@@ -86,6 +99,7 @@ const Reports = (() => {
else if (id === 'clientes') loadClientes();
else if (id === 'financieros') loadFinancieros();
else if (id === 'historico') loadHistorico();
else if (id === 'cortes') loadCortes();
}
}
window.switchTab = switchTab;
@@ -737,6 +751,153 @@ const Reports = (() => {
}
}
// =========================================================================
// TAB 6: CORTES DE CAJA
// =========================================================================
async function loadCortes() {
loaded.cortes = true;
var dateFrom = document.getElementById('cortes-date-from').value;
var dateTo = document.getElementById('cortes-date-to').value;
var employeeId = document.getElementById('cortes-employee').value;
// Cashiers/counters without pos.view can only see their own cuts
var u = currentUser();
if (isLimitedUser()) {
employeeId = u.employeeId || '';
var empSel = document.getElementById('cortes-employee');
if (empSel) empSel.value = employeeId;
}
var kpiEl = document.getElementById('cortes-kpis');
var detalleEl = document.getElementById('cortes-detalle');
var ventasDetalleEl = document.getElementById('corte-ventas-detalle');
kpiEl.innerHTML = spinner();
detalleEl.innerHTML = spinner();
if (ventasDetalleEl) ventasDetalleEl.style.display = 'none';
var params = new URLSearchParams();
if (dateFrom) params.set('date_from', dateFrom);
if (dateTo) params.set('date_to', dateTo);
if (employeeId) params.set('employee_id', employeeId);
params.set('per_page', '200');
try {
var data = await apiFetch('/pos/api/register/history?' + params.toString());
var regs = data.data || [];
var totalEsperado = 0;
var totalCierre = 0;
var totalDiferencia = 0;
regs.forEach(function(r) {
totalEsperado += r.expected_amount || 0;
totalCierre += r.closing_amount || 0;
totalDiferencia += r.difference || 0;
});
kpiEl.innerHTML =
kpiCard('Cortes', fmtInt(regs.length), 'en el periodo') +
kpiCard('Ventas esperadas', '$' + fmt(totalEsperado), 'total acumulado') +
kpiCard('Cierre real', '$' + fmt(totalCierre), 'efectivo contado') +
kpiCard('Diferencia', '$' + fmt(totalDiferencia), totalDiferencia >= 0 ? 'sobrante' : 'faltante');
var cHtml = '<div class="table-card__header"><span class="table-card__title">Cortes de Caja</span>' +
'<span class="pill pill--muted">' + (data.pagination ? data.pagination.total : regs.length) + ' registros</span></div>';
cHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
'<th>Caja</th><th>Empleado</th><th>Apertura</th><th>Cierre</th>' +
'<th class="align-right">Monto Apertura</th><th class="align-right">Esperado</th>' +
'<th class="align-right">Cierre Real</th><th class="align-right">Diferencia</th>' +
'<th>Acciones</th>' +
'</tr></thead><tbody>';
regs.forEach(function(r) {
var diffColor = r.difference < 0 ? 'color:var(--color-error)' :
r.difference > 0 ? 'color:var(--color-warning)' : 'color:var(--color-success)';
cHtml += '<tr data-register-id="' + r.id + '" style="cursor:pointer" onclick="Reports.showCorteVentas(' + r.id + ')">' +
'<td class="td-mono">#' + r.register_number + '</td>' +
'<td class="td-strong">' + (r.employee_name || '--') + '</td>' +
'<td style="color:var(--color-text-muted)">' + fmtDateTime(r.opened_at) + '</td>' +
'<td style="color:var(--color-text-muted)">' + fmtDateTime(r.closed_at) + '</td>' +
'<td class="align-right td-mono">$' + fmt(r.opening_amount) + '</td>' +
'<td class="align-right td-mono">$' + fmt(r.expected_amount) + '</td>' +
'<td class="align-right td-mono">$' + fmt(r.closing_amount) + '</td>' +
'<td class="align-right td-mono" style="' + diffColor + '">$' + fmt(r.difference) + '</td>' +
'<td><button class="btn btn-sm btn-ghost" onclick="event.stopPropagation(); Reports.showCorteVentas(' + r.id + ')">Ver ventas</button></td></tr>';
});
cHtml += '</tbody></table></div>';
detalleEl.innerHTML = regs.length ? cHtml : emptyMsg('No hay cortes de caja en el periodo seleccionado');
} catch (err) {
kpiEl.innerHTML = errorMsg('Error cargando cortes de caja: ' + err.message);
detalleEl.innerHTML = '';
}
}
// -------------------------------------------------------------------------
// Detail: sales for a selected cash cut
// -------------------------------------------------------------------------
async function showCorteVentas(registerId) {
var panel = document.getElementById('corte-ventas-detalle');
if (!panel) return;
panel.style.display = 'block';
panel.innerHTML = spinner();
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
var methodLabels = {
'cash': 'Efectivo', 'card': 'Tarjeta', 'transfer': 'Transferencia',
'credit': 'Crédito', 'mixed': 'Mixto', 'efectivo': 'Efectivo',
'tarjeta': 'Tarjeta', 'transferencia': 'Transferencia'
};
try {
var data = await apiFetch('/pos/api/register/' + registerId + '/sales');
var sales = data.sales || [];
var summary = data.summary || {};
var total = summary.total || 0;
var count = summary.count || 0;
var byMethod = summary.by_method || {};
var hHtml = '<div class="table-card__header"><span class="table-card__title">Ventas del corte #' + registerId + '</span>' +
'<span class="pill pill--muted">' + count + ' ventas · $' + fmt(total) + '</span></div>';
// Summary by payment method
var methods = Object.entries(byMethod).sort(function(a, b) { return b[1] - a[1]; });
if (methods.length) {
hHtml += '<div style="display:flex;gap:var(--space-3);flex-wrap:wrap;padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--color-border);">';
methods.forEach(function(m) {
var label = methodLabels[m[0]] || m[0];
hHtml += '<div class="pill pill--muted">' + label + ': <strong>$' + fmt(m[1]) + '</strong></div>';
});
hHtml += '</div>';
}
hHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
'<th># Venta</th><th>Fecha</th><th>Cliente</th><th>Método</th>' +
'<th class="align-right">Subtotal</th><th class="align-right">Desc.</th>' +
'<th class="align-right">Total</th><th>Estado</th>' +
'</tr></thead><tbody>';
sales.forEach(function(s) {
var statusPill = s.status === 'completed' ? 'pill--success' :
s.status === 'cancelled' ? 'pill--error' : 'pill--warning';
var statusLabel = s.status === 'completed' ? 'Completada' :
s.status === 'cancelled' ? 'Cancelada' : s.status;
var method = methodLabels[s.payment_method] || s.payment_method || '--';
hHtml += '<tr><td class="td-mono">' + s.id + '</td>' +
'<td>' + fmtDateTime(s.created_at) + '</td>' +
'<td>' + (s.customer_name || 'Mostrador') + '</td>' +
'<td>' + method + '</td>' +
'<td class="align-right td-mono">$' + fmt(s.subtotal) + '</td>' +
'<td class="align-right td-mono">$' + fmt(s.discount_total) + '</td>' +
'<td class="align-right td-mono-accent">$' + fmt(s.total) + '</td>' +
'<td><span class="pill ' + statusPill + '">' + statusLabel + '</span></td></tr>';
});
hHtml += '</tbody></table></div>';
panel.innerHTML = sales.length ? hHtml : emptyMsg('No hay ventas registradas en este corte');
} catch (err) {
panel.innerHTML = errorMsg('Error cargando ventas del corte: ' + err.message);
}
}
window.showCorteVentas = showCorteVentas;
// -------------------------------------------------------------------------
// Init
// -------------------------------------------------------------------------
@@ -753,6 +914,9 @@ const Reports = (() => {
updateClock();
setInterval(updateClock, 1000);
var u = currentUser();
var limited = isLimitedUser();
// Set default date range: first day of current month to today
var now = new Date();
var firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
@@ -761,6 +925,49 @@ const Reports = (() => {
if (fromEl) fromEl.value = firstDay.toISOString().substring(0, 10);
if (toEl) toEl.value = now.toISOString().substring(0, 10);
// Set default date range for cortes de caja
var cortesFrom = document.getElementById('cortes-date-from');
var cortesTo = document.getElementById('cortes-date-to');
if (cortesFrom) cortesFrom.value = firstDay.toISOString().substring(0, 10);
if (cortesTo) cortesTo.value = now.toISOString().substring(0, 10);
// Populate cajero filter for cortes de caja (admins/owners only see it)
var empSel = document.getElementById('cortes-employee');
if (empSel && !limited) {
apiFetch('/pos/api/config/employees?per_page=200').then(function(data) {
(data.data || []).forEach(function(e) {
var opt = document.createElement('option');
opt.value = e.id;
opt.textContent = e.name;
empSel.appendChild(opt);
});
}).catch(function() {});
} else if (empSel && limited) {
empSel.value = u.employeeId || '';
}
// For cashiers/counters without pos.view, limit the page to "Mis cortes"
if (limited) {
['ventas', 'inventario', 'clientes', 'financieros', 'historico'].forEach(function(id) {
var btn = document.querySelector('.tab-btn[onclick="switchTab(\'' + id + '\', this)"]');
if (btn) btn.style.display = 'none';
});
var empFilter = document.getElementById('cortes-employee-filter');
if (empFilter) empFilter.style.display = 'none';
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('is-active'); });
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('is-active'); });
var cortesPanel = document.getElementById('panel-cortes');
var cortesBtn = document.querySelector('.tab-btn[onclick="switchTab(\'cortes\', this)"]');
if (cortesPanel) cortesPanel.classList.add('is-active');
if (cortesBtn) {
cortesBtn.classList.add('is-active');
var svg = '<svg viewBox="0 0 15 15" fill="none" stroke="currentColor" stroke-width="1.4"><rect x="1" y="3" width="13" height="10" rx="1"/><path d="M4 7h7M4 10h5"/><circle cx="11" cy="10" r="1.5" fill="currentColor"/></svg>';
cortesBtn.innerHTML = svg + '<span>Mis cortes de caja</span>';
}
loadCortes();
}
// Populate financial period selectors
var monthSel = document.getElementById('fin-month');
var yearSel = document.getElementById('fin-year');
@@ -784,15 +991,18 @@ const Reports = (() => {
}
}
// Load the default active tab (ventas)
loadVentas();
// Load the default active tab (ventas) only for privileged users
if (!limited) {
loadVentas();
}
}
document.addEventListener('DOMContentLoaded', init);
return {
init, setTheme, switchTab,
loadVentas, loadInventario, loadClientes, loadFinancieros, loadHistorico, fmt
loadVentas, loadInventario, loadClientes, loadFinancieros, loadHistorico, loadCortes,
showCorteVentas, fmt
};
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {

View File

@@ -41,10 +41,32 @@ window.renderSidebar = function(modulesOverride) {
function itemAllowed(id) {
if (role === 'owner' || role === 'admin') return true;
if (role === 'workshop' || role === 'mechanic') {
return id === 'workshop';
// Taller siempre visible; el resto depende de los permisos asignados.
var allowed = ['workshop'];
var permMap = {
'customers.view': 'customers',
'inventory.view': 'inventory',
'catalog.view': 'catalog',
'pos.sell': 'pos',
'pos.view': 'pos',
'pos.remission': 'remission_notes',
'invoicing.view': 'invoicing',
'quotations.view': 'quotations',
'accounting.view': 'accounting',
'reports.view': 'reports'
};
for (var p in permMap) {
if (perms.indexOf(p) !== -1 && allowed.indexOf(permMap[p]) === -1) {
allowed.push(permMap[p]);
}
}
return allowed.indexOf(id) !== -1;
}
if (role === 'counter') {
return ['pos','catalog','inventory','customers','workshop','remission_notes','reports'].indexOf(id) !== -1;
}
if (role === 'cashier') {
return ['dashboard','pos','catalog','inventory','customers'].indexOf(id) !== -1;
return ['pos','catalog','inventory','customers','workshop','remission_notes','invoicing','reports'].indexOf(id) !== -1;
}
return true;
}
@@ -58,6 +80,7 @@ window.renderSidebar = function(modulesOverride) {
].filter(Boolean).filter(function(i){ return itemAllowed(i.id); })},
{ label: _t('nav_management'), items: [
{ 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: 'remission_notes', name: _t('remission_notes'), href: '/pos/remission-notes', 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: '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,
@@ -65,7 +88,6 @@ window.renderSidebar = function(modulesOverride) {
{ 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: [
@@ -132,14 +154,14 @@ window.renderSidebar = function(modulesOverride) {
+ '</div>';
// Replace existing sidebar
var existing = document.querySelector('aside.sidebar, .sidebar, #sidebar');
var existing = document.querySelector('.pos-sidebar, aside.sidebar, .sidebar, #sidebar');
if (existing) {
existing.className = 'pos-sidebar';
existing.className = 'pos-sidebar sidebar';
existing.innerHTML = sidebarHtml;
existing.removeAttribute('style');
} else {
var el = document.createElement('aside');
el.className = 'pos-sidebar';
el.className = 'pos-sidebar sidebar';
el.innerHTML = sidebarHtml;
document.body.insertBefore(el, document.body.firstChild);
}

View File

@@ -30,47 +30,99 @@ var Workshop = (function() {
var user = window.POS_USER || {};
var role = (user.role || '').toLowerCase();
var hidePrices = role === 'workshop' || role === 'mechanic';
var isRestricted = role === 'workshop' || role === 'mechanic';
var isMechanic = role === 'mechanic';
var hidePrices = isRestricted;
var perms = user.permissions || [];
var canEdit = role === 'owner' || role === 'admin' || perms.indexOf('workshop.edit') !== -1;
// Owner/admin/counter/cashier can create/edit/delete service orders.
// Mechanics have limited access: view allowed statuses, change status, edit diagnosis/repair notes.
var canEdit = role === 'owner' || role === 'admin' || role === 'counter' || role === 'cashier';
var canCreate = canEdit;
var canDelete = role === 'owner' || role === 'admin';
var canSell = role === 'owner' || role === 'admin' || perms.indexOf('pos.sell') !== -1;
var canSell = role === 'owner' || role === 'admin' || role === 'counter' || role === 'cashier' || perms.indexOf('pos.sell') !== -1;
var canChangeStatus = canEdit || isMechanic;
var COLUMNS = [
{key: 'received', label: 'Recibido'},
{key: 'diagnosis', label: 'Diagnóstico'},
{key: 'waiting_parts', label: 'Espera refacciones'},
{key: 'repair', label: 'En reparación'},
{key: 'ready', label: 'Listo'},
{key: 'delivered', label: 'Entregado'},
{key: 'por_revisar', label: 'Por revisar'},
{key: 'en_revision', label: 'En revisión'},
{key: 'revisada', label: 'Revisada'},
{key: 'cotizada', label: 'Cotizada'},
{key: 'por_autorizar', label: 'Por autorizar'},
{key: 'autorizada', label: 'Autorizada'},
{key: 'autorizacion_parcial', label: 'Autorización parcial'},
{key: 'en_reparacion', label: 'En reparación'},
{key: 'reparada', label: 'Reparada'},
{key: 'por_entregar', label: 'Por entregar'},
{key: 'entregado', label: 'Entregado'},
{key: 'por_enviar', label: 'Por enviar'},
{key: 'enviado', label: 'Enviado'},
{key: 'por_facturar', label: 'Por facturar'},
{key: 'facturada', label: 'Facturada'},
{key: 'por_recolectar', label: 'Por recolectar'},
];
var STATUS_LABELS = {
received: 'Recibido',
diagnosis: 'Diagnóstico',
waiting_parts: 'Espera refacciones',
repair: 'En reparación',
ready: 'Listo',
delivered: 'Entregado',
cancelled: 'Cancelado'
por_revisar: 'Por revisar',
en_revision: 'En revisión',
revisada: 'Revisada',
cotizada: 'Cotizada',
por_autorizar: 'Por autorizar',
autorizada: 'Autorizada',
autorizacion_parcial: 'Autorización parcial',
en_reparacion: 'En reparación',
reparada: 'Reparada',
por_entregar: 'Por entregar',
entregado: 'Entregado',
por_enviar: 'Por enviar',
enviado: 'Enviado',
por_facturar: 'Por facturar',
facturada: 'Facturada',
por_recolectar: 'Por recolectar',
cancelada: 'Cancelada'
};
var VALID_NEXT = {
received: ['diagnosis', 'cancelled'],
diagnosis: ['waiting_parts', 'repair', 'cancelled'],
waiting_parts: ['repair', 'cancelled'],
repair: ['ready', 'cancelled'],
ready: ['delivered', 'cancelled'],
delivered: [],
cancelled: []
por_revisar: ['en_revision', 'cancelada'],
en_revision: ['revisada', 'por_revisar', 'cancelada'],
revisada: ['cotizada', 'en_revision', 'cancelada'],
cotizada: ['por_autorizar', 'revisada', 'cancelada'],
por_autorizar: ['autorizada', 'autorizacion_parcial', 'cotizada', 'cancelada'],
autorizada: ['en_reparacion', 'por_autorizar', 'cancelada'],
autorizacion_parcial: ['en_reparacion', 'por_autorizar', 'cancelada'],
en_reparacion: ['reparada', 'por_autorizar', 'cancelada'],
reparada: ['por_entregar', 'en_reparacion', 'cancelada'],
por_entregar: ['entregado', 'por_enviar', 'reparada', 'cancelada'],
por_enviar: ['enviado', 'por_entregar', 'cancelada'],
enviado: ['entregado', 'por_enviar', 'cancelada'],
por_recolectar: ['por_revisar', 'cancelada'],
por_facturar: ['facturada', 'cancelada'],
entregado: ['por_facturar'],
facturada: [],
cancelada: []
};
var DELIVERY_LABELS = {
pickup: 'Pasa cliente',
pickup: 'Mostrador',
delivery: 'Envío a domicilio',
courier: 'Motociclista'
};
var ITEM_STATUS_LABELS = {
por_revisar: 'Por revisar',
revisando: 'Revisando',
revisado: 'Revisado',
cotizado: 'Cotizado',
por_autorizar: 'Por autorizar',
autorizado: 'Autorizado',
en_reparacion: 'En reparación',
reparado: 'Reparado',
por_entregar: 'Por entregar',
entregado: 'Entregado',
por_enviar: 'Por enviar',
enviado: 'Enviado',
cancelado: 'Cancelado'
};
function headers() {
return {
'Content-Type': 'application/json',
@@ -88,6 +140,16 @@ var Workshop = (function() {
return '$' + parseFloat(n).toLocaleString('es-MX', {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
// Price based on customer tier: base price (price_1) with tier discount.
// Tier 2 = Taller (5% off), Tier 3 = Mayoreo (10% off), Tier 1 = base.
function priceForTier(basePrice, tier) {
var p = parseFloat(basePrice) || 0;
var t = parseInt(tier, 10) || 1;
if (t === 2) return Math.round(p * 0.95 * 100) / 100;
if (t === 3) return Math.round(p * 0.90 * 100) / 100;
return p;
}
function fmtDate(d) {
if (!d) return '—';
var dt = new Date(d);
@@ -126,6 +188,15 @@ var Workshop = (function() {
if (savedView) currentView = savedView;
bindFilters();
bindDeliverySelect();
if (!canCreate) {
var btnNewOrder = document.getElementById('btnNewOrder');
if (btnNewOrder) btnNewOrder.style.display = 'none';
}
if (isRestricted) {
document.querySelectorAll('.restricted-hide').forEach(function(el) { el.style.display = 'none'; });
var searchInput = document.getElementById('filterSearch');
if (searchInput) searchInput.placeholder = 'Buscar orden';
}
if (hidePrices) {
var btnCatalog = document.getElementById('btnCatalog');
if (btnCatalog) btnCatalog.style.display = 'none';
@@ -151,11 +222,19 @@ var Workshop = (function() {
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';
});
if (sel) {
sel.addEventListener('change', function() {
var cf = document.getElementById('courierField');
if (cf) cf.style.display = (sel.value === 'delivery' || sel.value === 'courier') ? 'block' : 'none';
});
}
var eoSel = document.getElementById('eoDelivery');
if (eoSel) {
eoSel.addEventListener('change', function() {
var cf = document.getElementById('eoCourierField');
if (cf) cf.style.display = (eoSel.value === 'delivery' || eoSel.value === 'courier') ? 'block' : 'none';
});
}
}
function debounce(fn, ms) {
@@ -172,9 +251,9 @@ var Workshop = (function() {
fetch(API + '/kanban/summary', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
document.getElementById('statReceived').textContent = fmt(d.received || 0);
document.getElementById('statRepair').textContent = fmt((d.repair || 0) + (d.diagnosis || 0) + (d.waiting_parts || 0) + (d.quality_check || 0));
document.getElementById('statReady').textContent = fmt(d.ready || 0);
document.getElementById('statReceived').textContent = fmt(d.por_revisar || 0);
document.getElementById('statRepair').textContent = fmt((d.en_reparacion || 0) + (d.en_revision || 0) + (d.revisada || 0) + (d.cotizada || 0) + (d.por_autorizar || 0));
document.getElementById('statReady').textContent = fmt(d.por_entregar || 0);
document.getElementById('statOverdue').textContent = fmt(d.overdue || 0);
})
.catch(function() {});
@@ -243,20 +322,33 @@ var Workshop = (function() {
function renderList() {
var body = document.getElementById('listBody');
var fullCols = 8;
var restrictedCols = 4;
var cols = isRestricted ? restrictedCols : fullCols;
if (!orders.length) {
body.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:var(--space-4);">No se encontraron órdenes</td></tr>';
body.innerHTML = '<tr><td colspan="' + cols + '" 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 || ''));
var statusCell = '<td><span class="badge badge--' + esc(o.status) + '">' + esc(STATUS_LABELS[o.status] || o.status) + '</span></td>';
var actionCell = '<td><button class="btn btn--sm btn--secondary" onclick="Workshop.openDetail(' + o.id + ')">Ver</button></td>';
var base = '<td>' + esc(o.branch_name || '—') + '</td>' +
'<td><strong>' + esc(o.order_number) + '</strong></td>' +
statusCell +
actionCell;
if (isRestricted) {
return '<tr>' + base + '</tr>';
}
var vehicle = esc(o.vehicle_description || (o.vehicle_plate ? (o.vehicle_plate + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || '')).trim() : '—'));
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>' + esc(o.workshop_name || '—') + '</td>' +
'<td>' + vehicle + '</td>' +
'<td><span class="badge badge--' + esc(o.status) + '">' + esc(STATUS_LABELS[o.status] || o.status) + '</span></td>' +
statusCell +
'<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>' +
actionCell +
'</tr>';
}).join('');
if (hidePrices) {
@@ -287,7 +379,8 @@ var Workshop = (function() {
function renderKanban() {
var board = document.getElementById('kanbanBoard');
board.innerHTML = '';
COLUMNS.forEach(function(col) {
var hiddenCols = isMechanic ? ['cotizada','por_autorizar','autorizada','autorizacion_parcial','por_facturar','facturada'] : [];
COLUMNS.filter(function(col) { return hiddenCols.indexOf(col.key) === -1; }).forEach(function(col) {
var colOrders = orders.filter(function(o) { return o.status === col.key; });
var colEl = document.createElement('div');
colEl.className = 'kanban-column';
@@ -322,13 +415,22 @@ var Workshop = (function() {
card.className = 'kanban-card';
card.onclick = function() { openDetail(o.id); };
var priceHtml = hidePrices ? '' : '<span>' + fmtMoney(o.estimated_cost || o.total) + '</span>';
if (isRestricted) {
card.innerHTML =
'<div class="kanban-card__header">' +
' <span class="kanban-card__id">' + esc(o.order_number) + '</span>' +
' <span class="kanban-card__priority badge badge--' + esc(o.status) + '">' + esc(STATUS_LABELS[o.status] || o.status) + '</span>' +
'</div>' +
'<div class="kanban-card__branch">' + esc(o.branch_name || '—') + '</div>';
return card;
}
card.innerHTML =
'<div class="kanban-card__header">' +
' <span class="kanban-card__id">' + esc(o.order_number) + '</span>' +
' <span class="kanban-card__priority badge badge--' + esc(o.priority) + '">' + esc(priorityLabel(o.priority)) + '</span>' +
' <span class="kanban-card__priority badge badge--' + esc(o.status) + '">' + esc(STATUS_LABELS[o.status] || o.status) + '</span>' +
'</div>' +
'<div class="kanban-card__customer">' + esc(o.customer_name || 'Cliente general') + '</div>' +
'<div class="kanban-card__vehicle">' + esc(o.vehicle_plate || 'Sin vehículo') + '</div>' +
'<div class="kanban-card__vehicle">' + esc(o.vehicle_description || o.vehicle_plate || 'Sin vehículo') + '</div>' +
'<div class="kanban-card__meta">' +
' <span class="kanban-card__mechanic">🔧 ' + esc(o.employee_name || 'Sin asignar') + '</span>' +
priceHtml +
@@ -355,47 +457,56 @@ var Workshop = (function() {
var activeTab = document.querySelector('.so-tabs__btn.is-active');
var selectedTab = activeTab ? activeTab.dataset.tab : 'service';
var mechanicAssignHtml = isMechanic ?
' <div class="so-detail-header__row"><span class="so-detail__label">Mecánico asignado:</span> ' +
'<input id="mechanicNameInput" class="form-input" style="width:auto;min-width:180px;" value="' + esc(o.mechanic_name || '') + '" placeholder="Nombre del mecánico" />' +
' <button class="btn btn--sm btn--secondary" onclick="Workshop.saveMechanicName()">Guardar</button></div>' :
' <div class="so-detail-header__row"><span class="so-detail__label">Mecánico asignado:</span> ' + esc(o.mechanic_name || o.employee_name || 'Sin asignar') + '</div>';
var html =
'<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>' +
mechanicAssignHtml +
'</div>' +
(isRestricted ? '' :
'<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">Taller</span><span class="so-detail__value">' + esc(o.workshop_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">Vehículo</span><span class="so-detail__value">' + esc(o.vehicle_description || (o.vehicle_plate ? (o.vehicle_plate + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || '')).trim() : '—')) + '</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">Factura</span><span class="so-detail__value">' + (o.requires_invoice ? 'Sí requiere' : 'No requiere') + '</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>') +
(isRestricted ? '' :
'<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>') +
'<div class="so-tab-panel" id="tab-service" ' + (selectedTab === 'service' ? '' : 'style="display:none;"') + '>' +
'<div class="so-tab-panel" id="tab-service" ' + (selectedTab === 'service' || isRestricted ? '' : 'style="display:none;"') + '>' +
' <div class="so-detail__section">' +
' <h3>Notas</h3>' +
' <div class="so-notes-grid">' +
' <label class="form-label">Recepción</label>' +
' <textarea id="noteReception" class="form-input" rows="2" ' + (canEdit ? '' : 'readonly') + '>' + esc(o.reception_notes || '') + '</textarea>' +
' <label class="form-label">Diagnóstico</label>' +
' <textarea id="noteDiagnosis" class="form-input" rows="2" ' + (canEdit ? '' : 'readonly') + '>' + esc(o.diagnosis_notes || '') + '</textarea>' +
' <textarea id="noteDiagnosis" class="form-input" rows="2" ' + (canEdit || isMechanic ? '' : 'readonly') + '>' + esc(o.diagnosis_notes || '') + '</textarea>' +
' <label class="form-label">Reparación</label>' +
' <textarea id="noteRepair" class="form-input" rows="2" ' + (canEdit ? '' : 'readonly') + '>' + esc(o.repair_notes || '') + '</textarea>' +
' <textarea id="noteRepair" class="form-input" rows="2" ' + (canEdit || isMechanic ? '' : 'readonly') + '>' + esc(o.repair_notes || '') + '</textarea>' +
' <label class="form-label">Entrega</label>' +
' <textarea id="noteDelivery" class="form-input" rows="2" ' + (canEdit ? '' : 'readonly') + '>' + esc(o.delivery_notes || '') + '</textarea>' +
' </div>' +
(canEdit ? ' <button class="btn btn--secondary" style="margin-top:var(--space-2);" onclick="Workshop.saveNotes()">Guardar notas</button>' : '') +
(canEdit || isMechanic ? ' <button class="btn btn--secondary" style="margin-top:var(--space-2);" onclick="Workshop.saveNotes()">Guardar notas</button>' : '') +
' </div>' +
' <div class="so-detail__section">' +
' <h3>Bitácora</h3>' +
@@ -403,16 +514,18 @@ var Workshop = (function() {
' </div>' +
'</div>' +
'<div class="so-tab-panel" id="tab-articles" ' + (selectedTab === 'articles' ? '' : 'style="display:none;"') + '>' +
(isRestricted ? '' : '<div class="so-tab-panel" id="tab-articles" ' + (selectedTab === 'articles' ? '' : 'style="display:none;"') + '>' +
renderArticles(o) +
'</div>';
'</div>');
document.getElementById('detailBody').innerHTML = html;
// Footer actions
var footer = document.getElementById('detailFooter');
var allowedNext = VALID_NEXT[o.status] || [];
var statusHtml = canEdit && allowedNext.length ?
var allowedNext = (VALID_NEXT[o.status] || []).filter(function(s) {
return !isMechanic || ['cotizada','por_autorizar','autorizada','autorizacion_parcial','por_facturar','facturada'].indexOf(s) === -1;
});
var statusHtml = canChangeStatus && allowedNext.length ?
'<div class="so-detail__actions" style="margin-right:auto;">' +
' <select class="form-input" id="statusSelect" style="width:auto;">' +
'<option value="' + esc(o.status) + '" selected>' + esc(STATUS_LABELS[o.status] || o.status) + '</option>' +
@@ -424,10 +537,11 @@ var Workshop = (function() {
'<button class="btn btn--ghost" onclick="Workshop.closeDetailModal()">Cerrar</button>' +
(canEdit ? '<button class="btn btn--secondary" onclick="Workshop.openEditOrderModal()">Editar orden</button>' : '') +
(canDelete ? '<button class="btn btn--danger" onclick="Workshop.deleteOrder()">Eliminar orden</button>' : '') +
'<button class="btn btn--secondary" onclick="Workshop.printOrder()">' +
(isRestricted ? '' : '<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>' : '') +
'Imprimir orden</button>') +
(canEdit && canSell && (o.status === 'por_entregar' || o.status === 'entregado') && !o.sale_id ? '<button class="btn btn--primary" onclick="Workshop.convertToSale()">Convertir a venta</button>' : '') +
(canEdit && canSell && !o.sale_id && o.status !== 'cancelled' ? '<button class="btn btn--secondary" onclick="Workshop.convertToRemission()">Generar nota de remisión</button>' : '') +
(o.sale_id ? '<a class="btn btn--secondary" href="/pos/invoicing?sale_id=' + o.sale_id + '">Ver venta #' + o.sale_id + '</a>' : '');
}
@@ -439,93 +553,105 @@ var Workshop = (function() {
function renderBitacora(history) {
if (!history.length) return '<p style="color:var(--color-text-muted);">Sin movimientos</p>';
var vehicle = currentOrder ? (currentOrder.vehicle_description || (currentOrder.vehicle_plate ? currentOrder.vehicle_plate + ' ' + (currentOrder.vehicle_make || '') + ' ' + (currentOrder.vehicle_model || '') : null)) : null;
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>' +
(isMechanic && vehicle ? '<td>' + esc(vehicle) + '</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>';
var vehicleTh = isMechanic && vehicle ? '<th>Vehículo</th>' : '';
return '<table class="data-table bitacora-table"><thead><tr><th>Estatus</th><th>Fecha</th><th>Usuario</th>' + vehicleTh + '<th>Observaciones</th></tr></thead><tbody>' + rows + '</tbody></table>';
}
function mechanicName(id) {
var e = employees.find(function(x) { return x.id === id; });
return e ? e.name : '—';
}
function ensureEmployees() {
if (employees && employees.length) return Promise.resolve(employees);
return fetch('/pos/api/config/employees?per_page=500', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
employees = (d.data || d.employees || []);
return employees;
})
.catch(function() { employees = []; return employees; });
}
function populateMechanicSelect(selectId, selectedId) {
var sel = document.getElementById(selectId);
if (!sel) return;
ensureEmployees().then(function() {
sel.innerHTML = '<option value="">— Sin asignar —</option>' +
employees
.filter(function(e) { return e.is_active && (e.role === 'mechanic' || e.role === 'workshop'); })
.map(function(e) {
return '<option value="' + e.id + '"' + (e.id === selectedId ? ' selected' : '') + '>' + esc(e.name) + '</option>';
}).join('');
});
}
function renderArticles(o) {
var colCount = (hidePrices ? 5 : 6) - (isRestricted ? 1 : 0);
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>';
var priceCells = hidePrices ? '' : '<td>' + fmtMoney(it.unit_price) + '</td>';
var mechanicCells = isRestricted ? '' : '<td>' + esc(mechanicName(it.mechanic_id)) + '</td>';
var actionCell = canEdit && it.status !== 'cancelado' ?
'<td><button class="btn btn--sm btn--secondary" onclick="event.stopPropagation();Workshop.editItemInline(' + it.id + ')">Editar</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>' +
mechanicCells +
'<td><span class="badge ' + statusBadgeClass(it.status) + '">' + esc(ITEM_STATUS_LABELS[it.status] || it.status) + '</span></td>' +
'<td>' + esc(it.observations || '') + '</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 partsHeader = '<tr><th>Concepto</th><th>Cant.</th>' + (hidePrices ? '' : '<th>Precio</th>') + (isRestricted ? '' : '<th>Mecánico</th>') + '<th>Estado</th><th>Observaciones</th><th></th></tr>';
var addParts = canEdit ?
'<div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);align-items:flex-start;flex-wrap:wrap;">' +
' <div style="position:relative;flex:1;min-width:200px;">' +
' <div style="position:relative;flex:1;min-width:160px;">' +
' <input class="form-input" id="newItemSearch" placeholder="Buscar refacción por nombre/número" autocomplete="off" oninput="Workshop.searchItemsForSO()" />' +
' <div id="itemSearchResults" style="display:none;position:absolute;z-index:10;top:100%;left:0;right:0;max-height:220px;overflow-y:auto;background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);box-shadow:0 4px 12px rgba(0,0,0,.15);"></div>' +
' </div>' +
' <input class="form-input" id="newItemQty" type="number" value="1" min="1" style="width:80px;" />' +
' <input class="form-input" id="newItemQty" type="number" value="1" min="1" style="width:70px;" />' +
(hidePrices ? '' : '<input class="form-input" id="newItemPrice" type="number" step="0.01" placeholder="Precio" style="width:90px;" />') +
' <select class="form-input" id="newItemMechanic" style="width:auto;"><option value="">— Mecánico —</option></select>' +
' <select class="form-input" id="newItemStatus" style="width:auto;">' +
Object.keys(ITEM_STATUS_LABELS).map(function(s) { return '<option value="' + s + '">' + esc(ITEM_STATUS_LABELS[s]) + '</option>'; }).join('') +
' </select>' +
' <input class="form-input" id="newItemObs" placeholder="Observaciones" style="min-width:140px;flex:1;" />' +
' <button class="btn btn--secondary" onclick="Workshop.addSelectedItem()">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>' +
' <h3>Artículos</h3>' +
' <table class="data-table"><thead>' + partsHeader + '</thead><tbody>' + (partsRows || '<tr><td colspan="' + colCount + '" style="text-align:center;">Sin artículos</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
// populate mechanic select 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 + (hidePrices ? '' : ' ($' + fmtMoney(c.suggested_hours * c.suggested_rate).replace('$', '') + ')');
sel.appendChild(opt);
ensureEmployees().then(function() {
var sel = document.getElementById('newItemMechanic');
if (!sel || sel.dataset.populated) return;
employees.forEach(function(e) {
if (!e.is_active) return;
var opt = document.createElement('option');
opt.value = e.id;
opt.textContent = e.name;
sel.appendChild(opt);
});
sel.dataset.populated = '1';
});
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;
if (!hidePrices) document.getElementById('laborRate').value = c.suggested_rate;
};
}, 0);
return html;
@@ -559,12 +685,9 @@ var Workshop = (function() {
async function populateEditOrderModal() {
var o = currentOrder;
selectedEditCustomer = o.customer_id ? {id: o.customer_id, name: o.customer_name || ''} : null;
selectedEditVehicle = o.vehicle_id ? {id: o.vehicle_id, label: (o.vehicle_plate || '') + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || '')} : null;
document.getElementById('eoCustomerSearch').value = selectedEditCustomer ? selectedEditCustomer.name : '';
document.getElementById('eoCustomerId').value = selectedEditCustomer ? selectedEditCustomer.id : '';
document.getElementById('eoVehicleSearch').value = selectedEditVehicle ? selectedEditVehicle.label.trim() : '';
document.getElementById('eoVehicleId').value = selectedEditVehicle ? selectedEditVehicle.id : '';
// Branches
var branchSel = document.getElementById('eoBranch');
@@ -572,25 +695,26 @@ var Workshop = (function() {
return '<option value="' + b.id + '"' + (b.id === o.branch_id ? ' selected' : '') + '>' + esc(b.name) + '</option>';
}).join('');
// Mechanics/employees
var mechSel = document.getElementById('eoMechanic');
mechSel.innerHTML = '<option value="">— Ninguno —</option>';
try {
var res = await fetch('/pos/api/config/employees', {headers: headers()});
var json = await res.json();
(json.data || []).forEach(function(e) {
if (!e.is_active) return;
mechSel.innerHTML += '<option value="' + e.id + '"' + (e.id === o.employee_id ? ' selected' : '') + '>' + esc(e.name) + '</option>';
});
} catch (e) {}
document.getElementById('eoWorkshopName').value = o.workshop_name || '';
document.getElementById('eoCustomerAddress').value = o.customer_address || '';
document.getElementById('eoCustomerPhone').value = o.customer_phone || '';
document.getElementById('eoVehicleDescription').value = o.vehicle_description || '';
// Delivery / courier
var deliverySel = document.getElementById('eoDelivery');
deliverySel.value = o.delivery_method || '';
var courierField = document.getElementById('eoCourierField');
var courierSel = document.getElementById('eoCourier');
courierSel.innerHTML = couriers.map(function(c) {
return '<option value="' + c.id + '"' + (c.id === o.courier_id ? ' selected' : '') + '>' + esc(c.name) + '</option>';
}).join('');
courierField.style.display = (o.delivery_method === 'delivery' || o.delivery_method === 'courier') ? 'block' : 'none';
document.getElementById('eoPriority').value = o.priority || 'normal';
document.getElementById('eoFuelLevel').value = o.fuel_level || '';
document.getElementById('eoMileageIn').value = o.mileage_in != null ? o.mileage_in : '';
document.getElementById('eoMileageOut').value = o.mileage_out != null ? o.mileage_out : '';
document.getElementById('eoEstimatedCompletion').value = toDatetimeLocal(o.estimated_completion);
document.getElementById('eoEstimatedCost').value = o.estimated_cost != null ? o.estimated_cost : '';
populateMechanicSelect('eoMechanic', o.employee_id || null);
document.getElementById('eoMechanicName').value = o.mechanic_name || '';
document.getElementById('eoNotes').value = o.reception_notes || '';
document.getElementById('eoRequiresInvoice').checked = !!o.requires_invoice;
}
function hideEditSearchResults(type) {
@@ -638,36 +762,9 @@ var Workshop = (function() {
}
function searchVehiclesForSO() {
var input = document.getElementById('eoVehicleSearch');
// Fleet module removed; vehicle is free-text in the taller flow.
var box = document.getElementById('eoVehicleResults');
var q = input.value.trim();
selectedEditVehicle = null;
document.getElementById('eoVehicleId').value = '';
if (!q || q.length < 2) {
box.style.display = 'none'; box.innerHTML = '';
return;
}
clearTimeout(vehicleSearchTimer);
vehicleSearchTimer = setTimeout(function() {
fetch('/pos/api/fleet/vehicles?q=' + encodeURIComponent(q) + '&per_page=20&active_only=true', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
var items = d.data || [];
if (!items.length) {
box.innerHTML = '<div style="padding:var(--space-2);color:var(--color-text-muted);">Sin resultados</div>';
box.style.display = 'block'; return;
}
box.innerHTML = items.map(function(v) {
var label = (v.plate || '') + ' · ' + (v.make || '') + ' ' + (v.model || '');
return '<div class="so-search-result" style="padding:var(--space-2);cursor:pointer;border-bottom:1px solid var(--color-border);" onclick="Workshop.selectEditVehicle(' + v.id + ', \'' + escJs(label) + '\')">' +
'<strong>' + esc(v.plate || '') + '</strong>' +
'<small>' + esc(v.make || '') + ' ' + esc(v.model || '') + ' · ' + esc(v.owner_name || '') + '</small>' +
'</div>';
}).join('');
box.style.display = 'block';
})
.catch(function() { box.style.display = 'none'; });
}, 250);
if (box) { box.style.display = 'none'; box.innerHTML = ''; }
}
function selectEditVehicle(id, label) {
@@ -680,19 +777,22 @@ var Workshop = (function() {
async function saveOrderChanges() {
if (!currentOrderId) return;
var customerId = selectedEditCustomer ? selectedEditCustomer.id : parseInt(document.getElementById('eoCustomerId').value, 10) || null;
var vehicleId = selectedEditVehicle ? selectedEditVehicle.id : parseInt(document.getElementById('eoVehicleId').value, 10) || null;
var delivery = document.getElementById('eoDelivery').value;
var mechanicVal = document.getElementById('eoMechanic').value;
var payload = {
customer_id: customerId,
vehicle_id: vehicleId,
branch_id: parseInt(document.getElementById('eoBranch').value, 10) || null,
employee_id: parseInt(document.getElementById('eoMechanic').value, 10) || null,
priority: document.getElementById('eoPriority').value,
fuel_level: document.getElementById('eoFuelLevel').value || null,
mileage_in: document.getElementById('eoMileageIn').value ? parseInt(document.getElementById('eoMileageIn').value, 10) : null,
mileage_out: document.getElementById('eoMileageOut').value ? parseInt(document.getElementById('eoMileageOut').value, 10) : null,
estimated_completion: document.getElementById('eoEstimatedCompletion').value || null,
workshop_name: document.getElementById('eoWorkshopName').value.trim() || null,
customer_address: document.getElementById('eoCustomerAddress').value.trim() || null,
customer_phone: document.getElementById('eoCustomerPhone').value.trim() || null,
vehicle_description: document.getElementById('eoVehicleDescription').value.trim() || null,
delivery_method: delivery || null,
courier_id: (delivery === 'delivery' || delivery === 'courier') ? (parseInt(document.getElementById('eoCourier').value, 10) || null) : null,
estimated_cost: document.getElementById('eoEstimatedCost').value ? parseFloat(document.getElementById('eoEstimatedCost').value) : null,
reception_notes: document.getElementById('eoNotes').value
employee_id: mechanicVal ? parseInt(mechanicVal, 10) : null,
mechanic_name: document.getElementById('eoMechanicName').value.trim() || null,
reception_notes: document.getElementById('eoNotes').value,
requires_invoice: document.getElementById('eoRequiresInvoice').checked
};
try {
await api('PUT', '/' + currentOrderId, payload);
@@ -832,8 +932,11 @@ var Workshop = (function() {
opt.textContent = label.trim();
sel.appendChild(opt);
sel.value = res.id;
document.getElementById('noWorkshopName').value = name;
document.getElementById('noCustomerPhone').value = payload.phone || '';
document.getElementById('noCustomerAddress').value = payload.address || '';
if (typeof customers !== 'undefined') {
customers.push({id: res.id, name: name, phone: payload.phone, rfc: payload.rfc});
customers.push({id: res.id, name: name, phone: payload.phone, rfc: payload.rfc, address: payload.address});
}
} else {
selectedEditCustomer = {id: res.id, name: name};
@@ -867,25 +970,46 @@ var Workshop = (function() {
function saveNotes() {
if (!currentOrderId) return;
var payload = {
reception_notes: document.getElementById('noteReception').value,
diagnosis_notes: document.getElementById('noteDiagnosis').value,
repair_notes: document.getElementById('noteRepair').value,
delivery_notes: document.getElementById('noteDelivery').value
};
var payload;
if (isMechanic) {
payload = {
diagnosis_notes: document.getElementById('noteDiagnosis').value,
repair_notes: document.getElementById('noteRepair').value
};
} else {
payload = {
reception_notes: document.getElementById('noteReception').value,
diagnosis_notes: document.getElementById('noteDiagnosis').value,
repair_notes: document.getElementById('noteRepair').value,
delivery_notes: document.getElementById('noteDelivery').value
};
}
api('PUT', '/' + currentOrderId, payload)
.then(function() {
alert('Notas guardadas');
if (currentOrder) {
currentOrder.reception_notes = payload.reception_notes;
currentOrder.diagnosis_notes = payload.diagnosis_notes;
currentOrder.repair_notes = payload.repair_notes;
currentOrder.delivery_notes = payload.delivery_notes;
if ('reception_notes' in payload) currentOrder.reception_notes = payload.reception_notes;
if ('diagnosis_notes' in payload) currentOrder.diagnosis_notes = payload.diagnosis_notes;
if ('repair_notes' in payload) currentOrder.repair_notes = payload.repair_notes;
if ('delivery_notes' in payload) currentOrder.delivery_notes = payload.delivery_notes;
}
})
.catch(function(e) { alert('Error: ' + e.message); });
}
function saveMechanicName() {
if (!currentOrderId) return;
var input = document.getElementById('mechanicNameInput');
if (!input) return;
var payload = { mechanic_name: input.value.trim() || null };
api('PUT', '/' + currentOrderId, payload)
.then(function() {
alert('Mecánico asignado guardado');
if (currentOrder) currentOrder.mechanic_name = payload.mechanic_name;
})
.catch(function(e) { alert('Error: ' + e.message); });
}
function reserveItem(itemId) {
api('POST', '/' + currentOrderId + '/items/' + itemId + '/reserve', {})
.then(function() {
@@ -923,10 +1047,12 @@ var Workshop = (function() {
box.style.display = 'block';
return;
}
var orderTier = currentOrder ? (currentOrder.customer_price_tier || 1) : 1;
box.innerHTML = items.map(function(it) {
return '<div class="so-search-result" data-id="' + it.id + '" data-name="' + esc(it.name) + '" data-part="' + esc(it.part_number || '') + '" data-price="' + (it.price_1 || 0) + '" data-cost="' + (it.cost || 0) + '" style="padding:var(--space-2);cursor:pointer;border-bottom:1px solid var(--color-border);" onclick="Workshop.selectInventoryItem(' + it.id + ')">' +
var price = priceForTier(it.price_1, orderTier);
return '<div class="so-search-result" data-id="' + it.id + '" data-name="' + esc(it.name) + '" data-part="' + esc(it.part_number || '') + '" data-price="' + price + '" data-cost="' + (it.cost || 0) + '" style="padding:var(--space-2);cursor:pointer;border-bottom:1px solid var(--color-border);" onclick="Workshop.selectInventoryItem(' + it.id + ')">' +
'<div><strong>' + esc(it.name) + '</strong></div>' +
'<small>' + esc(it.part_number || '') + ' · ' + esc(it.brand || '') + ' · Stock: ' + fmt(it.stock) + (hidePrices ? '' : ' · ' + fmtMoney(it.price_1)) + '</small>' +
'<small>' + esc(it.part_number || '') + ' · ' + esc(it.brand || '') + ' · Stock: ' + fmt(it.stock) + (hidePrices ? '' : ' · ' + fmtMoney(price)) + '</small>' +
'</div>';
}).join('');
box.style.display = 'block';
@@ -954,20 +1080,28 @@ var Workshop = (function() {
function addSelectedItem() {
if (!currentOrderId) return;
var qty = parseInt(document.getElementById('newItemQty').value, 10) || 1;
var qty = parseFloat(document.getElementById('newItemQty').value) || 1;
var status = document.getElementById('newItemStatus').value || 'por_revisar';
var mechanicId = parseInt(document.getElementById('newItemMechanic').value, 10) || null;
var observations = document.getElementById('newItemObs').value.trim();
if (selectedInventoryItem) {
var price = hidePrices ? selectedInventoryItem.unit_price : (parseFloat(document.getElementById('newItemPrice').value) || selectedInventoryItem.unit_price || 0);
api('POST', '/' + currentOrderId + '/items', {
inventory_id: selectedInventoryItem.id,
part_number: selectedInventoryItem.part_number,
name: selectedInventoryItem.name,
quantity: qty,
unit_cost: selectedInventoryItem.unit_cost,
unit_price: selectedInventoryItem.unit_price,
status: 'pending'
unit_price: price,
status: status,
mechanic_id: mechanicId,
observations: observations
}).then(function() {
selectedInventoryItem = null;
document.getElementById('newItemSearch').value = '';
document.getElementById('newItemQty').value = '1';
if (!hidePrices) document.getElementById('newItemPrice').value = '';
document.getElementById('newItemObs').value = '';
openDetail(currentOrderId);
}).catch(function(e) { alert('Error: ' + e.message); });
return;
@@ -975,19 +1109,36 @@ var Workshop = (function() {
// Fallback: manual placeholder (no inventory link)
var name = document.getElementById('newItemSearch').value.trim();
if (!name) return;
if (!confirm('No seleccionaste una refacción del inventario. ¿Agregar como concepto manual? No podrá reservarse.')) return;
var manualPrice = hidePrices ? 0 : (parseFloat(document.getElementById('newItemPrice').value) || 0);
api('POST', '/' + currentOrderId + '/items', {
name: name,
quantity: qty,
unit_price: 0,
status: 'pending'
unit_price: manualPrice,
status: status,
mechanic_id: mechanicId,
observations: observations
}).then(function() {
document.getElementById('newItemSearch').value = '';
document.getElementById('newItemQty').value = '1';
if (!hidePrices) document.getElementById('newItemPrice').value = '';
document.getElementById('newItemObs').value = '';
openDetail(currentOrderId);
}).catch(function(e) { alert('Error: ' + e.message); });
}
function editItemInline(itemId) {
if (!currentOrder || !currentOrder.items) return;
var it = currentOrder.items.find(function(x) { return x.id === itemId; });
if (!it) return;
var newStatus = prompt('Nuevo estado (' + Object.keys(ITEM_STATUS_LABELS).join(', ') + '):', it.status);
if (!newStatus || !ITEM_STATUS_LABELS[newStatus]) return;
var newObs = prompt('Observaciones:', it.observations || '');
var payload = {status: newStatus, observations: newObs != null ? newObs : it.observations};
api('PUT', '/items/' + itemId, payload)
.then(function() { openDetail(currentOrderId); })
.catch(function(e) { alert('Error: ' + e.message); });
}
function addLabor() {
var desc = document.getElementById('laborDesc').value.trim();
var hours = parseFloat(document.getElementById('laborHours').value) || 0;
@@ -1020,6 +1171,18 @@ var Workshop = (function() {
}).catch(function(e) { alert('Error: ' + e.message); });
}
function convertToRemission() {
if (!currentOrderId) return;
if (!confirm('¿Generar nota de remisión desde esta orden? Se reservarán las refacciones y quedará pendiente de cobro.')) return;
api('POST', '/' + currentOrderId + '/convert-to-remission', {})
.then(function(r) {
alert('Nota de remisión creada: #' + r.sale_id + ' Total: ' + fmtMoney(r.total));
closeDetailModal();
loadSummary();
loadOrders();
}).catch(function(e) { alert('Error: ' + e.message); });
}
function deleteOrder() {
if (!currentOrderId) return;
if (!confirm('¿Eliminar esta orden de servicio? Se ocultará del taller pero las reservas de inventario y la venta asociada (si existe) no se verán afectadas.')) return;
@@ -1058,10 +1221,22 @@ var Workshop = (function() {
// ─── New order ───
function openNewOrderModal() {
populateSelect('noBranch', branches, function(b) { return {value: b.id, text: b.name}; });
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}; });
populateMechanicSelect('noMechanic', null);
document.getElementById('noMechanicName').value = '';
document.getElementById('noEstimatedCost').value = '';
var noCustomer = document.getElementById('noCustomer');
noCustomer.onchange = function() {
var cid = parseInt(noCustomer.value, 10);
var c = customers.find(function(x) { return x.id === cid; });
if (c) {
document.getElementById('noWorkshopName').value = c.name || '';
document.getElementById('noCustomerPhone').value = c.phone || '';
document.getElementById('noCustomerAddress').value = c.address || '';
}
};
document.getElementById('newOrderModal').classList.add('is-open');
}
@@ -1074,19 +1249,27 @@ var Workshop = (function() {
function submitNewOrder() {
var customerId = document.getElementById('noCustomer').value;
var branchId = document.getElementById('noBranch').value;
if (!customerId) return alert('Selecciona un cliente');
if (!branchId) return alert('Selecciona una sucursal');
var delivery = document.getElementById('noDelivery').value;
var mechanicVal = document.getElementById('noMechanic').value;
var estimatedCostVal = document.getElementById('noEstimatedCost').value;
var payload = {
branch_id: parseInt(branchId, 10),
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,
workshop_name: document.getElementById('noWorkshopName').value.trim() || null,
customer_address: document.getElementById('noCustomerAddress').value.trim() || null,
customer_phone: document.getElementById('noCustomerPhone').value.trim() || null,
vehicle_description: document.getElementById('noVehicleDescription').value.trim() || null,
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
courier_id: (delivery === 'delivery' || delivery === 'courier') ? (parseInt(document.getElementById('noCourier').value, 10) || null) : null,
employee_id: mechanicVal ? parseInt(mechanicVal, 10) : null,
mechanic_name: document.getElementById('noMechanicName').value.trim() || null,
estimated_cost: estimatedCostVal ? parseFloat(estimatedCostVal) : null,
is_direct: document.getElementById('noDirect').checked,
requires_invoice: document.getElementById('noRequiresInvoice').checked
};
api('POST', '', payload).then(function() {
closeNewOrderModal();
@@ -1168,18 +1351,13 @@ var Workshop = (function() {
.then(function(r) { return r.json(); })
.then(function(d) { customers = (d.data || d.customers || []); })
.catch(function() {});
// Vehicles
fetch('/pos/api/fleet/vehicles?per_page=500', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) { vehicles = (d.data || []); })
.catch(function() { vehicles = []; });
// Employees
fetch('/pos/api/config/employees?per_page=500', {headers: headers()})
.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()})
fetch('/pos/api/logistics/couriers?per_page=500', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
couriers = (d.data || d.couriers || []);
@@ -1192,6 +1370,11 @@ var Workshop = (function() {
.then(function(d) {
branches = (d.data || []);
populateBranchFilter();
var noBranch = document.getElementById('noBranch');
var userBranch = (window.POS_USER || {}).branch_id;
if (noBranch && userBranch && branches.some(function(b) { return b.id == userBranch; })) {
noBranch.value = userBranch;
}
})
.catch(function() { branches = []; });
}
@@ -1235,11 +1418,14 @@ var Workshop = (function() {
changeStatus: changeStatus,
reserveItem: reserveItem,
saveNotes: saveNotes,
saveMechanicName: saveMechanicName,
searchItemsForSO: searchItemsForSO,
selectInventoryItem: selectInventoryItem,
addSelectedItem: addSelectedItem,
editItemInline: editItemInline,
addLabor: addLabor,
convertToSale: convertToSale,
convertToRemission: convertToRemission,
deleteOrder: deleteOrder,
printOrder: printOrder,
openNewOrderModal: openNewOrderModal,

View File

@@ -6,7 +6,7 @@
// The fetch handler normalizes static asset URLs (strips ?v= query strings)
// so templates can use cache-busting query params freely.
const VERSION = 33;
const VERSION = 40;
const CACHE_NAME = 'nexus-pos-v' + VERSION;
const APP_SHELL = [