feat(workshop): restrict status flow, add mechanic notes, inventory part picker
- Service orders can no longer move backwards through the workshop pipeline. - Added editable Reception / Diagnosis / Repair / Delivery notes in the detail modal. - Added inventory-aware autocomplete when attaching parts to a service order.
This commit is contained in:
@@ -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) ─────────────
|
||||
|
||||
|
||||
|
||||
@@ -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': [],
|
||||
|
||||
@@ -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() {
|
||||
|
||||
'<div class="so-tab-panel" id="tab-service" ' + (selectedTab === 'service' ? '' : 'style="display:none;"') + '>' +
|
||||
' <div class="so-detail__section">' +
|
||||
' <h3>Observaciones</h3>' +
|
||||
' <p>' + esc(o.reception_notes || 'Sin observaciones') + '</p>' +
|
||||
' <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>' +
|
||||
@@ -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 ?
|
||||
'<div class="so-detail__actions" style="margin-right:auto;">' +
|
||||
' <select class="form-input" id="statusSelect" style="width:auto;">' +
|
||||
COLUMNS.map(function(c) { return '<option value="' + c.key + '"' + (c.key === o.status ? ' selected' : '') + '>' + c.label + '</option>'; }).join('') +
|
||||
'<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>' : '';
|
||||
@@ -442,9 +467,13 @@ var Workshop = (function() {
|
||||
var laborHeader = '<tr><th>Concepto</th><th>Horas</th>' + (hidePrices ? '' : '<th>Precio/hr</th><th>Total</th>') + '<th>Estado</th></tr>';
|
||||
|
||||
var addParts = canEdit ?
|
||||
'<div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);">' +
|
||||
' <input class="form-input" id="newItemSearch" placeholder="Buscar refacción por nombre/número" style="flex:1;" />' +
|
||||
' <button class="btn btn--secondary" onclick="Workshop.addItemPlaceholder()">Agregar</button>' +
|
||||
'<div 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 ?
|
||||
@@ -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 = '<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: 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,
|
||||
|
||||
@@ -15,7 +15,12 @@
|
||||
<meta name="theme-color" content="#F5A623" />
|
||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
||||
|
||||
<link rel="stylesheet" href="/pos/static/css/workshop.css?v=33">
|
||||
<link rel="stylesheet" href="/pos/static/css/workshop.css?v=34">
|
||||
<style>
|
||||
.so-notes-grid { display: grid; grid-template-columns: 120px 1fr; gap: var(--space-2); align-items: start; }
|
||||
.so-notes-grid .form-label { margin: 0; padding-top: var(--space-2); }
|
||||
.so-search-result:hover { background: var(--color-bg-secondary); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -295,7 +300,7 @@
|
||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
||||
<script src="/pos/static/js/workshop.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/workshop.js?v=34" defer></script>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||
<script src="/pos/static/js/chat.js" defer></script>
|
||||
|
||||
Reference in New Issue
Block a user