fix(audit): corrige errores criticos y mayores, mejora UX/accesibilidad y optimiza rendimiento
- Arregla @require_auth, permisos, race conditions, locks de caja/stock - Elimina N+1 en layaway, flotilla, dashboard y global_invoice - Asegura folios atomicos para CFDI, ordenes de servicio y polizas - Protege client_secret de MercadoLibre en backend - Conecta botones/filtros de config, customers, accounting e invoicing - Mejora accesibilidad (labels/aria-label) y estados de carga/vacio - Limpia accounting.js obsoleto y consolida accounting.v9.js - Actualiza cache busting a v32 y Service Worker a v32 - Documenta todo en docs/AUDIT_Y_MEJORAS_2026-06-15.md Tests: 35 passed
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* workshop.js — Taller / Service Orders Kanban for Nexus POS
|
||||
* workshop.js — Taller / Service Orders for Nexus POS
|
||||
* Supports list + kanban views, filters, bitacora and role-based price hiding.
|
||||
*/
|
||||
var Workshop = (function() {
|
||||
'use strict';
|
||||
@@ -11,7 +12,20 @@ var Workshop = (function() {
|
||||
var customers = [];
|
||||
var vehicles = [];
|
||||
var employees = [];
|
||||
var couriers = [];
|
||||
var branches = [];
|
||||
var currentOrderId = null;
|
||||
var currentOrder = null;
|
||||
var currentView = 'list';
|
||||
var currentPage = 1;
|
||||
var perPage = 25;
|
||||
|
||||
var user = window.POS_USER || {};
|
||||
var role = (user.role || '').toLowerCase();
|
||||
var hidePrices = role === 'workshop';
|
||||
var perms = user.permissions || [];
|
||||
var canEdit = role === 'owner' || role === 'admin' || perms.indexOf('workshop.edit') !== -1;
|
||||
var canSell = role === 'owner' || role === 'admin' || perms.indexOf('pos.sell') !== -1;
|
||||
|
||||
var COLUMNS = [
|
||||
{key: 'received', label: 'Recibido'},
|
||||
@@ -23,6 +37,23 @@ var Workshop = (function() {
|
||||
{key: 'delivered', label: 'Entregado'},
|
||||
];
|
||||
|
||||
var STATUS_LABELS = {
|
||||
received: 'Recibido',
|
||||
diagnosis: 'Diagnóstico',
|
||||
waiting_parts: 'Espera refacciones',
|
||||
repair: 'En reparación',
|
||||
quality_check: 'Control calidad',
|
||||
ready: 'Listo',
|
||||
delivered: 'Entregado',
|
||||
cancelled: 'Cancelado'
|
||||
};
|
||||
|
||||
var DELIVERY_LABELS = {
|
||||
pickup: 'Pasa cliente',
|
||||
delivery: 'Envío a domicilio',
|
||||
courier: 'Motociclista'
|
||||
};
|
||||
|
||||
function headers() {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -43,11 +74,13 @@ var Workshop = (function() {
|
||||
function fmtDate(d) {
|
||||
if (!d) return '—';
|
||||
var dt = new Date(d);
|
||||
return dt.toLocaleDateString('es-MX', {day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit'});
|
||||
if (isNaN(dt.getTime())) return d;
|
||||
return dt.toLocaleDateString('es-MX', {day: '2-digit', month: '2-digit', year: 'numeric'}) + ' ' +
|
||||
dt.toLocaleTimeString('es-MX', {hour: '2-digit', minute: '2-digit', hour12: false});
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (!s) return '';
|
||||
if (s == null) return '';
|
||||
var el = document.createElement('div');
|
||||
el.textContent = s;
|
||||
return el.innerHTML;
|
||||
@@ -67,13 +100,51 @@ var Workshop = (function() {
|
||||
// ─── Init ───
|
||||
|
||||
function init() {
|
||||
var savedView = localStorage.getItem('workshop_view');
|
||||
if (savedView) currentView = savedView;
|
||||
bindFilters();
|
||||
bindDeliverySelect();
|
||||
if (hidePrices) {
|
||||
var btnCatalog = document.getElementById('btnCatalog');
|
||||
if (btnCatalog) btnCatalog.style.display = 'none';
|
||||
document.querySelectorAll('.price-col').forEach(function(el) { el.style.display = 'none'; });
|
||||
}
|
||||
loadReferenceData();
|
||||
loadSummary();
|
||||
setView(currentView);
|
||||
loadOrders();
|
||||
loadCatalog();
|
||||
loadReferenceData();
|
||||
}
|
||||
|
||||
// ─── Summary / Kanban ───
|
||||
function bindFilters() {
|
||||
['filterBranch','filterStatus','filterDelivery','filterDirect','filterSearch'].forEach(function(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.addEventListener('change', function() { currentPage = 1; loadOrders(); });
|
||||
if (el.tagName === 'INPUT' && id === 'filterSearch') {
|
||||
el.addEventListener('keyup', debounce(function() { currentPage = 1; loadOrders(); }, 350));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindDeliverySelect() {
|
||||
var sel = document.getElementById('noDelivery');
|
||||
if (!sel) return;
|
||||
sel.addEventListener('change', function() {
|
||||
var cf = document.getElementById('courierField');
|
||||
if (cf) cf.style.display = sel.value === 'courier' ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function debounce(fn, ms) {
|
||||
var t;
|
||||
return function() {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(fn, ms);
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Summary ───
|
||||
|
||||
function loadSummary() {
|
||||
fetch(API + '/kanban/summary', {headers: headers()})
|
||||
@@ -87,19 +158,110 @@ var Workshop = (function() {
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
// ─── Orders loading ───
|
||||
|
||||
function getFilterQuery() {
|
||||
var params = [];
|
||||
var status = document.getElementById('filterStatus').value;
|
||||
var delivery = document.getElementById('filterDelivery').value;
|
||||
var branch = document.getElementById('filterBranch').value;
|
||||
var direct = document.getElementById('filterDirect').checked;
|
||||
var q = document.getElementById('filterSearch').value.trim();
|
||||
if (status) params.push('status=' + encodeURIComponent(status));
|
||||
if (delivery) params.push('delivery_method=' + encodeURIComponent(delivery));
|
||||
if (branch) params.push('branch_id=' + encodeURIComponent(branch));
|
||||
if (direct) params.push('is_direct=true');
|
||||
if (q) params.push('q=' + encodeURIComponent(q));
|
||||
params.push('page=' + currentPage);
|
||||
params.push('per_page=' + perPage);
|
||||
return '?' + params.join('&');
|
||||
}
|
||||
|
||||
function loadOrders() {
|
||||
fetch(API + '?per_page=200', {headers: headers()})
|
||||
fetch(API + getFilterQuery(), {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
orders = d.data || [];
|
||||
renderKanban();
|
||||
var pagination = d.pagination || {};
|
||||
totalPages = pagination.total_pages || 1;
|
||||
if (currentView === 'list') {
|
||||
renderList();
|
||||
renderPagination();
|
||||
} else {
|
||||
renderKanban();
|
||||
}
|
||||
})
|
||||
.catch(function(e) {
|
||||
console.error(e);
|
||||
document.getElementById('kanbanBoard').innerHTML = '<div class="empty-state"><div class="empty-state__title">Error cargando órdenes</div><div class="empty-state__subtitle">No se pudieron cargar las órdenes de servicio.</div></div>';
|
||||
document.getElementById('listBody').innerHTML = '<tr><td colspan="7" style="text-align:center;padding:var(--space-4);">Error cargando órdenes</td></tr>';
|
||||
});
|
||||
}
|
||||
|
||||
var totalPages = 1;
|
||||
|
||||
// ─── View switching ───
|
||||
|
||||
function setView(view) {
|
||||
currentView = view;
|
||||
localStorage.setItem('workshop_view', view);
|
||||
document.querySelectorAll('.view-switch__btn').forEach(function(b) {
|
||||
b.classList.toggle('is-active', b.dataset.view === view);
|
||||
});
|
||||
document.getElementById('listView').style.display = view === 'list' ? 'block' : 'none';
|
||||
document.getElementById('kanbanBoard').style.display = view === 'kanban' ? 'flex' : 'none';
|
||||
if (view === 'list') {
|
||||
renderList();
|
||||
renderPagination();
|
||||
} else {
|
||||
renderKanban();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── List view ───
|
||||
|
||||
function renderList() {
|
||||
var body = document.getElementById('listBody');
|
||||
if (!orders.length) {
|
||||
body.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:var(--space-4);">No se encontraron órdenes</td></tr>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = orders.map(function(o) {
|
||||
var vehicle = esc((o.vehicle_plate || '—') + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || ''));
|
||||
return '<tr>' +
|
||||
'<td>' + esc(o.branch_name || '—') + '</td>' +
|
||||
'<td><strong>' + esc(o.order_number) + '</strong></td>' +
|
||||
'<td>' + esc(o.customer_name || 'Cliente general') + '</td>' +
|
||||
'<td>' + vehicle + '</td>' +
|
||||
'<td><span class="badge badge--' + esc(o.status) + '">' + esc(STATUS_LABELS[o.status] || o.status) + '</span></td>' +
|
||||
'<td class="price-col" style="text-align:right;">' + fmtMoney(o.total) + '</td>' +
|
||||
'<td><button class="btn btn--sm btn--secondary" onclick="Workshop.openDetail(' + o.id + ')">Ver</button></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
if (hidePrices) {
|
||||
document.querySelectorAll('.price-col').forEach(function(el) { el.style.display = 'none'; });
|
||||
}
|
||||
}
|
||||
|
||||
function renderPagination() {
|
||||
var el = document.getElementById('listPagination');
|
||||
if (totalPages <= 1) {
|
||||
el.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
var html = '<button class="btn btn--sm btn--ghost" ' + (currentPage === 1 ? 'disabled' : '') + ' onclick="Workshop.goPage(' + (currentPage - 1) + ')">Anterior</button>' +
|
||||
'<span class="pagination-info">Página ' + currentPage + ' de ' + totalPages + '</span>' +
|
||||
'<button class="btn btn--sm btn--ghost" ' + (currentPage === totalPages ? 'disabled' : '') + ' onclick="Workshop.goPage(' + (currentPage + 1) + ')">Siguiente</button>';
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function goPage(p) {
|
||||
if (p < 1 || p > totalPages) return;
|
||||
currentPage = p;
|
||||
loadOrders();
|
||||
}
|
||||
|
||||
// ─── Kanban view ───
|
||||
|
||||
function renderKanban() {
|
||||
var board = document.getElementById('kanbanBoard');
|
||||
board.innerHTML = '';
|
||||
@@ -119,9 +281,7 @@ var Workshop = (function() {
|
||||
if (!colOrders.length) {
|
||||
body.innerHTML = '<div class="empty-state" style="padding:var(--space-4);"><div class="empty-state__subtitle">Sin órdenes</div></div>';
|
||||
} else {
|
||||
colOrders.forEach(function(o) {
|
||||
body.appendChild(renderCard(o));
|
||||
});
|
||||
colOrders.forEach(function(o) { body.appendChild(renderCard(o)); });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -132,31 +292,14 @@ var Workshop = (function() {
|
||||
}
|
||||
|
||||
function statusBadgeClass(status) {
|
||||
var map = {
|
||||
pending: 'badge--pending',
|
||||
reserved: 'badge--reserved',
|
||||
installed: 'badge--installed',
|
||||
cancelled: 'badge--cancelled',
|
||||
complete: 'badge--complete'
|
||||
};
|
||||
return map[status] || 'badge--pending';
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
var map = {
|
||||
pending: 'Pendiente',
|
||||
reserved: 'Reservado',
|
||||
installed: 'Instalado',
|
||||
cancelled: 'Cancelado',
|
||||
complete: 'Completado'
|
||||
};
|
||||
return map[status] || status;
|
||||
return 'badge--' + (status || 'pending');
|
||||
}
|
||||
|
||||
function renderCard(o) {
|
||||
var card = document.createElement('div');
|
||||
card.className = 'kanban-card';
|
||||
card.onclick = function() { openDetail(o.id); };
|
||||
var priceHtml = hidePrices ? '' : '<span>' + fmtMoney(o.estimated_cost || o.total) + '</span>';
|
||||
card.innerHTML =
|
||||
'<div class="kanban-card__header">' +
|
||||
' <span class="kanban-card__id">' + esc(o.order_number) + '</span>' +
|
||||
@@ -166,7 +309,7 @@ var Workshop = (function() {
|
||||
'<div class="kanban-card__vehicle">' + esc(o.vehicle_plate || 'Sin vehículo') + '</div>' +
|
||||
'<div class="kanban-card__meta">' +
|
||||
' <span class="kanban-card__mechanic">🔧 ' + esc(o.employee_name || 'Sin asignar') + '</span>' +
|
||||
' <span>' + fmtMoney(o.estimated_cost) + '</span>' +
|
||||
priceHtml +
|
||||
'</div>';
|
||||
return card;
|
||||
}
|
||||
@@ -178,7 +321,8 @@ var Workshop = (function() {
|
||||
fetch(API + '/' + id, {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(o) {
|
||||
document.getElementById('detailTitle').textContent = 'Orden ' + esc(o.order_number);
|
||||
currentOrder = o;
|
||||
document.getElementById('detailTitle').textContent = 'Bitácora Orden: ' + esc(o.order_number);
|
||||
renderDetailBody(o);
|
||||
document.getElementById('detailModal').classList.add('is-open');
|
||||
})
|
||||
@@ -186,108 +330,171 @@ var Workshop = (function() {
|
||||
}
|
||||
|
||||
function renderDetailBody(o) {
|
||||
var activeTab = document.querySelector('.so-tabs__btn.is-active');
|
||||
var selectedTab = activeTab ? activeTab.dataset.tab : 'service';
|
||||
|
||||
var html =
|
||||
'<div class="so-detail">' +
|
||||
' <div class="so-detail__section">' +
|
||||
' <h3>Información general</h3>' +
|
||||
' <div class="so-detail__grid">' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Cliente</span><span class="so-detail__value">' + esc(o.customer_name || '—') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Vehículo</span><span class="so-detail__value">' + esc((o.vehicle_plate || '—') + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || '')) + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Mecánico</span><span class="so-detail__value">' + esc(o.employee_name || 'Sin asignar') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Estado</span><span class="so-detail__value">' + esc(o.status) + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Entrega estimada</span><span class="so-detail__value">' + fmtDate(o.estimated_completion) + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Kilometraje entrada</span><span class="so-detail__value">' + fmt(o.mileage_in) + '</span></div>' +
|
||||
' </div>' +
|
||||
' <div style="margin-top:var(--space-3);"><span class="so-detail__label">Notas recepción</span><p>' + esc(o.reception_notes || '—') + '</p></div>' +
|
||||
' </div>' +
|
||||
'<div class="so-detail-header">' +
|
||||
' <div class="so-detail-header__row"><span class="so-detail__label">Estatus:</span> <span class="badge badge--' + esc(o.status) + '">' + esc(STATUS_LABELS[o.status] || o.status) + '</span></div>' +
|
||||
' <div class="so-detail-header__row"><span class="so-detail__label">Fecha:</span> ' + fmtDate(o.created_at) + '</div>' +
|
||||
' <div class="so-detail-header__row"><span class="so-detail__label">Registrado por:</span> ' + esc(o.created_by_name || '—') + '</div>' +
|
||||
' <div class="so-detail-header__row"><span class="so-detail__label">Sucursal:</span> ' + esc(o.branch_name || '—') + '</div>' +
|
||||
'</div>' +
|
||||
|
||||
' <div class="so-detail__section">' +
|
||||
' <h3>Refacciones</h3>' +
|
||||
' <table class="data-table"><thead><tr><th>Concepto</th><th>Cant.</th><th>Precio</th><th>Estado</th><th></th></tr></thead><tbody>' +
|
||||
(o.items || []).map(function(it) {
|
||||
var itemStatus = it.reserved_quantity >= it.quantity ? 'reserved' : it.status;
|
||||
return '<tr>' +
|
||||
'<td>' + esc(it.name) + '<br><small>' + esc(it.part_number || '') + '</small></td>' +
|
||||
'<td>' + fmt(it.quantity) + '</td>' +
|
||||
'<td>' + fmtMoney(it.unit_price) + '</td>' +
|
||||
'<td><span class="badge ' + statusBadgeClass(itemStatus) + '">' + statusLabel(itemStatus) + '</span></td>' +
|
||||
'<td>' + (it.reserved_quantity < it.quantity && it.status !== 'cancelled' ? '<button class="btn btn--sm btn--secondary" onclick="event.stopPropagation();Workshop.reserveItem(' + it.id + ')">Reservar</button>' : '') + '</td>' +
|
||||
'</tr>';
|
||||
}).join('') +
|
||||
'</tbody></table>' +
|
||||
' <div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);">' +
|
||||
' <input class="form-input" id="newItemSearch" placeholder="Buscar refacción por nombre/numero" style="flex:1;" />' +
|
||||
' <button class="btn btn--secondary" onclick="Workshop.addItemPlaceholder()">Agregar</button>' +
|
||||
' </div>' +
|
||||
' </div>' +
|
||||
'<div class="so-detail-info">' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Cliente</span><span class="so-detail__value">' + esc(o.customer_name || '—') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Dirección</span><span class="so-detail__value">' + esc(o.customer_address || '—') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Teléfono</span><span class="so-detail__value">' + esc(o.customer_phone || '—') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Vehículo</span><span class="so-detail__value">' + esc((o.vehicle_plate || '—') + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || '')) + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Vía de entrega</span><span class="so-detail__value">' + esc(DELIVERY_LABELS[o.delivery_method] || o.delivery_method || '—') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Motociclista</span><span class="so-detail__value">' + esc(o.courier_name || '—') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Mecánico</span><span class="so-detail__value">' + esc(o.employee_name || 'Sin asignar') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Entrega estimada</span><span class="so-detail__value">' + fmtDate(o.estimated_completion) + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Kilometraje entrada</span><span class="so-detail__value">' + fmt(o.mileage_in) + '</span></div>' +
|
||||
(hidePrices ? '' : '<div class="so-detail__field"><span class="so-detail__label">Presupuesto</span><span class="so-detail__value">' + fmtMoney(o.estimated_cost) + '</span></div>') +
|
||||
(hidePrices ? '' : '<div class="so-detail__field"><span class="so-detail__label">Total</span><span class="so-detail__value">' + fmtMoney(o.total) + '</span></div>') +
|
||||
'</div>' +
|
||||
|
||||
' <div class="so-detail__section">' +
|
||||
' <h3>Mano de obra</h3>' +
|
||||
' <table class="data-table"><thead><tr><th>Concepto</th><th>Horas</th><th>Precio/hr</th><th>Total</th><th>Estado</th></tr></thead><tbody>' +
|
||||
(o.labor || []).map(function(l) {
|
||||
return '<tr>' +
|
||||
'<td>' + esc(l.description) + '</td>' +
|
||||
'<td>' + fmt(l.hours) + '</td>' +
|
||||
'<td>' + fmtMoney(l.hourly_rate) + '</td>' +
|
||||
'<td>' + fmtMoney(l.total_cost) + '</td>' +
|
||||
'<td><span class="badge ' + statusBadgeClass(l.status) + '">' + statusLabel(l.status) + '</span></td>' +
|
||||
'</tr>';
|
||||
}).join('') +
|
||||
'</tbody></table>' +
|
||||
' <div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);">' +
|
||||
' <select class="form-input" id="laborCatalogSelect"><option value="">Concepto manual</option></select>' +
|
||||
' <input class="form-input" id="laborDesc" placeholder="Descripción" style="flex:1;" />' +
|
||||
' <input class="form-input" id="laborHours" type="number" step="0.1" placeholder="Hrs" style="width:80px;" />' +
|
||||
' <input class="form-input" id="laborRate" type="number" step="0.01" placeholder="$/hr" style="width:100px;" />' +
|
||||
' <button class="btn btn--secondary" onclick="Workshop.addLabor()">Agregar</button>' +
|
||||
' </div>' +
|
||||
' </div>' +
|
||||
'<div class="so-tabs">' +
|
||||
' <button class="so-tabs__btn ' + (selectedTab === 'service' ? 'is-active' : '') + '" data-tab="service" onclick="Workshop.switchTab(\'service\')">Orden de servicio</button>' +
|
||||
' <button class="so-tabs__btn ' + (selectedTab === 'articles' ? 'is-active' : '') + '" data-tab="articles" onclick="Workshop.switchTab(\'articles\')">Artículos</button>' +
|
||||
'</div>' +
|
||||
|
||||
'<div class="so-tab-panel" id="tab-service" ' + (selectedTab === 'service' ? '' : 'style="display:none;"') + '>' +
|
||||
' <div class="so-detail__section">' +
|
||||
' <h3>Cambiar estado</h3>' +
|
||||
' <div class="so-detail__actions">' +
|
||||
' <select class="form-input" id="statusSelect" style="width:auto;">' +
|
||||
COLUMNS.map(function(c) { return '<option value="' + c.key + '"' + (c.key === o.status ? ' selected' : '') + '>' + c.label + '</option>'; }).join('') +
|
||||
' </select>' +
|
||||
' <button class="btn btn--primary" onclick="Workshop.changeStatus()">Actualizar estado</button>' +
|
||||
' </div>' +
|
||||
' <h3>Observaciones</h3>' +
|
||||
' <p>' + esc(o.reception_notes || 'Sin observaciones') + '</p>' +
|
||||
' </div>' +
|
||||
' <div class="so-detail__section">' +
|
||||
' <h3>Bitácora</h3>' +
|
||||
renderBitacora(o.status_history || []) +
|
||||
' </div>' +
|
||||
'</div>' +
|
||||
|
||||
'<div class="so-tab-panel" id="tab-articles" ' + (selectedTab === 'articles' ? '' : 'style="display:none;"') + '>' +
|
||||
renderArticles(o) +
|
||||
'</div>';
|
||||
|
||||
document.getElementById('detailBody').innerHTML = html;
|
||||
|
||||
// Populate labor catalog select
|
||||
var sel = document.getElementById('laborCatalogSelect');
|
||||
if (sel) {
|
||||
// Footer actions
|
||||
var footer = document.getElementById('detailFooter');
|
||||
var statusHtml = canEdit ?
|
||||
'<div class="so-detail__actions" style="margin-right:auto;">' +
|
||||
' <select class="form-input" id="statusSelect" style="width:auto;">' +
|
||||
COLUMNS.map(function(c) { return '<option value="' + c.key + '"' + (c.key === o.status ? ' selected' : '') + '>' + c.label + '</option>'; }).join('') +
|
||||
' </select>' +
|
||||
' <button class="btn btn--primary" onclick="Workshop.changeStatus()">Actualizar estado</button>' +
|
||||
'</div>' : '';
|
||||
footer.innerHTML = statusHtml +
|
||||
'<button class="btn btn--ghost" onclick="Workshop.closeDetailModal()">Cerrar</button>' +
|
||||
'<button class="btn btn--secondary" onclick="Workshop.printOrder()">' +
|
||||
'<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>' +
|
||||
'Imprimir orden</button>' +
|
||||
(canEdit && canSell && o.status === 'ready' && !o.sale_id ? '<button class="btn btn--primary" onclick="Workshop.convertToSale()">Convertir a venta</button>' : '') +
|
||||
(o.sale_id ? '<a class="btn btn--secondary" href="/pos/invoicing?sale_id=' + o.sale_id + '">Ver venta #' + o.sale_id + '</a>' : '');
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.so-tabs__btn').forEach(function(b) { b.classList.toggle('is-active', b.dataset.tab === tab); });
|
||||
document.getElementById('tab-service').style.display = tab === 'service' ? 'block' : 'none';
|
||||
document.getElementById('tab-articles').style.display = tab === 'articles' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function renderBitacora(history) {
|
||||
if (!history.length) return '<p style="color:var(--color-text-muted);">Sin movimientos</p>';
|
||||
var rows = history.map(function(h) {
|
||||
return '<tr>' +
|
||||
'<td><span class="badge badge--' + esc(h.new_status) + '">' + esc(STATUS_LABELS[h.new_status] || h.new_status) + '</span></td>' +
|
||||
'<td>' + fmtDate(h.created_at) + '</td>' +
|
||||
'<td>' + esc(h.changed_by_name || '—') + '</td>' +
|
||||
'<td>' + esc(h.notes || '—') + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
return '<table class="data-table bitacora-table"><thead><tr><th>Estatus</th><th>Fecha</th><th>Usuario</th><th>Observaciones</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
||||
}
|
||||
|
||||
function renderArticles(o) {
|
||||
var partsRows = (o.items || []).map(function(it) {
|
||||
var priceCells = hidePrices ? '' :
|
||||
'<td>' + fmtMoney(it.unit_price) + '</td>';
|
||||
var actionCell = canEdit && it.status !== 'cancelled' ?
|
||||
'<td><button class="btn btn--sm btn--secondary" onclick="event.stopPropagation();Workshop.reserveItem(' + it.id + ')">Reservar</button></td>' : '<td></td>';
|
||||
return '<tr>' +
|
||||
'<td>' + esc(it.name) + '<br><small>' + esc(it.part_number || '') + '</small></td>' +
|
||||
'<td>' + fmt(it.quantity) + '</td>' +
|
||||
priceCells +
|
||||
'<td><span class="badge ' + statusBadgeClass(it.status) + '">' + esc(STATUS_LABELS[it.status] || it.status) + '</span></td>' +
|
||||
actionCell +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
var partsHeader = '<tr><th>Concepto</th><th>Cant.</th>' + (hidePrices ? '' : '<th>Precio</th>') + '<th>Estado</th><th></th></tr>';
|
||||
|
||||
var laborRows = (o.labor || []).map(function(l) {
|
||||
var priceCells = hidePrices ? '' : '<td>' + fmtMoney(l.hourly_rate) + '</td><td>' + fmtMoney(l.total_cost) + '</td>';
|
||||
return '<tr>' +
|
||||
'<td>' + esc(l.description) + '</td>' +
|
||||
'<td>' + fmt(l.hours) + '</td>' +
|
||||
priceCells +
|
||||
'<td><span class="badge ' + statusBadgeClass(l.status) + '">' + esc(STATUS_LABELS[l.status] || l.status) + '</span></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
var laborHeader = '<tr><th>Concepto</th><th>Horas</th>' + (hidePrices ? '' : '<th>Precio/hr</th><th>Total</th>') + '<th>Estado</th></tr>';
|
||||
|
||||
var addParts = canEdit ?
|
||||
'<div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);">' +
|
||||
' <input class="form-input" id="newItemSearch" placeholder="Buscar refacción por nombre/número" style="flex:1;" />' +
|
||||
' <button class="btn btn--secondary" onclick="Workshop.addItemPlaceholder()">Agregar</button>' +
|
||||
'</div>' : '';
|
||||
|
||||
var addLabor = canEdit ?
|
||||
'<div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);flex-wrap:wrap;">' +
|
||||
' <select class="form-input" id="laborCatalogSelect"><option value="">Concepto manual</option></select>' +
|
||||
' <input class="form-input" id="laborDesc" placeholder="Descripción" style="flex:1;min-width:160px;" />' +
|
||||
' <input class="form-input" id="laborHours" type="number" step="0.1" placeholder="Hrs" style="width:80px;" />' +
|
||||
(hidePrices ? '' : '<input class="form-input" id="laborRate" type="number" step="0.01" placeholder="$/hr" style="width:100px;" />') +
|
||||
' <button class="btn btn--secondary" onclick="Workshop.addLabor()">Agregar</button>' +
|
||||
'</div>' : '';
|
||||
|
||||
var html =
|
||||
'<div class="so-detail__section">' +
|
||||
' <h3>Refacciones</h3>' +
|
||||
' <table class="data-table"><thead>' + partsHeader + '</thead><tbody>' + (partsRows || '<tr><td colspan="' + (hidePrices ? 4 : 5) + '" style="text-align:center;">Sin refacciones</td></tr>') + '</tbody></table>' +
|
||||
addParts +
|
||||
'</div>' +
|
||||
'<div class="so-detail__section">' +
|
||||
' <h3>Mano de obra</h3>' +
|
||||
' <table class="data-table"><thead>' + laborHeader + '</thead><tbody>' + (laborRows || '<tr><td colspan="' + (hidePrices ? 3 : 5) + '" style="text-align:center;">Sin mano de obra</td></tr>') + '</tbody></table>' +
|
||||
addLabor +
|
||||
'</div>';
|
||||
|
||||
// schedule labor catalog select population after DOM insertion
|
||||
setTimeout(function() {
|
||||
var sel = document.getElementById('laborCatalogSelect');
|
||||
if (!sel || sel.dataset.populated) return;
|
||||
catalog.forEach(function(c) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = JSON.stringify(c);
|
||||
opt.textContent = c.name + ' ($' + fmtMoney(c.suggested_hours * c.suggested_rate).replace('$', '') + ')';
|
||||
opt.textContent = c.name + (hidePrices ? '' : ' ($' + fmtMoney(c.suggested_hours * c.suggested_rate).replace('$', '') + ')');
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
sel.dataset.populated = '1';
|
||||
sel.onchange = function() {
|
||||
if (!sel.value) return;
|
||||
var c = JSON.parse(sel.value);
|
||||
document.getElementById('laborDesc').value = c.name;
|
||||
document.getElementById('laborHours').value = c.suggested_hours;
|
||||
document.getElementById('laborRate').value = c.suggested_rate;
|
||||
if (!hidePrices) document.getElementById('laborRate').value = c.suggested_rate;
|
||||
};
|
||||
}
|
||||
}, 0);
|
||||
|
||||
// Footer actions
|
||||
var footer = document.getElementById('detailFooter');
|
||||
footer.innerHTML =
|
||||
'<button class="btn btn--ghost" onclick="Workshop.closeDetailModal()">Cerrar</button>' +
|
||||
'<button class="btn btn--secondary" onclick="Workshop.printOrder()">' +
|
||||
'<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>' +
|
||||
'Imprimir orden</button>' +
|
||||
(o.status === 'ready' && !o.sale_id ? '<button class="btn btn--primary" onclick="Workshop.convertToSale()">Convertir a venta</button>' : '') +
|
||||
(o.sale_id ? '<a class="btn btn--secondary" href="/pos/invoicing?sale_id=' + o.sale_id + '">Ver venta #' + o.sale_id + '</a>' : '');
|
||||
return html;
|
||||
}
|
||||
|
||||
function closeDetailModal() {
|
||||
document.getElementById('detailModal').classList.remove('is-open');
|
||||
currentOrderId = null;
|
||||
currentOrder = null;
|
||||
}
|
||||
|
||||
// ─── Actions ───
|
||||
@@ -330,7 +537,7 @@ var Workshop = (function() {
|
||||
function addLabor() {
|
||||
var desc = document.getElementById('laborDesc').value.trim();
|
||||
var hours = parseFloat(document.getElementById('laborHours').value) || 0;
|
||||
var rate = parseFloat(document.getElementById('laborRate').value) || 0;
|
||||
var rate = hidePrices ? 0 : parseFloat(document.getElementById('laborRate').value) || 0;
|
||||
if (!desc) return alert('Escribe una descripción');
|
||||
api('POST', '/' + currentOrderId + '/labor', {
|
||||
description: desc,
|
||||
@@ -340,7 +547,7 @@ var Workshop = (function() {
|
||||
}).then(function() {
|
||||
document.getElementById('laborDesc').value = '';
|
||||
document.getElementById('laborHours').value = '';
|
||||
document.getElementById('laborRate').value = '';
|
||||
if (!hidePrices) document.getElementById('laborRate').value = '';
|
||||
openDetail(currentOrderId);
|
||||
}).catch(function(e) { alert('Error: ' + e.message); });
|
||||
}
|
||||
@@ -388,26 +595,34 @@ var Workshop = (function() {
|
||||
populateSelect('noCustomer', customers, function(c) { return {value: c.id, text: c.name + ' (' + (c.phone || '') + ')'}; });
|
||||
populateSelect('noVehicle', vehicles, function(v) { return {value: v.id, text: v.plate + ' ' + v.make + ' ' + v.model}; });
|
||||
populateSelect('noMechanic', employees, function(e) { return {value: e.id, text: e.name}; });
|
||||
populateSelect('noCourier', couriers, function(c) { return {value: c.id, text: c.name}; });
|
||||
document.getElementById('newOrderModal').classList.add('is-open');
|
||||
}
|
||||
|
||||
function closeNewOrderModal() {
|
||||
document.getElementById('newOrderModal').classList.remove('is-open');
|
||||
document.getElementById('newOrderForm').reset();
|
||||
var cf = document.getElementById('courierField');
|
||||
if (cf) cf.style.display = 'none';
|
||||
}
|
||||
|
||||
function submitNewOrder() {
|
||||
var customerId = document.getElementById('noCustomer').value;
|
||||
if (!customerId) return alert('Selecciona un cliente');
|
||||
api('POST', '', {
|
||||
var delivery = document.getElementById('noDelivery').value;
|
||||
var payload = {
|
||||
customer_id: parseInt(customerId, 10),
|
||||
vehicle_id: parseInt(document.getElementById('noVehicle').value, 10) || null,
|
||||
employee_id: parseInt(document.getElementById('noMechanic').value, 10) || null,
|
||||
priority: document.getElementById('noPriority').value,
|
||||
estimated_completion: document.getElementById('noEstimatedCompletion').value || null,
|
||||
mileage_in: parseInt(document.getElementById('noMileage').value, 10) || null,
|
||||
reception_notes: document.getElementById('noNotes').value
|
||||
}).then(function() {
|
||||
reception_notes: document.getElementById('noNotes').value,
|
||||
delivery_method: delivery || null,
|
||||
courier_id: delivery === 'courier' ? (parseInt(document.getElementById('noCourier').value, 10) || null) : null,
|
||||
is_direct: document.getElementById('noDirect').checked
|
||||
};
|
||||
api('POST', '', payload).then(function() {
|
||||
closeNewOrderModal();
|
||||
loadSummary();
|
||||
loadOrders();
|
||||
@@ -497,12 +712,42 @@ var Workshop = (function() {
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) { employees = (d.data || d.employees || []); })
|
||||
.catch(function() { employees = []; });
|
||||
// Couriers
|
||||
fetch('/pos/api/couriers?per_page=500', {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
couriers = (d.data || d.couriers || []);
|
||||
populateSelect('noCourier', couriers, function(c) { return {value: c.id, text: c.name}; });
|
||||
})
|
||||
.catch(function() { couriers = []; });
|
||||
// Branches
|
||||
fetch('/pos/api/config/branches', {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
branches = (d.data || []);
|
||||
populateBranchFilter();
|
||||
})
|
||||
.catch(function() { branches = []; });
|
||||
}
|
||||
|
||||
function populateBranchFilter() {
|
||||
var sel = document.getElementById('filterBranch');
|
||||
if (!sel) return;
|
||||
var current = sel.value;
|
||||
sel.innerHTML = '<option value="">Todas las sucursales</option>';
|
||||
branches.forEach(function(b) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = b.id;
|
||||
opt.textContent = b.name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
sel.value = current;
|
||||
}
|
||||
|
||||
function populateSelect(id, items, mapper) {
|
||||
var sel = document.getElementById(id);
|
||||
if (!sel) return;
|
||||
sel.innerHTML = id === 'noCustomer' ? '' : '<option value="">—</option>';
|
||||
sel.innerHTML = id === 'noCustomer' || id === 'noCourier' ? '' : '<option value="">—</option>';
|
||||
items.forEach(function(it) {
|
||||
var opt = mapper(it);
|
||||
var el = document.createElement('option');
|
||||
@@ -516,8 +761,11 @@ var Workshop = (function() {
|
||||
|
||||
return {
|
||||
init: init,
|
||||
setView: setView,
|
||||
goPage: goPage,
|
||||
openDetail: openDetail,
|
||||
closeDetailModal: closeDetailModal,
|
||||
switchTab: switchTab,
|
||||
changeStatus: changeStatus,
|
||||
reserveItem: reserveItem,
|
||||
addItemPlaceholder: addItemPlaceholder,
|
||||
|
||||
Reference in New Issue
Block a user