Replace Log Material item dropdown with searchable combobox

Inventory lists grow over time; a plain <select> becomes unusable. The
new combobox filters as you type, supports keyboard navigation
(Arrow/Enter/Escape), and shows current stock on selection — matching
the pattern used by the powder picker in the item wizard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 21:41:14 -04:00
parent ebd474ae81
commit 6cf355071b
2 changed files with 178 additions and 54 deletions
@@ -1105,9 +1105,22 @@
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-semibold">Inventory Item <span class="text-danger">*</span></label>
<select id="lmInventoryItem" class="form-select">
<option value="">-- Select item --</option>
</select>
<div class="position-relative">
<div class="input-group">
<input type="text" class="form-control" id="lmItemSearch"
placeholder="Search by name or unit&hellip;" autocomplete="off"
oninput="lmComboInput()"
onfocus="lmComboOpen()"
onkeydown="lmComboKey(event)">
<button class="btn btn-outline-secondary" type="button" tabindex="-1"
id="lmItemDropdownToggle" onclick="lmComboToggle()">
<i class="bi bi-chevron-down" style="font-size:.75rem;"></i>
</button>
</div>
<div id="lmItemDropdown"
style="display:none;max-height:220px;overflow-y:auto;z-index:1070;background:#fff;border:1px solid rgba(0,0,0,.15);border-radius:.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);">
</div>
</div>
<div id="lmItemBalance" class="form-text text-muted d-none"></div>
</div>
<div class="mb-3">
+162 -51
View File
@@ -6,60 +6,148 @@
let _items = [];
let _modal = null;
function init() {
const cfg = window.__logMaterial;
if (!cfg) return;
// ── Combobox state ────────────────────────────────────────────────────────
let _selectedItemId = 0;
_items = cfg.inventoryItems || [];
_modal = new bootstrap.Modal(document.getElementById('logMaterialModal'));
const sel = document.getElementById('lmInventoryItem');
_items.forEach(function (item) {
const opt = document.createElement('option');
opt.value = item.id;
opt.textContent = item.name + (item.unitOfMeasure ? ' (' + item.unitOfMeasure + ')' : '');
opt.dataset.qty = item.quantityOnHand;
opt.dataset.uom = item.unitOfMeasure || '';
sel.appendChild(opt);
});
sel.addEventListener('change', lmOnItemChange);
document.getElementById('lmQuantity').addEventListener('input', lmOnQtyInput);
}
function lmOnItemChange() {
const sel = document.getElementById('lmInventoryItem');
const opt = sel.options[sel.selectedIndex];
const balDiv = document.getElementById('lmItemBalance');
if (sel.value && opt) {
const qty = parseFloat(opt.dataset.qty) || 0;
const uom = opt.dataset.uom;
balDiv.textContent = 'Current stock: ' + qty.toFixed(2) + (uom ? ' ' + uom : '');
balDiv.classList.remove('d-none');
} else {
balDiv.classList.add('d-none');
}
function lmComboInput() {
const q = document.getElementById('lmItemSearch')?.value?.toLowerCase() || '';
lmComboRender(q);
lmComboShow();
_selectedItemId = 0;
document.getElementById('lmItemBalance').classList.add('d-none');
lmOnQtyInput();
}
function lmComboOpen() {
const q = document.getElementById('lmItemSearch')?.value?.toLowerCase() || '';
lmComboRender(q);
lmComboShow();
}
function lmComboToggle() {
const dd = document.getElementById('lmItemDropdown');
if (!dd) return;
if (dd.style.display === 'none' || !dd.style.display) {
lmComboOpen();
document.getElementById('lmItemSearch')?.focus();
} else {
lmComboClose();
}
}
function lmComboRender(query) {
const dd = document.getElementById('lmItemDropdown');
if (!dd) return;
const filtered = query
? _items.filter(it => it.name.toLowerCase().includes(query) ||
(it.unitOfMeasure && it.unitOfMeasure.toLowerCase().includes(query)))
: _items;
if (filtered.length === 0) {
dd.innerHTML = '<div class="px-3 py-2 text-muted small">No items match.</div>';
return;
}
dd.innerHTML = filtered.map(it => {
const label = it.name + (it.unitOfMeasure ? ' (' + it.unitOfMeasure + ')' : '');
return `<div class="lm-item-opt" style="padding:.35rem .75rem;font-size:.875rem;cursor:pointer;"
data-id="${it.id}"
data-qty="${it.quantityOnHand}"
data-uom="${escLm(it.unitOfMeasure || '')}"
data-label="${escLm(label)}"
onmousedown="event.preventDefault(); lmComboSelect(this)"
onmouseenter="this.style.background='#f0f4ff'"
onmouseleave="this.classList.contains('lm-active') ? null : this.style.background=''">
${escLm(label)}
</div>`;
}).join('');
}
function lmComboShow() {
const dd = document.getElementById('lmItemDropdown');
const anchor = document.getElementById('lmItemSearch');
if (!dd || !anchor) return;
const rect = anchor.closest('.input-group').getBoundingClientRect();
dd.style.position = 'fixed';
dd.style.top = (rect.bottom + 2) + 'px';
dd.style.left = rect.left + 'px';
dd.style.width = rect.width + 'px';
dd.style.display = 'block';
}
function lmComboClose() {
const dd = document.getElementById('lmItemDropdown');
if (dd) dd.style.display = 'none';
}
window.lmComboSelect = function (el) {
_selectedItemId = parseInt(el.dataset.id) || 0;
document.getElementById('lmItemSearch').value = el.dataset.label;
lmComboClose();
const qty = parseFloat(el.dataset.qty) || 0;
const uom = el.dataset.uom;
const balDiv = document.getElementById('lmItemBalance');
balDiv.textContent = 'Current stock: ' + qty.toFixed(2) + (uom ? ' ' + uom : '');
balDiv.classList.remove('d-none');
lmOnQtyInput();
};
window.lmComboInput = lmComboInput;
window.lmComboOpen = lmComboOpen;
window.lmComboToggle = lmComboToggle;
window.lmComboKey = function (event) {
const dd = document.getElementById('lmItemDropdown');
if (!dd || dd.style.display === 'none') {
if (event.key === 'ArrowDown' || event.key === 'Enter') {
event.preventDefault();
lmComboOpen();
}
return;
}
const opts = Array.from(dd.querySelectorAll('.lm-item-opt'));
let idx = opts.findIndex(o => o.classList.contains('lm-active'));
if (event.key === 'ArrowDown') {
event.preventDefault();
idx = Math.min(idx + 1, opts.length - 1);
opts.forEach(o => { o.classList.remove('lm-active'); o.style.background = ''; });
if (opts[idx]) { opts[idx].classList.add('lm-active'); opts[idx].style.background = '#e8eeff'; opts[idx].scrollIntoView({ block: 'nearest' }); }
} else if (event.key === 'ArrowUp') {
event.preventDefault();
idx = Math.max(idx - 1, 0);
opts.forEach(o => { o.classList.remove('lm-active'); o.style.background = ''; });
if (opts[idx]) { opts[idx].classList.add('lm-active'); opts[idx].style.background = '#e8eeff'; opts[idx].scrollIntoView({ block: 'nearest' }); }
} else if (event.key === 'Enter') {
event.preventDefault();
const active = dd.querySelector('.lm-active') || opts[0];
if (active) active.dispatchEvent(new MouseEvent('mousedown'));
} else if (event.key === 'Escape') {
lmComboClose();
}
};
function escLm(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Quantity / label logic ────────────────────────────────────────────────
function lmOnQtyInput() {
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
if (method !== 'remaining') {
document.getElementById('lmComputedUsed').classList.add('d-none');
return;
}
const sel = document.getElementById('lmInventoryItem');
const opt = sel.options[sel.selectedIndex];
if (!_selectedItemId) {
document.getElementById('lmComputedUsed').classList.add('d-none');
return;
}
const item = _items.find(it => it.id === _selectedItemId);
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
const remaining = parseFloat(document.getElementById('lmQuantity').value) || 0;
const onHand = parseFloat(opt?.dataset.qty) || 0;
const used = onHand - remaining;
const computedDiv = document.getElementById('lmComputedUsed');
if (sel.value) {
computedDiv.textContent = 'Usage = ' + onHand.toFixed(2) + ' ' + remaining.toFixed(2) + ' = ' + used.toFixed(2) + ' ' + (opt?.dataset.uom || '');
computedDiv.classList.remove('d-none');
} else {
computedDiv.classList.add('d-none');
}
computedDiv.textContent = 'Usage = ' + onHand.toFixed(2) + ' ' + remaining.toFixed(2) + ' = ' + used.toFixed(2) + (item?.unitOfMeasure ? ' ' + item.unitOfMeasure : '');
computedDiv.classList.remove('d-none');
}
window.lmUpdateQuantityLabel = function () {
@@ -70,9 +158,11 @@
lmOnQtyInput();
};
// ── Modal open / save ─────────────────────────────────────────────────────
window.openLogMaterialModal = function () {
// Reset form
document.getElementById('lmInventoryItem').value = '';
_selectedItemId = 0;
document.getElementById('lmItemSearch').value = '';
document.getElementById('lmItemBalance').classList.add('d-none');
document.getElementById('lmQuantity').value = '';
document.getElementById('lmComputedUsed').classList.add('d-none');
@@ -82,14 +172,12 @@
document.getElementById('lmSaveBtn').disabled = false;
document.getElementById('lmMethodUsed').checked = true;
window.lmUpdateQuantityLabel();
lmComboClose();
if (_modal) _modal.show();
};
window.lmSave = async function () {
const cfg = window.__logMaterial;
const itemId = parseInt(document.getElementById('lmInventoryItem').value) || 0;
const qtyInput = parseFloat(document.getElementById('lmQuantity').value) || 0;
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
const alertEl = document.getElementById('lmAlert');
function showError(msg) {
@@ -98,13 +186,16 @@
alertEl.classList.remove('d-none');
}
if (!itemId) { showError('Please select an inventory item.'); return; }
if (!_selectedItemId) { showError('Please select an inventory item.'); return; }
const qtyInput = parseFloat(document.getElementById('lmQuantity').value) || 0;
if (qtyInput <= 0) { showError('Please enter a quantity greater than zero.'); return; }
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
let quantityUsed = qtyInput;
if (method === 'remaining') {
const sel = document.getElementById('lmInventoryItem');
const onHand = parseFloat(sel.options[sel.selectedIndex]?.dataset.qty) || 0;
const item = _items.find(it => it.id === _selectedItemId);
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
quantityUsed = onHand - qtyInput;
if (quantityUsed <= 0) {
showError('Remaining quantity cannot be equal to or greater than the current stock (' + onHand.toFixed(2) + ').');
@@ -125,7 +216,7 @@
},
body: JSON.stringify({
jobId: cfg.jobId,
inventoryItemId: itemId,
inventoryItemId: _selectedItemId,
quantityUsed: quantityUsed,
transactionType: document.getElementById('lmTransactionType').value,
notes: document.getElementById('lmNotes').value.trim() || null
@@ -134,7 +225,6 @@
const data = await resp.json();
if (data.success) {
if (_modal) _modal.hide();
// Reload page so the materials table refreshes
window.location.reload();
} else {
showError(data.message || 'An error occurred.');
@@ -146,5 +236,26 @@
}
};
// ── Init ──────────────────────────────────────────────────────────────────
function init() {
const cfg = window.__logMaterial;
if (!cfg) return;
_items = cfg.inventoryItems || [];
_modal = new bootstrap.Modal(document.getElementById('logMaterialModal'));
document.getElementById('lmQuantity').addEventListener('input', lmOnQtyInput);
// Close dropdown when clicking outside
document.addEventListener('click', function (e) {
if (!e.target.closest('#lmItemSearch') &&
!e.target.closest('#lmItemDropdown') &&
!e.target.closest('#lmItemDropdownToggle')) {
lmComboClose();
}
});
}
document.addEventListener('DOMContentLoaded', init);
})();