feat(workshop): restrict status flow, add mechanic notes, inventory part picker
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- 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:
2026-06-30 17:43:19 +00:00
parent bd5f733d6f
commit 2863cf8e9f
4 changed files with 208 additions and 13 deletions

View File

@@ -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) ─────────────

View File

@@ -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': [],

View File

@@ -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,

View File

@@ -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>