The '+ Nuevo' vehicle button is now available in both the edit and new service order modals.
1188 lines
56 KiB
JavaScript
1188 lines
56 KiB
JavaScript
/**
|
|
* workshop.js — Taller / Service Orders for Nexus POS
|
|
* Supports list + kanban views, filters, bitacora and role-based price hiding.
|
|
*/
|
|
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 couriers = [];
|
|
var branches = [];
|
|
var currentOrderId = null;
|
|
var currentOrder = null;
|
|
var selectedInventoryItem = null;
|
|
var itemSearchTimer = null;
|
|
var customerSearchTimer = null;
|
|
var selectedEditCustomer = null;
|
|
var vehicleSearchTimer = null;
|
|
var selectedEditVehicle = null;
|
|
var newVehicleContext = null;
|
|
var currentView = 'list';
|
|
var currentPage = 1;
|
|
var perPage = 25;
|
|
|
|
var user = window.POS_USER || {};
|
|
var role = (user.role || '').toLowerCase();
|
|
var hidePrices = role === 'workshop' || role === 'mechanic';
|
|
var perms = user.permissions || [];
|
|
var canEdit = role === 'owner' || role === 'admin' || perms.indexOf('workshop.edit') !== -1;
|
|
var canDelete = role === 'owner' || role === 'admin';
|
|
var canSell = role === 'owner' || role === 'admin' || perms.indexOf('pos.sell') !== -1;
|
|
|
|
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'},
|
|
];
|
|
|
|
var STATUS_LABELS = {
|
|
received: 'Recibido',
|
|
diagnosis: 'Diagnóstico',
|
|
waiting_parts: 'Espera refacciones',
|
|
repair: 'En reparación',
|
|
ready: 'Listo',
|
|
delivered: 'Entregado',
|
|
cancelled: 'Cancelado'
|
|
};
|
|
|
|
var VALID_NEXT = {
|
|
received: ['diagnosis', 'cancelled'],
|
|
diagnosis: ['waiting_parts', 'repair', 'cancelled'],
|
|
waiting_parts: ['repair', 'cancelled'],
|
|
repair: ['ready', 'cancelled'],
|
|
ready: ['delivered', 'cancelled'],
|
|
delivered: [],
|
|
cancelled: []
|
|
};
|
|
|
|
var DELIVERY_LABELS = {
|
|
pickup: 'Pasa cliente',
|
|
delivery: 'Envío a domicilio',
|
|
courier: 'Motociclista'
|
|
};
|
|
|
|
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);
|
|
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 == null) return '';
|
|
var el = document.createElement('div');
|
|
el.textContent = s;
|
|
return el.innerHTML;
|
|
}
|
|
|
|
function escJs(s) {
|
|
if (s == null) return '';
|
|
return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
|
|
}
|
|
|
|
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() {
|
|
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();
|
|
}
|
|
|
|
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()})
|
|
.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() {});
|
|
}
|
|
|
|
// ─── 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 + getFilterQuery(), {headers: headers()})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(d) {
|
|
orders = d.data || [];
|
|
var pagination = d.pagination || {};
|
|
totalPages = pagination.total_pages || 1;
|
|
if (currentView === 'list') {
|
|
renderList();
|
|
renderPagination();
|
|
} else {
|
|
renderKanban();
|
|
}
|
|
})
|
|
.catch(function(e) {
|
|
console.error(e);
|
|
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 = '';
|
|
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) {
|
|
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>' +
|
|
' <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>' +
|
|
priceHtml +
|
|
'</div>';
|
|
return card;
|
|
}
|
|
|
|
// ─── Detail modal ───
|
|
|
|
function openDetail(id) {
|
|
currentOrderId = id;
|
|
fetch(API + '/' + id, {headers: headers()})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(o) {
|
|
currentOrder = o;
|
|
document.getElementById('detailTitle').textContent = 'Bitácora 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 activeTab = document.querySelector('.so-tabs__btn.is-active');
|
|
var selectedTab = activeTab ? activeTab.dataset.tab : 'service';
|
|
|
|
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>' +
|
|
'</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-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>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>' +
|
|
' <label class="form-label">Reparación</label>' +
|
|
' <textarea id="noteRepair" class="form-input" rows="2" ' + (canEdit ? '' : '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>' : '') +
|
|
' </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;
|
|
|
|
// Footer actions
|
|
var footer = document.getElementById('detailFooter');
|
|
var allowedNext = VALID_NEXT[o.status] || [];
|
|
var statusHtml = canEdit && 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>' +
|
|
allowedNext.map(function(s) { return '<option value="' + s + '">' + esc(STATUS_LABELS[s] || s) + '</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>' +
|
|
(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()">' +
|
|
'<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);align-items:flex-start;flex-wrap:wrap;">' +
|
|
' <div style="position:relative;flex:1;min-width:200px;">' +
|
|
' <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;" />' +
|
|
' <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>' +
|
|
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 + (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;
|
|
if (!hidePrices) document.getElementById('laborRate').value = c.suggested_rate;
|
|
};
|
|
}, 0);
|
|
|
|
return html;
|
|
}
|
|
|
|
function closeDetailModal() {
|
|
document.getElementById('detailModal').classList.remove('is-open');
|
|
currentOrderId = null;
|
|
currentOrder = null;
|
|
}
|
|
|
|
function openEditOrderModal() {
|
|
if (!currentOrder) return;
|
|
populateEditOrderModal();
|
|
document.getElementById('editOrderModal').classList.add('is-open');
|
|
}
|
|
|
|
function closeEditOrderModal() {
|
|
document.getElementById('editOrderModal').classList.remove('is-open');
|
|
selectedEditCustomer = null;
|
|
selectedEditVehicle = null;
|
|
}
|
|
|
|
function toDatetimeLocal(d) {
|
|
if (!d) return '';
|
|
var date = new Date(d);
|
|
date.setMinutes(date.getMinutes() - date.getTimezoneOffset());
|
|
return date.toISOString().slice(0, 16);
|
|
}
|
|
|
|
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');
|
|
branchSel.innerHTML = branches.map(function(b) {
|
|
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('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 : '';
|
|
document.getElementById('eoNotes').value = o.reception_notes || '';
|
|
}
|
|
|
|
function hideEditSearchResults(type) {
|
|
var box = document.getElementById(type === 'customer' ? 'eoCustomerResults' : 'eoVehicleResults');
|
|
if (box) box.style.display = 'none';
|
|
}
|
|
|
|
function searchCustomersForSO() {
|
|
var input = document.getElementById('eoCustomerSearch');
|
|
var box = document.getElementById('eoCustomerResults');
|
|
var q = input.value.trim();
|
|
selectedEditCustomer = null;
|
|
document.getElementById('eoCustomerId').value = '';
|
|
if (!q || q.length < 2) {
|
|
box.style.display = 'none'; box.innerHTML = '';
|
|
return;
|
|
}
|
|
clearTimeout(customerSearchTimer);
|
|
customerSearchTimer = setTimeout(function() {
|
|
fetch('/pos/api/customers?q=' + encodeURIComponent(q) + '&per_page=20', {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(c) {
|
|
return '<div class="so-search-result" style="padding:var(--space-2);cursor:pointer;border-bottom:1px solid var(--color-border);" onclick="Workshop.selectEditCustomer(' + c.id + ', \'' + escJs(c.name) + '\')">' +
|
|
'<strong>' + esc(c.name) + '</strong>' +
|
|
'<small>' + esc(c.phone || '') + ' · ' + esc(c.rfc || '') + '</small>' +
|
|
'</div>';
|
|
}).join('');
|
|
box.style.display = 'block';
|
|
})
|
|
.catch(function() { box.style.display = 'none'; });
|
|
}, 250);
|
|
}
|
|
|
|
function selectEditCustomer(id, name) {
|
|
selectedEditCustomer = {id: id, name: name};
|
|
document.getElementById('eoCustomerSearch').value = name;
|
|
document.getElementById('eoCustomerId').value = id;
|
|
hideEditSearchResults('customer');
|
|
}
|
|
|
|
function searchVehiclesForSO() {
|
|
var input = document.getElementById('eoVehicleSearch');
|
|
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);
|
|
}
|
|
|
|
function selectEditVehicle(id, label) {
|
|
selectedEditVehicle = {id: id, label: label};
|
|
document.getElementById('eoVehicleSearch').value = label;
|
|
document.getElementById('eoVehicleId').value = id;
|
|
hideEditSearchResults('vehicle');
|
|
}
|
|
|
|
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 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,
|
|
estimated_cost: document.getElementById('eoEstimatedCost').value ? parseFloat(document.getElementById('eoEstimatedCost').value) : null,
|
|
reception_notes: document.getElementById('eoNotes').value
|
|
};
|
|
try {
|
|
await api('PUT', '/' + currentOrderId, payload);
|
|
toast('Orden actualizada');
|
|
closeEditOrderModal();
|
|
closeDetailModal();
|
|
loadSummary();
|
|
loadOrders();
|
|
} catch (e) {
|
|
alert('Error: ' + e.message);
|
|
}
|
|
}
|
|
|
|
function openNewVehicleModal(context) {
|
|
newVehicleContext = context || 'edit';
|
|
document.getElementById('newVehicleModal').classList.add('is-open');
|
|
document.getElementById('nvPlate').value = '';
|
|
document.getElementById('nvVIN').value = '';
|
|
document.getElementById('nvMake').value = '';
|
|
document.getElementById('nvModel').value = '';
|
|
document.getElementById('nvYear').value = '';
|
|
document.getElementById('nvColor').value = '';
|
|
document.getElementById('nvOwner').value = '';
|
|
}
|
|
|
|
function openNewVehicleModalFromNewOrder() {
|
|
openNewVehicleModal('new');
|
|
}
|
|
|
|
function closeNewVehicleModal() {
|
|
document.getElementById('newVehicleModal').classList.remove('is-open');
|
|
}
|
|
|
|
async function saveNewVehicle() {
|
|
var plate = document.getElementById('nvPlate').value.trim();
|
|
var vin = document.getElementById('nvVIN').value.trim();
|
|
if (!plate && !vin) {
|
|
alert('Se requiere al menos placa o VIN');
|
|
return;
|
|
}
|
|
var payload = {
|
|
plate: plate,
|
|
vin: vin,
|
|
make: document.getElementById('nvMake').value.trim() || null,
|
|
model: document.getElementById('nvModel').value.trim() || null,
|
|
year: document.getElementById('nvYear').value ? parseInt(document.getElementById('nvYear').value, 10) : null,
|
|
color: document.getElementById('nvColor').value.trim() || null,
|
|
owner_name: document.getElementById('nvOwner').value.trim() || null,
|
|
branch_id: (currentOrder ? currentOrder.branch_id : null) || (window.POS_USER ? window.POS_USER.branch_id : null)
|
|
};
|
|
try {
|
|
var res = await api('POST', '/vehicles', payload);
|
|
var label = plate + (payload.make ? ' · ' + payload.make : '') + (payload.model ? ' ' + payload.model : '');
|
|
if (newVehicleContext === 'new') {
|
|
var sel = document.getElementById('noVehicle');
|
|
var opt = document.createElement('option');
|
|
opt.value = res.id;
|
|
opt.textContent = label.trim();
|
|
sel.appendChild(opt);
|
|
sel.value = res.id;
|
|
// also add to cached vehicles array if present
|
|
if (typeof vehicles !== 'undefined') {
|
|
vehicles.push({id: res.id, plate: plate, vin: vin, make: payload.make, model: payload.model, year: payload.year, color: payload.color, owner_name: payload.owner_name});
|
|
}
|
|
} else {
|
|
selectedEditVehicle = {id: res.id, label: label.trim()};
|
|
document.getElementById('eoVehicleSearch').value = selectedEditVehicle.label;
|
|
document.getElementById('eoVehicleId').value = res.id;
|
|
}
|
|
toast('Vehículo creado');
|
|
closeNewVehicleModal();
|
|
} catch (e) {
|
|
alert('Error: ' + e.message);
|
|
}
|
|
}
|
|
|
|
// ─── Actions ───
|
|
|
|
function changeStatus() {
|
|
if (!currentOrderId) return;
|
|
var newStatus = document.getElementById('statusSelect').value;
|
|
if (currentOrder && newStatus === currentOrder.status) {
|
|
alert('Selecciona un estado diferente al actual');
|
|
return;
|
|
}
|
|
api('PUT', '/' + currentOrderId + '/status', {status: newStatus})
|
|
.then(function() {
|
|
closeDetailModal();
|
|
loadSummary();
|
|
loadOrders();
|
|
})
|
|
.catch(function(e) { alert('Error: ' + e.message); });
|
|
}
|
|
|
|
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
|
|
};
|
|
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;
|
|
}
|
|
})
|
|
.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 hideItemSearchResults() {
|
|
var box = document.getElementById('itemSearchResults');
|
|
if (box) box.style.display = 'none';
|
|
}
|
|
|
|
function searchItemsForSO() {
|
|
var input = document.getElementById('newItemSearch');
|
|
var box = document.getElementById('itemSearchResults');
|
|
var q = input.value.trim();
|
|
selectedInventoryItem = null;
|
|
if (!q || q.length < 2) {
|
|
box.style.display = 'none';
|
|
box.innerHTML = '';
|
|
return;
|
|
}
|
|
clearTimeout(itemSearchTimer);
|
|
itemSearchTimer = setTimeout(function() {
|
|
var branchId = currentOrder && currentOrder.branch_id ? currentOrder.branch_id : '';
|
|
fetch('/pos/api/service-orders/inventory-search?q=' + encodeURIComponent(q) + '&branch_id=' + encodeURIComponent(branchId), {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(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 + ')">' +
|
|
'<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>' +
|
|
'</div>';
|
|
}).join('');
|
|
box.style.display = 'block';
|
|
})
|
|
.catch(function() {
|
|
box.style.display = 'none';
|
|
});
|
|
}, 250);
|
|
}
|
|
|
|
function selectInventoryItem(id) {
|
|
var box = document.getElementById('itemSearchResults');
|
|
var div = box.querySelector('[data-id="' + id + '"]');
|
|
if (!div) return;
|
|
selectedInventoryItem = {
|
|
id: id,
|
|
name: div.dataset.name || '',
|
|
part_number: div.dataset.part || '',
|
|
unit_price: parseFloat(div.dataset.price) || 0,
|
|
unit_cost: parseFloat(div.dataset.cost) || 0
|
|
};
|
|
document.getElementById('newItemSearch').value = selectedInventoryItem.name;
|
|
box.style.display = 'none';
|
|
}
|
|
|
|
function addSelectedItem() {
|
|
if (!currentOrderId) return;
|
|
var qty = parseInt(document.getElementById('newItemQty').value, 10) || 1;
|
|
if (selectedInventoryItem) {
|
|
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'
|
|
}).then(function() {
|
|
selectedInventoryItem = null;
|
|
document.getElementById('newItemSearch').value = '';
|
|
document.getElementById('newItemQty').value = '1';
|
|
openDetail(currentOrderId);
|
|
}).catch(function(e) { alert('Error: ' + e.message); });
|
|
return;
|
|
}
|
|
// 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;
|
|
api('POST', '/' + currentOrderId + '/items', {
|
|
name: name,
|
|
quantity: qty,
|
|
unit_price: 0,
|
|
status: 'pending'
|
|
}).then(function() {
|
|
document.getElementById('newItemSearch').value = '';
|
|
document.getElementById('newItemQty').value = '1';
|
|
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 = hidePrices ? 0 : 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 = '';
|
|
if (!hidePrices) 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 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;
|
|
api('DELETE', '/' + currentOrderId, {})
|
|
.then(function() {
|
|
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}; });
|
|
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');
|
|
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,
|
|
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();
|
|
}).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="' + (hidePrices ? 3 : 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>' +
|
|
(hidePrices ? '' : '<td class="price-col">' + fmtMoney(c.suggested_rate) + '</td>' +
|
|
'<td class="price-col">' + 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 = []; });
|
|
// 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' || id === 'noCourier' ? '' : '<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,
|
|
setView: setView,
|
|
goPage: goPage,
|
|
openDetail: openDetail,
|
|
closeDetailModal: closeDetailModal,
|
|
switchTab: switchTab,
|
|
changeStatus: changeStatus,
|
|
reserveItem: reserveItem,
|
|
saveNotes: saveNotes,
|
|
searchItemsForSO: searchItemsForSO,
|
|
selectInventoryItem: selectInventoryItem,
|
|
addSelectedItem: addSelectedItem,
|
|
addLabor: addLabor,
|
|
convertToSale: convertToSale,
|
|
deleteOrder: deleteOrder,
|
|
printOrder: printOrder,
|
|
openNewOrderModal: openNewOrderModal,
|
|
closeNewOrderModal: closeNewOrderModal,
|
|
submitNewOrder: submitNewOrder,
|
|
openCatalogModal: openCatalogModal,
|
|
closeCatalogModal: closeCatalogModal,
|
|
addCatalogItem: addCatalogItem,
|
|
deleteCatalogItem: deleteCatalogItem,
|
|
openEditOrderModal: openEditOrderModal,
|
|
closeEditOrderModal: closeEditOrderModal,
|
|
saveOrderChanges: saveOrderChanges,
|
|
selectEditCustomer: selectEditCustomer,
|
|
selectEditVehicle: selectEditVehicle,
|
|
searchCustomersForSO: searchCustomersForSO,
|
|
searchVehiclesForSO: searchVehiclesForSO,
|
|
openNewVehicleModal: openNewVehicleModal,
|
|
openNewVehicleModalFromNewOrder: openNewVehicleModalFromNewOrder,
|
|
closeNewVehicleModal: closeNewVehicleModal,
|
|
saveNewVehicle: saveNewVehicle,
|
|
};
|
|
})();
|
|
|
|
document.addEventListener('DOMContentLoaded', Workshop.init);
|