Files
Autoparts-DB/pos/static/js/workshop.js
consultoria-as b635e44302 style(workshop): alinea UI del taller con el resto del POS
- Usa app-shell/main, page-header con eyebrow, summary-strip y cards con iconos.
- Reemplaza badges personalizados por .badge del design system.
- Unifica tablas con .data-table y .table-wrapper.
- Estandariza modales con .modal-overlay/.modal y formularios con .form-grid.
- Actualiza workshop.js para usar clases del sistema y toggle is-open en modales.
- Corrige tokens rotos (--text-sm, --color-warn, etc.) y usa variables del tema.
2026-06-15 07:17:28 +00:00

538 lines
23 KiB
JavaScript

/**
* workshop.js — Taller / Service Orders Kanban for Nexus POS
*/
var Workshop = (function() {
'use strict';
var API = '/pos/api/service-orders';
var token = localStorage.getItem('pos_token');
var orders = [];
var catalog = [];
var customers = [];
var vehicles = [];
var employees = [];
var currentOrderId = null;
var COLUMNS = [
{key: 'received', label: 'Recibido'},
{key: 'diagnosis', label: 'Diagnóstico'},
{key: 'waiting_parts', label: 'Espera refacciones'},
{key: 'repair', label: 'En reparación'},
{key: 'quality_check', label: 'Control calidad'},
{key: 'ready', label: 'Listo'},
{key: 'delivered', label: 'Entregado'},
];
function headers() {
return {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
};
}
function fmt(n) {
if (n == null) return '0';
return parseFloat(n).toLocaleString('es-MX');
}
function fmtMoney(n) {
if (n == null) return '$0.00';
return '$' + parseFloat(n).toLocaleString('es-MX', {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
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'});
}
function esc(s) {
if (!s) return '';
var el = document.createElement('div');
el.textContent = s;
return el.innerHTML;
}
function api(method, url, body) {
var opts = {method: method, headers: headers()};
if (body) opts.body = JSON.stringify(body);
return fetch(API + url, opts).then(function(r) {
return r.json().then(function(data) {
if (!r.ok) throw new Error(data.error || r.statusText);
return data;
});
});
}
// ─── Init ───
function init() {
loadSummary();
loadOrders();
loadCatalog();
loadReferenceData();
}
// ─── Summary / Kanban ───
function loadSummary() {
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('statOverdue').textContent = fmt(d.overdue || 0);
})
.catch(function() {});
}
function loadOrders() {
fetch(API + '?per_page=200', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
orders = d.data || [];
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>';
});
}
function renderKanban() {
var board = document.getElementById('kanbanBoard');
board.innerHTML = '';
COLUMNS.forEach(function(col) {
var colOrders = orders.filter(function(o) { return o.status === col.key; });
var colEl = document.createElement('div');
colEl.className = 'kanban-column';
colEl.innerHTML =
'<div class="kanban-column__header">' +
' <span>' + esc(col.label) + '</span>' +
' <span class="kanban-column__count">' + colOrders.length + '</span>' +
'</div>' +
'<div class="kanban-column__body" id="col-' + col.key + '"></div>';
board.appendChild(colEl);
var body = colEl.querySelector('.kanban-column__body');
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));
});
}
});
}
function priorityLabel(p) {
var map = {normal: 'Normal', high: 'Alta', urgent: 'Urgente'};
return map[p] || p;
}
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;
}
function renderCard(o) {
var card = document.createElement('div');
card.className = 'kanban-card';
card.onclick = function() { openDetail(o.id); };
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>' +
'</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__meta">' +
' <span class="kanban-card__mechanic">🔧 ' + esc(o.employee_name || 'Sin asignar') + '</span>' +
' <span>' + fmtMoney(o.estimated_cost) + '</span>' +
'</div>';
return card;
}
// ─── Detail modal ───
function openDetail(id) {
currentOrderId = id;
fetch(API + '/' + id, {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(o) {
document.getElementById('detailTitle').textContent = 'Orden ' + esc(o.order_number);
renderDetailBody(o);
document.getElementById('detailModal').classList.add('is-open');
})
.catch(function(e) { alert('Error: ' + e.message); });
}
function renderDetailBody(o) {
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__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__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-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>' +
' </div>' +
'</div>';
document.getElementById('detailBody').innerHTML = html;
// Populate labor catalog select
var sel = document.getElementById('laborCatalogSelect');
if (sel) {
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('$', '') + ')';
sel.appendChild(opt);
});
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;
};
}
// 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>' : '');
}
function closeDetailModal() {
document.getElementById('detailModal').classList.remove('is-open');
currentOrderId = null;
}
// ─── Actions ───
function changeStatus() {
if (!currentOrderId) return;
var newStatus = document.getElementById('statusSelect').value;
api('PUT', '/' + currentOrderId + '/status', {status: newStatus})
.then(function() {
closeDetailModal();
loadSummary();
loadOrders();
})
.catch(function(e) { alert('Error: ' + e.message); });
}
function reserveItem(itemId) {
api('POST', '/' + currentOrderId + '/items/' + itemId + '/reserve', {})
.then(function() {
alert('Refacción reservada');
openDetail(currentOrderId);
loadSummary();
})
.catch(function(e) { alert('Error: ' + e.message); });
}
function addItemPlaceholder() {
var name = document.getElementById('newItemSearch').value.trim();
if (!name) return;
api('POST', '/' + currentOrderId + '/items', {
name: name,
quantity: 1,
unit_price: 0,
status: 'pending'
}).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;
var rate = parseFloat(document.getElementById('laborRate').value) || 0;
if (!desc) return alert('Escribe una descripción');
api('POST', '/' + currentOrderId + '/labor', {
description: desc,
hours: hours,
hourly_rate: rate,
status: 'pending'
}).then(function() {
document.getElementById('laborDesc').value = '';
document.getElementById('laborHours').value = '';
document.getElementById('laborRate').value = '';
openDetail(currentOrderId);
}).catch(function(e) { alert('Error: ' + e.message); });
}
function convertToSale() {
if (!currentOrderId) return;
if (!confirm('¿Convertir esta orden en una venta? Se descontarán las refacciones reservadas del inventario.')) return;
api('POST', '/' + currentOrderId + '/convert-to-sale', {
payment_method: 'efectivo',
sale_type: 'cash'
}).then(function(r) {
alert('Venta creada: #' + r.sale_id + ' Total: ' + fmtMoney(r.total));
closeDetailModal();
loadSummary();
loadOrders();
}).catch(function(e) { alert('Error: ' + e.message); });
}
function printOrder() {
if (!currentOrderId) return;
if (!window.NexusPrinter || !window.NexusPrinter.isConnected()) {
var connect = confirm('No hay impresora conectada. ¿Conectar ahora?');
if (connect) {
window.NexusPrinter.connect().then(function(r) {
if (r.ok) doPrint();
});
}
return;
}
doPrint();
function doPrint() {
window.NexusPrinter.printServiceOrder(currentOrderId, 80)
.then(function(ok) {
if (ok) alert('Orden enviada a la impresora');
else alert('No se pudo imprimir');
})
.catch(function(e) { alert('Error: ' + e.message); });
}
}
// ─── New order ───
function openNewOrderModal() {
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}; });
document.getElementById('newOrderModal').classList.add('is-open');
}
function closeNewOrderModal() {
document.getElementById('newOrderModal').classList.remove('is-open');
document.getElementById('newOrderForm').reset();
}
function submitNewOrder() {
var customerId = document.getElementById('noCustomer').value;
if (!customerId) return alert('Selecciona un cliente');
api('POST', '', {
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() {
closeNewOrderModal();
loadSummary();
loadOrders();
}).catch(function(e) { alert('Error: ' + e.message); });
}
// ─── Catalog ───
function openCatalogModal() {
document.getElementById('catalogModal').classList.add('is-open');
renderCatalog();
}
function closeCatalogModal() {
document.getElementById('catalogModal').classList.remove('is-open');
}
function loadCatalog() {
fetch(API + '/service-catalog?active_only=true', {headers: headers()})
.then(function(r) { return r.json(); })
.then(function(d) {
catalog = d.data || [];
})
.catch(function() {});
}
function renderCatalog() {
var body = document.getElementById('catalogBody');
if (!catalog.length) {
body.innerHTML = '<tr><td colspan="5" style="text-align:center;">Sin conceptos</td></tr>';
return;
}
body.innerHTML = catalog.map(function(c) {
return '<tr>' +
'<td>' + esc(c.name) + (c.description ? '<br><small>' + esc(c.description) + '</small>' : '') + '</td>' +
'<td>' + fmt(c.suggested_hours) + '</td>' +
'<td>' + fmtMoney(c.suggested_rate) + '</td>' +
'<td>' + fmtMoney(c.suggested_hours * c.suggested_rate) + '</td>' +
'<td><button class="btn btn--sm btn--ghost" onclick="Workshop.deleteCatalogItem(' + c.id + ')">Desactivar</button></td>' +
'</tr>';
}).join('');
}
function addCatalogItem() {
var name = document.getElementById('catName').value.trim();
if (!name) return alert('Escribe un nombre');
api('POST', '/service-catalog', {
name: name,
description: document.getElementById('catDesc').value,
suggested_hours: parseFloat(document.getElementById('catHours').value) || 0,
suggested_rate: parseFloat(document.getElementById('catRate').value) || 0
}).then(function() {
document.getElementById('catName').value = '';
document.getElementById('catDesc').value = '';
document.getElementById('catHours').value = '';
document.getElementById('catRate').value = '';
loadCatalog();
setTimeout(renderCatalog, 200);
}).catch(function(e) { alert('Error: ' + e.message); });
}
function deleteCatalogItem(id) {
if (!confirm('¿Desactivar este concepto?')) return;
api('DELETE', '/service-catalog/' + id, {})
.then(function() {
loadCatalog();
setTimeout(renderCatalog, 200);
})
.catch(function(e) { alert('Error: ' + e.message); });
}
// ─── Reference data ───
function loadReferenceData() {
// Customers
fetch('/pos/api/customers?per_page=500', {headers: headers()})
.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 = []; });
}
function populateSelect(id, items, mapper) {
var sel = document.getElementById(id);
if (!sel) return;
sel.innerHTML = id === 'noCustomer' ? '' : '<option value="">—</option>';
items.forEach(function(it) {
var opt = mapper(it);
var el = document.createElement('option');
el.value = opt.value;
el.textContent = opt.text;
sel.appendChild(el);
});
}
// ─── Public API ───
return {
init: init,
openDetail: openDetail,
closeDetailModal: closeDetailModal,
changeStatus: changeStatus,
reserveItem: reserveItem,
addItemPlaceholder: addItemPlaceholder,
addLabor: addLabor,
convertToSale: convertToSale,
printOrder: printOrder,
openNewOrderModal: openNewOrderModal,
closeNewOrderModal: closeNewOrderModal,
submitNewOrder: submitNewOrder,
openCatalogModal: openCatalogModal,
closeCatalogModal: closeCatalogModal,
addCatalogItem: addCatalogItem,
deleteCatalogItem: deleteCatalogItem,
};
})();
document.addEventListener('DOMContentLoaded', Workshop.init);