feat(inventory): edit items in inventory grid
- 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:
@@ -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'])
|
||||
|
||||
@@ -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' : '') + '> ' + 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>';
|
||||
|
||||
@@ -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()">×</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>
|
||||
|
||||
Reference in New Issue
Block a user