/** * 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 = '
Error cargando órdenes
'; }); } 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 = '
' + ' ' + esc(col.label) + '' + ' ' + colOrders.length + '' + '
' + '
'; board.appendChild(colEl); var body = colEl.querySelector('.kanban-column__body'); if (!colOrders.length) { body.innerHTML = '
Sin órdenes
'; } else { colOrders.forEach(function(o) { body.appendChild(renderCard(o)); }); } }); } function renderCard(o) { var card = document.createElement('div'); card.className = 'kanban-card'; card.onclick = function() { openDetail(o.id); }; card.innerHTML = '
' + ' ' + esc(o.order_number) + '' + ' ' + esc(o.priority) + '' + '
' + '
' + esc(o.customer_name || 'Cliente general') + '
' + '
' + esc(o.vehicle_plate || 'Sin vehículo') + '
' + '
' + ' 🔧 ' + esc(o.employee_name || 'Sin asignar') + '' + ' ' + fmtMoney(o.estimated_cost) + '' + '
'; 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').style.display = 'flex'; }) .catch(function(e) { alert('Error: ' + e.message); }); } function renderDetailBody(o) { var html = '
' + '
' + '

Información general

' + '
' + '
Cliente' + esc(o.customer_name || '—') + '
' + '
Vehículo' + esc((o.vehicle_plate || '—') + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || '')) + '
' + '
Mecánico' + esc(o.employee_name || 'Sin asignar') + '
' + '
Estado' + esc(o.status) + '
' + '
Entrega estimada' + fmtDate(o.estimated_completion) + '
' + '
Kilometraje entrada' + fmt(o.mileage_in) + '
' + '
' + '
Notas recepción

' + esc(o.reception_notes || '—') + '

' + '
' + '
' + '

Refacciones

' + ' ' + (o.items || []).map(function(it) { return '' + '' + '' + '' + '' + '' + ''; }).join('') + '
ConceptoCant.PrecioEstado
' + esc(it.name) + '
' + esc(it.part_number || '') + '
' + fmt(it.quantity) + '' + fmtMoney(it.unit_price) + '' + (it.reserved_quantity >= it.quantity ? 'Reservado' : esc(it.status)) + '' + (it.reserved_quantity < it.quantity && it.status !== 'cancelled' ? '' : '') + '
' + '
' + ' ' + ' ' + '
' + '
' + '
' + '

Mano de obra

' + ' ' + (o.labor || []).map(function(l) { return '' + '' + '' + '' + '' + '' + ''; }).join('') + '
ConceptoHorasPrecio/hrTotalEstado
' + esc(l.description) + '' + fmt(l.hours) + '' + fmtMoney(l.hourly_rate) + '' + fmtMoney(l.total_cost) + '' + esc(l.status) + '
' + '
' + ' ' + ' ' + ' ' + ' ' + ' ' + '
' + '
' + '
' + '

Cambiar estado

' + '
' + ' ' + ' ' + '
' + '
' + '
'; 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 = '' + (o.status === 'ready' && !o.sale_id ? '' : '') + (o.sale_id ? 'Ver venta #' + o.sale_id + '' : ''); } function closeDetailModal() { document.getElementById('detailModal').style.display = 'none'; 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); }); } // ─── 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').style.display = 'flex'; } function closeNewOrderModal() { document.getElementById('newOrderModal').style.display = 'none'; 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').style.display = 'flex'; renderCatalog(); } function closeCatalogModal() { document.getElementById('catalogModal').style.display = 'none'; } 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 = 'Sin conceptos'; return; } body.innerHTML = catalog.map(function(c) { return '' + '' + esc(c.name) + (c.description ? '
' + esc(c.description) + '' : '') + '' + '' + fmt(c.suggested_hours) + '' + '' + fmtMoney(c.suggested_rate) + '' + '' + fmtMoney(c.suggested_hours * c.suggested_rate) + '' + '' + ''; }).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' ? '' : ''; 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, openNewOrderModal: openNewOrderModal, closeNewOrderModal: closeNewOrderModal, submitNewOrder: submitNewOrder, openCatalogModal: openCatalogModal, closeCatalogModal: closeCatalogModal, addCatalogItem: addCatalogItem, deleteCatalogItem: deleteCatalogItem, }; })(); document.addEventListener('DOMContentLoaded', Workshop.init);