diff --git a/pos/blueprints/service_order_bp.py b/pos/blueprints/service_order_bp.py index df7687f..85dc91d 100644 --- a/pos/blueprints/service_order_bp.py +++ b/pos/blueprints/service_order_bp.py @@ -356,6 +356,60 @@ def assign_mechanic_endpoint(so_id): conn.close() +@service_order_bp.route('/inventory-search', methods=['GET']) +@require_auth('workshop.view') +def inventory_search(): + """Search active inventory for attaching parts to a service order. + + Does not require inventory.view so workshop users can pick parts. + """ + q = request.args.get('q', '').strip() + if not q: + return jsonify({'data': []}) + + branch_id = request.args.get('branch_id', g.branch_id) + conn = get_tenant_conn(g.tenant_id) + try: + cur = conn.cursor() + like = f'%{q}%' + if branch_id: + cur.execute(""" + SELECT i.id, i.part_number, i.name, i.brand, i.unit, + COALESCE((SELECT stock FROM inventory_stock + WHERE inventory_id = i.id AND branch_id = %s), 0) AS stock, + i.cost, i.price_1 + FROM inventory i + WHERE i.is_active = true + AND (i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s) + ORDER BY i.name + LIMIT 20 + """, (branch_id, like, like, like)) + else: + cur.execute(""" + SELECT i.id, i.part_number, i.name, i.brand, i.unit, + COALESCE((SELECT stock FROM inventory_stock_summary + WHERE inventory_id = i.id), 0) AS stock, + i.cost, i.price_1 + FROM inventory i + WHERE i.is_active = true + AND (i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s) + ORDER BY i.name + LIMIT 20 + """, (like, like, like)) + items = [] + for r in cur.fetchall(): + items.append({ + 'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3], 'unit': r[4], + 'stock': float(r[5]) if r[5] else 0, + 'cost': float(r[6]) if r[6] else 0, + 'price_1': float(r[7]) if r[7] else 0, + }) + cur.close() + return jsonify({'data': items}) + finally: + conn.close() + + # ─── Service catalog (reusable labor) ───────────── diff --git a/pos/services/service_order_engine.py b/pos/services/service_order_engine.py index 5c96389..ffb55e8 100644 --- a/pos/services/service_order_engine.py +++ b/pos/services/service_order_engine.py @@ -13,7 +13,7 @@ VALID_TRANSITIONS = { 'diagnosis': ['waiting_parts', 'repair', 'cancelled'], 'waiting_parts': ['repair', 'cancelled'], 'repair': ['quality_check', 'cancelled'], - 'quality_check': ['ready', 'repair', 'cancelled'], + 'quality_check': ['ready', 'cancelled'], 'ready': ['delivered', 'cancelled'], 'delivered': [], 'cancelled': [], diff --git a/pos/static/js/workshop.js b/pos/static/js/workshop.js index 55a0816..4da383a 100644 --- a/pos/static/js/workshop.js +++ b/pos/static/js/workshop.js @@ -16,6 +16,8 @@ var Workshop = (function() { var branches = []; var currentOrderId = null; var currentOrder = null; + var selectedInventoryItem = null; + var itemSearchTimer = null; var currentView = 'list'; var currentPage = 1; var perPage = 25; @@ -48,6 +50,17 @@ var Workshop = (function() { cancelled: 'Cancelado' }; + var VALID_NEXT = { + received: ['diagnosis', 'cancelled'], + diagnosis: ['waiting_parts', 'repair', 'cancelled'], + waiting_parts: ['repair', 'cancelled'], + repair: ['quality_check', 'cancelled'], + quality_check: ['ready', 'cancelled'], + ready: ['delivered', 'cancelled'], + delivered: [], + cancelled: [] + }; + var DELIVERY_LABELS = { pickup: 'Pasa cliente', delivery: 'Envío a domicilio', @@ -362,8 +375,18 @@ var Workshop = (function() { '
' + '
' + - '

Observaciones

' + - '

' + esc(o.reception_notes || 'Sin observaciones') + '

' + + '

Notas

' + + '
' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + '
' + + (canEdit ? ' ' : '') + '
' + '
' + '

Bitácora

' + @@ -379,10 +402,12 @@ var Workshop = (function() { // Footer actions var footer = document.getElementById('detailFooter'); - var statusHtml = canEdit ? + var allowedNext = VALID_NEXT[o.status] || []; + var statusHtml = canEdit && allowedNext.length ? '
' + ' ' + ' ' + '
' : ''; @@ -442,9 +467,13 @@ var Workshop = (function() { var laborHeader = 'ConceptoHoras' + (hidePrices ? '' : 'Precio/hrTotal') + 'Estado'; var addParts = canEdit ? - '
' + - ' ' + - ' ' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + ' ' + + ' ' + '
' : ''; var addLabor = canEdit ? @@ -502,6 +531,10 @@ var Workshop = (function() { 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(); @@ -511,6 +544,27 @@ var Workshop = (function() { .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() { @@ -521,15 +575,94 @@ var Workshop = (function() { .catch(function(e) { alert('Error: ' + e.message); }); } - function addItemPlaceholder() { + 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 = '
Sin resultados
'; + box.style.display = 'block'; + return; + } + box.innerHTML = items.map(function(it) { + return '
' + + '
' + esc(it.name) + '
' + + '' + esc(it.part_number || '') + ' · ' + esc(it.brand || '') + ' · Stock: ' + fmt(it.stock) + (hidePrices ? '' : ' · ' + fmtMoney(it.price_1)) + '' + + '
'; + }).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: 1, + 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); }); } @@ -768,7 +901,10 @@ var Workshop = (function() { switchTab: switchTab, changeStatus: changeStatus, reserveItem: reserveItem, - addItemPlaceholder: addItemPlaceholder, + saveNotes: saveNotes, + searchItemsForSO: searchItemsForSO, + selectInventoryItem: selectInventoryItem, + addSelectedItem: addSelectedItem, addLabor: addLabor, convertToSale: convertToSale, printOrder: printOrder, diff --git a/pos/templates/workshop.html b/pos/templates/workshop.html index 3f208a8..3234038 100644 --- a/pos/templates/workshop.html +++ b/pos/templates/workshop.html @@ -15,7 +15,12 @@ - + + @@ -295,7 +300,7 @@ - +