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()
|
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) ─────────────
|
# ─── Service catalog (reusable labor) ─────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ VALID_TRANSITIONS = {
|
|||||||
'diagnosis': ['waiting_parts', 'repair', 'cancelled'],
|
'diagnosis': ['waiting_parts', 'repair', 'cancelled'],
|
||||||
'waiting_parts': ['repair', 'cancelled'],
|
'waiting_parts': ['repair', 'cancelled'],
|
||||||
'repair': ['quality_check', 'cancelled'],
|
'repair': ['quality_check', 'cancelled'],
|
||||||
'quality_check': ['ready', 'repair', 'cancelled'],
|
'quality_check': ['ready', 'cancelled'],
|
||||||
'ready': ['delivered', 'cancelled'],
|
'ready': ['delivered', 'cancelled'],
|
||||||
'delivered': [],
|
'delivered': [],
|
||||||
'cancelled': [],
|
'cancelled': [],
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ var Workshop = (function() {
|
|||||||
var branches = [];
|
var branches = [];
|
||||||
var currentOrderId = null;
|
var currentOrderId = null;
|
||||||
var currentOrder = null;
|
var currentOrder = null;
|
||||||
|
var selectedInventoryItem = null;
|
||||||
|
var itemSearchTimer = null;
|
||||||
var currentView = 'list';
|
var currentView = 'list';
|
||||||
var currentPage = 1;
|
var currentPage = 1;
|
||||||
var perPage = 25;
|
var perPage = 25;
|
||||||
@@ -48,6 +50,17 @@ var Workshop = (function() {
|
|||||||
cancelled: 'Cancelado'
|
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 = {
|
var DELIVERY_LABELS = {
|
||||||
pickup: 'Pasa cliente',
|
pickup: 'Pasa cliente',
|
||||||
delivery: 'Envío a domicilio',
|
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-tab-panel" id="tab-service" ' + (selectedTab === 'service' ? '' : 'style="display:none;"') + '>' +
|
||||||
' <div class="so-detail__section">' +
|
' <div class="so-detail__section">' +
|
||||||
' <h3>Observaciones</h3>' +
|
' <h3>Notas</h3>' +
|
||||||
' <p>' + esc(o.reception_notes || 'Sin observaciones') + '</p>' +
|
' <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>' +
|
||||||
' <div class="so-detail__section">' +
|
' <div class="so-detail__section">' +
|
||||||
' <h3>Bitácora</h3>' +
|
' <h3>Bitácora</h3>' +
|
||||||
@@ -379,10 +402,12 @@ var Workshop = (function() {
|
|||||||
|
|
||||||
// Footer actions
|
// Footer actions
|
||||||
var footer = document.getElementById('detailFooter');
|
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;">' +
|
'<div class="so-detail__actions" style="margin-right:auto;">' +
|
||||||
' <select class="form-input" id="statusSelect" style="width: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>' +
|
' </select>' +
|
||||||
' <button class="btn btn--primary" onclick="Workshop.changeStatus()">Actualizar estado</button>' +
|
' <button class="btn btn--primary" onclick="Workshop.changeStatus()">Actualizar estado</button>' +
|
||||||
'</div>' : '';
|
'</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 laborHeader = '<tr><th>Concepto</th><th>Horas</th>' + (hidePrices ? '' : '<th>Precio/hr</th><th>Total</th>') + '<th>Estado</th></tr>';
|
||||||
|
|
||||||
var addParts = canEdit ?
|
var addParts = canEdit ?
|
||||||
'<div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);">' +
|
'<div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);align-items:flex-start;flex-wrap:wrap;">' +
|
||||||
' <input class="form-input" id="newItemSearch" placeholder="Buscar refacción por nombre/número" style="flex:1;" />' +
|
' <div style="position:relative;flex:1;min-width:200px;">' +
|
||||||
' <button class="btn btn--secondary" onclick="Workshop.addItemPlaceholder()">Agregar</button>' +
|
' <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>' : '';
|
'</div>' : '';
|
||||||
|
|
||||||
var addLabor = canEdit ?
|
var addLabor = canEdit ?
|
||||||
@@ -502,6 +531,10 @@ var Workshop = (function() {
|
|||||||
function changeStatus() {
|
function changeStatus() {
|
||||||
if (!currentOrderId) return;
|
if (!currentOrderId) return;
|
||||||
var newStatus = document.getElementById('statusSelect').value;
|
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})
|
api('PUT', '/' + currentOrderId + '/status', {status: newStatus})
|
||||||
.then(function() {
|
.then(function() {
|
||||||
closeDetailModal();
|
closeDetailModal();
|
||||||
@@ -511,6 +544,27 @@ var Workshop = (function() {
|
|||||||
.catch(function(e) { alert('Error: ' + e.message); });
|
.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) {
|
function reserveItem(itemId) {
|
||||||
api('POST', '/' + currentOrderId + '/items/' + itemId + '/reserve', {})
|
api('POST', '/' + currentOrderId + '/items/' + itemId + '/reserve', {})
|
||||||
.then(function() {
|
.then(function() {
|
||||||
@@ -521,15 +575,94 @@ var Workshop = (function() {
|
|||||||
.catch(function(e) { alert('Error: ' + e.message); });
|
.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();
|
var name = document.getElementById('newItemSearch').value.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
if (!confirm('No seleccionaste una refacción del inventario. ¿Agregar como concepto manual? No podrá reservarse.')) return;
|
||||||
api('POST', '/' + currentOrderId + '/items', {
|
api('POST', '/' + currentOrderId + '/items', {
|
||||||
name: name,
|
name: name,
|
||||||
quantity: 1,
|
quantity: qty,
|
||||||
unit_price: 0,
|
unit_price: 0,
|
||||||
status: 'pending'
|
status: 'pending'
|
||||||
}).then(function() {
|
}).then(function() {
|
||||||
|
document.getElementById('newItemSearch').value = '';
|
||||||
|
document.getElementById('newItemQty').value = '1';
|
||||||
openDetail(currentOrderId);
|
openDetail(currentOrderId);
|
||||||
}).catch(function(e) { alert('Error: ' + e.message); });
|
}).catch(function(e) { alert('Error: ' + e.message); });
|
||||||
}
|
}
|
||||||
@@ -768,7 +901,10 @@ var Workshop = (function() {
|
|||||||
switchTab: switchTab,
|
switchTab: switchTab,
|
||||||
changeStatus: changeStatus,
|
changeStatus: changeStatus,
|
||||||
reserveItem: reserveItem,
|
reserveItem: reserveItem,
|
||||||
addItemPlaceholder: addItemPlaceholder,
|
saveNotes: saveNotes,
|
||||||
|
searchItemsForSO: searchItemsForSO,
|
||||||
|
selectInventoryItem: selectInventoryItem,
|
||||||
|
addSelectedItem: addSelectedItem,
|
||||||
addLabor: addLabor,
|
addLabor: addLabor,
|
||||||
convertToSale: convertToSale,
|
convertToSale: convertToSale,
|
||||||
printOrder: printOrder,
|
printOrder: printOrder,
|
||||||
|
|||||||
@@ -15,7 +15,12 @@
|
|||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -295,7 +300,7 @@
|
|||||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
<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/sidebar.js?v=33" defer></script>
|
||||||
<script src="/pos/static/js/offline-banner.js" 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>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/pwa-install.js" defer></script>
|
||||||
<script src="/pos/static/js/chat.js" defer></script>
|
<script src="/pos/static/js/chat.js" defer></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user