feat(inventory): edit items in inventory grid
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- Added 'Editar' button in inventory rows.

- Reused create modal as create/edit with pre-filled data.

- Added fields: unit, max_stock, tax_rate, description, is_active.

- Added /inventory/categories/all endpoint for flat category selector.

- Price/cost inputs are disabled for users without config.edit_prices.
This commit is contained in:
2026-06-30 19:43:03 +00:00
parent b6679c6d9c
commit 603046cb09
3 changed files with 131 additions and 59 deletions

View File

@@ -2271,6 +2271,22 @@ def list_inventory_subcategories(category_id):
conn.close()
@inventory_bp.route('/categories/all', methods=['GET'])
@require_auth()
def list_all_inventory_categories():
"""Return all active categories (flat, with parent_id) for selectors."""
conn = get_tenant_conn(g.tenant_id)
try:
cur = conn.cursor()
cur.execute(
"SELECT id, name, parent_id FROM categories WHERE is_active = true ORDER BY name"
)
rows = cur.fetchall()
return jsonify({'categories': [{'id': r[0], 'name': r[1], 'parent_id': r[2]} for r in rows]})
finally:
conn.close()
# ─── Global Tier Discounts ───────────────────────
@inventory_bp.route('/tier-discounts', methods=['GET'])

View File

@@ -17,6 +17,11 @@
var compatSource = 'both'; // default, loaded from config
var inventorySearchController = null;
var user = window.POS_USER || {};
var userRole = (user.role || '').toLowerCase();
var userPerms = user.permissions || [];
var canEditPrices = userRole === 'owner' || userRole === 'admin' || userPerms.indexOf('config.edit_prices') !== -1;
// Load compatibility source setting
(function loadCompatSource() {
fetch('/pos/api/config/vehicle-compat-source', { headers: { 'Authorization': 'Bearer ' + token } })
@@ -181,6 +186,7 @@
'<td>' + esc(it.location) + '</td>' +
'<td>' +
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();viewHistory(' + it.id + ')">Historial</button> ' +
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();showEditItemModal(' + it.id + ')">Editar</button> ' +
'<button class="btn btn--ghost btn--sm" style="color:var(--color-accent);" onclick="event.stopPropagation();showPurchaseModalForItem(' + it.id + ')">Entrada</button> ' +
'<button class="btn btn--sm btn--meli" onclick="event.stopPropagation();publishToMeli(' + it.id + ')">ML</button> ' +
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();printBarcode(\'' + esc(it.barcode) + '\',\'' + esc(it.part_number) + '\',\'' + esc(it.name) + '\')">Etiqueta</button> ' +
@@ -307,39 +313,49 @@
// CREATE ITEM (createModal)
// =====================================================================
function loadCategories() {
function loadCategories(selectedId) {
var sel = document.getElementById('newCategory');
if (!sel) return;
apiFetch(API + '/categories').then(function(data) {
apiFetch(API + '/categories/all').then(function(data) {
if (!data || !data.categories) return;
sel.innerHTML = '<option value="">Selecciona categoría</option>';
data.categories.forEach(function(c) {
sel.innerHTML += '<option value="' + c.id + '">' + esc(c.name) + '</option>';
var cats = data.categories;
var top = cats.filter(function(c) { return !c.parent_id; });
var subs = cats.filter(function(c) { return c.parent_id; });
sel.innerHTML = '<option value="">Sin categoría</option>';
top.forEach(function(c) {
sel.innerHTML += '<optgroup label="' + esc(c.name) + '">' +
'<option value="' + c.id + '"' + (c.id === selectedId ? ' selected' : '') + '>' + esc(c.name) + '</option>';
subs.filter(function(s) { return s.parent_id === c.id; }).forEach(function(s) {
sel.innerHTML += '<option value="' + s.id + '"' + (s.id === selectedId ? ' selected' : '') + '>&nbsp;&nbsp;' + esc(s.name) + '</option>';
});
sel.innerHTML += '</optgroup>';
});
});
}
window.loadCategories = loadCategories;
function onCategoryChange(categoryId) {
var subSel = document.getElementById('newSubcategory');
if (!subSel) return;
if (!categoryId) {
subSel.innerHTML = '<option value="">Selecciona categoría primero</option>';
subSel.disabled = true;
return;
}
apiFetch(API + '/categories/' + categoryId + '/subcategories').then(function(data) {
if (!data || !data.subcategories) return;
subSel.innerHTML = '<option value="">Selecciona subcategoría</option>';
data.subcategories.forEach(function(s) {
subSel.innerHTML += '<option value="' + s.id + '">' + esc(s.name) + '</option>';
});
subSel.disabled = false;
});
// Kept for backwards compatibility; selector is now flat.
}
window.onCategoryChange = onCategoryChange;
function resetCreateModal() {
document.getElementById('editItemId').value = '';
document.getElementById('createModalTitle').textContent = 'Nuevo Producto';
document.getElementById('createModalBtn').textContent = 'Crear Producto';
var ids = ['newPartNumber','newName','newBrand','newBarcode','newSku2','newSku3','newUnit','newCost','newPrice1','newMinStock','newInitialStock','newMaxStock','newTaxRate','newLocation','newDescription'];
ids.forEach(function(id) {
var el = document.getElementById(id);
if (el) el.value = '';
});
document.getElementById('newCategory').innerHTML = '<option value="">Sin categoría</option>';
document.getElementById('newIsActive').value = 'true';
document.getElementById('initialStockField').style.display = '';
document.querySelectorAll('#createModal .price-field input').forEach(function(el) { el.disabled = false; });
}
function showCreateModal() {
resetCreateModal();
document.getElementById('createModal').classList.add('is-open');
loadCategories();
// Attach AI classification on part number blur
@@ -357,6 +373,41 @@
}
}
function showEditItemModal(itemId) {
resetCreateModal();
document.getElementById('editItemId').value = itemId;
document.getElementById('createModalTitle').textContent = 'Editar Producto';
document.getElementById('createModalBtn').textContent = 'Guardar Cambios';
document.getElementById('initialStockField').style.display = 'none';
document.getElementById('createModal').classList.add('is-open');
apiFetch(API + '/items/' + itemId).then(function(it) {
if (!it) return;
document.getElementById('newPartNumber').value = it.part_number || '';
document.getElementById('newName').value = it.name || '';
document.getElementById('newBrand').value = it.brand || '';
document.getElementById('newBarcode').value = it.barcode || '';
document.getElementById('newUnit').value = it.unit || '';
document.getElementById('newCost').value = it.cost != null ? it.cost : '';
document.getElementById('newPrice1').value = it.price_1 != null ? it.price_1 : '';
document.getElementById('newMinStock').value = it.min_stock != null ? it.min_stock : '';
document.getElementById('newMaxStock').value = it.max_stock != null ? it.max_stock : '';
document.getElementById('newTaxRate').value = it.tax_rate != null ? it.tax_rate : '';
document.getElementById('newLocation').value = it.location || '';
document.getElementById('newDescription').value = it.description || '';
document.getElementById('newIsActive').value = (it.is_active === false ? 'false' : 'true');
document.getElementById('newSku2').value = (it.sku_aliases && it.sku_aliases[0]) ? it.sku_aliases[0].sku : '';
document.getElementById('newSku3').value = (it.sku_aliases && it.sku_aliases[1]) ? it.sku_aliases[1].sku : '';
if (!canEditPrices) {
document.querySelectorAll('#createModal .price-field input').forEach(function(el) { el.disabled = true; });
}
loadCategories(it.category_id);
}).catch(function(e) {
alert('Error al cargar producto: ' + e.message);
closeCreateModal();
});
}
window.showEditItemModal = showEditItemModal;
function classifyPartNumber(partNumber) {
var resultEl = document.getElementById('createResult');
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Consultando IA...</span>';
@@ -387,58 +438,57 @@
function closeCreateModal() {
document.getElementById('createModal').classList.remove('is-open');
document.getElementById('createResult').innerHTML = '';
var catSel = document.getElementById('newCategory');
var subSel = document.getElementById('newSubcategory');
if (catSel) catSel.innerHTML = '<option value="">Selecciona categoría</option>';
if (subSel) { subSel.innerHTML = '<option value="">Selecciona categoría primero</option>'; subSel.disabled = true; }
resetCreateModal();
}
function createItem() {
var elPrice2 = document.getElementById('newPrice2');
var elPrice3 = document.getElementById('newPrice3');
var editId = document.getElementById('editItemId').value;
var data = {
part_number: document.getElementById('newPartNumber').value.trim(),
name: document.getElementById('newName').value.trim(),
brand: document.getElementById('newBrand').value.trim(),
barcode: document.getElementById('newBarcode').value.trim() || undefined,
unit: document.getElementById('newUnit').value.trim() || undefined,
cost: parseFloat(document.getElementById('newCost').value) || 0,
price_1: parseFloat(document.getElementById('newPrice1').value) || 0,
price_2: elPrice2 ? (parseFloat(elPrice2.value) || 0) : 0,
price_3: elPrice3 ? (parseFloat(elPrice3.value) || 0) : 0,
min_stock: parseInt(document.getElementById('newMinStock').value) || 0,
initial_stock: parseInt(document.getElementById('newInitialStock').value) || 0,
max_stock: parseInt(document.getElementById('newMaxStock').value) || 0,
tax_rate: parseFloat(document.getElementById('newTaxRate').value) || 0,
location: document.getElementById('newLocation').value.trim(),
description: document.getElementById('newDescription').value.trim(),
is_active: document.getElementById('newIsActive').value === 'true',
sku_aliases: []
};
var sku2 = document.getElementById('newSku2').value.trim();
var sku3 = document.getElementById('newSku3').value.trim();
var categoryId = document.getElementById('newCategory').value;
var subcategoryId = document.getElementById('newSubcategory').value;
if (categoryId) data.category_id = parseInt(categoryId);
if (sku2) data.sku_aliases.push({sku: sku2, label: 'Alternativo 1'});
if (sku3) data.sku_aliases.push({sku: sku3, label: 'Alternativo 2'});
if (subcategoryId) {
data.category_id = parseInt(subcategoryId);
} else if (categoryId) {
data.category_id = parseInt(categoryId);
}
if (!data.part_number || !data.name) {
document.getElementById('createResult').innerHTML = '<span style="color:var(--color-error);">Numero de parte y nombre son obligatorios</span>';
return;
}
apiFetch(API + '/items', { method: 'POST', body: JSON.stringify(data) }).then(function (result) {
if (result && result.id) {
var msg = 'Creado ID ' + result.id + ' | Barcode: ' + result.barcode;
if (result.vehicle_compatibilities_added > 0) {
var url = API + '/items';
var method = 'POST';
if (editId) {
url = API + '/items/' + editId;
method = 'PUT';
// Stock inicial solo en creación
} else {
data.initial_stock = parseInt(document.getElementById('newInitialStock').value) || 0;
}
apiFetch(url, { method: method, body: JSON.stringify(data) }).then(function (result) {
if (result && (result.id || result.message)) {
var msg = editId ? 'Producto actualizado' : ('Creado ID ' + result.id + ' | Barcode: ' + result.barcode);
if (!editId && result.vehicle_compatibilities_added > 0) {
msg += ' | ' + result.vehicle_compatibilities_added + ' vehiculo(s) asignado(s) por IA';
}
document.getElementById('createResult').innerHTML = '<span style="color:var(--color-success);">' + msg + '</span>';
loadItems(currentPage);
// Close modal, clear form, refresh badges
closeCreateModal();
['newPartNumber','newName','newBrand','newBarcode','newSku2','newSku3','newCost','newPrice1','newMinStock','newInitialStock','newLocation'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.value = '';
});
if (window.loadInventoryStats) window.loadInventoryStats();
} else {
document.getElementById('createResult').innerHTML = '<span style="color:var(--color-error);">' + (result ? result.error || 'Error' : 'Error de red') + '</span>';

View File

@@ -15,7 +15,7 @@
<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/inventory.css?v=33"></head>
<link rel="stylesheet" href="/pos/static/css/inventory.css?v=34"></head>
<body>
@@ -710,42 +710,48 @@
<!-- ===== MODALS ===== -->
<!-- Create Item Modal -->
<!-- Create/Edit Item Modal -->
<div class="inv-modal-overlay" id="createModal">
<div class="inv-modal">
<div class="inv-modal__header">
<h3>Nuevo Producto</h3>
<h3 id="createModalTitle">Nuevo Producto</h3>
<button class="inv-modal__close" onclick="closeCreateModal()">&times;</button>
</div>
<div class="inv-modal__body">
<input type="hidden" id="editItemId" />
<div class="inv-form-grid">
<div class="inv-field"><label>No. Parte *</label><input type="text" id="newPartNumber" placeholder="Ej: GAT-50104" /></div>
<div class="inv-field"><label>Nombre *</label><input type="text" id="newName" placeholder="Nombre del producto" /></div>
<div class="inv-field"><label>Marca</label><input type="text" id="newBrand" placeholder="Marca del fabricante" /></div>
<div class="inv-field"><label>Categoría</label>
<select class="select-filter" id="newCategory" onchange="onCategoryChange(this.value)" style="width:100%;">
<option value="">Selecciona categoría</option>
</select>
</div>
<div class="inv-field"><label>Subcategoría</label>
<select class="select-filter" id="newSubcategory" style="width:100%;" disabled>
<option value="">Selecciona categoría primero</option>
<select class="select-filter" id="newCategory" style="width:100%;">
<option value="">Sin categoría</option>
</select>
</div>
<div class="inv-field"><label>Barcode</label><input type="text" id="newBarcode" placeholder="Auto-generado si vacío" /></div>
<div class="inv-field"><label>SKU Alternativo 1</label><input type="text" id="newSku2" placeholder="Ej: SKU-Bodega-A" /></div>
<div class="inv-field"><label>SKU Alternativo 2</label><input type="text" id="newSku3" placeholder="Ej: SKU-Bodega-B" /></div>
<div class="inv-field"><label>Costo</label><input type="number" id="newCost" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Precio Mostrador</label><input type="number" id="newPrice1" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Unidad</label><input type="text" id="newUnit" placeholder="pza, kit, lt..." /></div>
<div class="inv-field price-field"><label>Costo</label><input type="number" id="newCost" step="0.01" placeholder="0.00" /></div>
<div class="inv-field price-field"><label>Precio Mostrador</label><input type="number" id="newPrice1" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Stock Mínimo</label><input type="number" id="newMinStock" placeholder="0" /></div>
<div class="inv-field"><label>Stock Inicial</label><input type="number" id="newInitialStock" placeholder="0" /></div>
<div class="inv-field" id="initialStockField"><label>Stock Inicial</label><input type="number" id="newInitialStock" placeholder="0" /></div>
<div class="inv-field"><label>Stock Máximo</label><input type="number" id="newMaxStock" placeholder="0" /></div>
<div class="inv-field"><label>Impuesto (%)</label><input type="number" id="newTaxRate" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Ubicación</label><input type="text" id="newLocation" placeholder="Ej: A-12-3" /></div>
<div class="inv-field"><label>Estado</label>
<select class="select-filter" id="newIsActive" style="width:100%;">
<option value="true">Activo</option>
<option value="false">Inactivo</option>
</select>
</div>
<div class="inv-field inv-field--full"><label>Descripción</label><textarea id="newDescription" rows="2" placeholder="Descripción del producto"></textarea></div>
</div>
<div id="createResult" style="margin-top:var(--space-3);min-height:1.5em;"></div>
</div>
<div class="inv-modal__footer">
<button class="btn btn--ghost" onclick="closeCreateModal()">Cancelar</button>
<button class="btn btn--primary" onclick="createItem()">Crear Producto</button>
<button class="btn btn--primary" id="createModalBtn" onclick="createItem()">Crear Producto</button>
</div>
</div>
</div>
@@ -1058,7 +1064,7 @@
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
<script src="/pos/static/js/virtual-scroll.js?v=33" defer></script>
<script src="/pos/static/js/inventory.js?v=33" defer></script>
<script src="/pos/static/js/inventory.js?v=34" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>