87bbf158a4
- InventoryController: extract RecordInventoryUsageAsync helper; both LogUsage (scan page) and LogMaterial (jobs modal, moved from JobsController) call it — no more duplicate save/GL logic across two controllers - Log Material modal: replace radio buttons with prominent toggle buttons so the active mode (Amount Used vs Amount Remaining) is always visually obvious; add always-visible preview line showing exactly what will be logged before saving - Edit Usage modal: add quantity field (pre-populated from existing transaction) with delta adjustment to InventoryItem.QuantityOnHand on save; include completed/terminal jobs in the dropdown so entries can be corrected after a job is marked done - Scan page job picker: include jobs completed within the last 7 days (marked with '(completed)') so usage can be logged after a job is finished Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
314 lines
14 KiB
JavaScript
314 lines
14 KiB
JavaScript
/**
|
|
* Log Material Usage modal — job details page.
|
|
* Reads config from window.__logMaterial injected inline by the view.
|
|
*/
|
|
(function () {
|
|
let _items = [];
|
|
let _jobPowderIds = new Set();
|
|
let _modal = null;
|
|
let _selectedItemId = 0;
|
|
let _entryMethod = 'used'; // 'used' | 'remaining'
|
|
|
|
// ── Mode toggle ───────────────────────────────────────────────────────────
|
|
|
|
window.lmSetMethod = function (method) {
|
|
_entryMethod = method;
|
|
const btnUsed = document.getElementById('lmBtnUsed');
|
|
const btnRemaining = document.getElementById('lmBtnRemaining');
|
|
const hintEl = document.getElementById('lmMethodHint');
|
|
const qtyLabel = document.getElementById('lmQtyLabel');
|
|
|
|
if (method === 'remaining') {
|
|
btnUsed.className = 'btn btn-outline-primary';
|
|
btnRemaining.className = 'btn btn-primary';
|
|
hintEl.textContent = 'Enter how much is LEFT in the bag — the system calculates what was used.';
|
|
qtyLabel.innerHTML = 'Weight Remaining in Bag <span class="text-danger">*</span>';
|
|
} else {
|
|
btnUsed.className = 'btn btn-primary';
|
|
btnRemaining.className = 'btn btn-outline-primary';
|
|
hintEl.textContent = 'Enter how much powder you took out of the bag.';
|
|
qtyLabel.innerHTML = 'Quantity Used <span class="text-danger">*</span>';
|
|
}
|
|
lmUpdatePreview();
|
|
};
|
|
|
|
// ── Live preview (always visible once qty + item are set) ─────────────────
|
|
|
|
function lmUpdatePreview() {
|
|
const computedDiv = document.getElementById('lmComputedUsed');
|
|
if (!_selectedItemId || !computedDiv) { computedDiv?.classList.add('d-none'); return; }
|
|
const item = _items.find(it => it.id === _selectedItemId);
|
|
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
|
|
const qty = parseFloat(document.getElementById('lmQuantity').value) || 0;
|
|
if (qty <= 0) { computedDiv.classList.add('d-none'); return; }
|
|
|
|
const uom = item?.unitOfMeasure || '';
|
|
if (_entryMethod === 'remaining') {
|
|
const used = onHand - qty;
|
|
if (used <= 0) {
|
|
computedDiv.className = 'form-text fw-semibold text-danger';
|
|
computedDiv.textContent = 'Remaining cannot be ≥ current stock (' + onHand.toFixed(2) + ' ' + uom + ').';
|
|
} else {
|
|
computedDiv.className = 'form-text fw-semibold text-success';
|
|
computedDiv.textContent =
|
|
'Will log ' + used.toFixed(2) + ' ' + uom + ' used — new balance: ' + qty.toFixed(2) + ' ' + uom;
|
|
}
|
|
} else {
|
|
const newBal = onHand - qty;
|
|
const col = newBal < 0 ? 'text-danger' : 'text-success';
|
|
computedDiv.className = 'form-text fw-semibold ' + col;
|
|
computedDiv.textContent =
|
|
'Will log ' + qty.toFixed(2) + ' ' + uom + ' used — new balance: ' + newBal.toFixed(2) + ' ' + uom;
|
|
}
|
|
computedDiv.classList.remove('d-none');
|
|
}
|
|
|
|
// ── Combobox state ────────────────────────────────────────────────────────
|
|
|
|
function lmComboInput() {
|
|
const q = document.getElementById('lmItemSearch')?.value?.toLowerCase() || '';
|
|
lmComboRender(q);
|
|
lmComboShow();
|
|
_selectedItemId = 0;
|
|
document.getElementById('lmItemBalance').classList.add('d-none');
|
|
lmUpdatePreview();
|
|
}
|
|
|
|
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 lmMakeRow(it) {
|
|
const display = (it.manufacturer ? escLm(it.manufacturer) + ' – ' : '') +
|
|
escLm(it.name) +
|
|
(it.unitOfMeasure ? ' <span class="text-muted" style="font-size:.82rem;">(' + escLm(it.unitOfMeasure) + ')</span>' : '');
|
|
const label = (it.manufacturer ? it.manufacturer + ' - ' : '') +
|
|
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='var(--bs-secondary-bg)'"
|
|
onmouseleave="this.classList.contains('lm-active') ? null : this.style.background=''">
|
|
${display}
|
|
</div>`;
|
|
}
|
|
|
|
function lmComboRender(query) {
|
|
const dd = document.getElementById('lmItemDropdown');
|
|
if (!dd) return;
|
|
const filtered = query
|
|
? _items.filter(it => it.name.toLowerCase().includes(query) ||
|
|
(it.manufacturer && it.manufacturer.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;
|
|
}
|
|
|
|
const jobItems = filtered.filter(it => _jobPowderIds.has(it.id));
|
|
const otherItems = filtered.filter(it => !_jobPowderIds.has(it.id));
|
|
|
|
let html = '';
|
|
if (jobItems.length > 0) {
|
|
html += '<div class="px-3 py-1 text-muted" style="font-size:.72rem;letter-spacing:.04em;text-transform:uppercase;background:var(--bs-tertiary-bg);border-bottom:1px solid var(--bs-border-color);">This Job</div>';
|
|
html += jobItems.map(lmMakeRow).join('');
|
|
if (otherItems.length > 0) {
|
|
html += '<div style="height:1px;background:var(--bs-border-color);margin:.25rem 0;"></div>';
|
|
html += '<div class="px-3 py-1 text-muted" style="font-size:.72rem;letter-spacing:.04em;text-transform:uppercase;background:var(--bs-tertiary-bg);border-bottom:1px solid var(--bs-border-color);">All Inventory</div>';
|
|
}
|
|
}
|
|
html += otherItems.map(lmMakeRow).join('');
|
|
dd.innerHTML = html;
|
|
}
|
|
|
|
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');
|
|
lmUpdatePreview();
|
|
};
|
|
|
|
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 = 'var(--bs-primary-bg-subtle)'; 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 = 'var(--bs-primary-bg-subtle)'; 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
// ── Kept for backward-compat with any inline onchange handlers that may exist ─
|
|
window.lmUpdateQuantityLabel = function () { lmUpdatePreview(); };
|
|
|
|
// ── Modal open / save ─────────────────────────────────────────────────────
|
|
|
|
window.openLogMaterialModal = function () {
|
|
_selectedItemId = 0;
|
|
_entryMethod = 'used';
|
|
document.getElementById('lmItemSearch').value = '';
|
|
document.getElementById('lmItemBalance').classList.add('d-none');
|
|
document.getElementById('lmQuantity').value = '';
|
|
document.getElementById('lmComputedUsed').classList.add('d-none');
|
|
document.getElementById('lmTransactionType').value = 'JobUsage';
|
|
document.getElementById('lmNotes').value = '';
|
|
document.getElementById('lmAlert').classList.add('d-none');
|
|
document.getElementById('lmSaveBtn').disabled = false;
|
|
lmSetMethod('used');
|
|
lmComboClose();
|
|
if (_modal) _modal.show();
|
|
};
|
|
|
|
window.lmSave = async function () {
|
|
const cfg = window.__logMaterial;
|
|
const alertEl = document.getElementById('lmAlert');
|
|
|
|
function showError(msg) {
|
|
alertEl.className = 'alert alert-danger alert-permanent';
|
|
alertEl.textContent = msg;
|
|
alertEl.classList.remove('d-none');
|
|
}
|
|
|
|
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 item = _items.find(it => it.id === _selectedItemId);
|
|
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
|
|
|
|
let quantityUsed = qtyInput;
|
|
if (_entryMethod === 'remaining') {
|
|
quantityUsed = onHand - qtyInput;
|
|
if (quantityUsed <= 0) {
|
|
showError('Remaining cannot be equal to or greater than the current stock (' + onHand.toFixed(2) + ').');
|
|
return;
|
|
}
|
|
}
|
|
|
|
const btn = document.getElementById('lmSaveBtn');
|
|
btn.disabled = true;
|
|
alertEl.classList.add('d-none');
|
|
|
|
try {
|
|
const resp = await fetch(cfg.logUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'RequestVerificationToken': cfg.token
|
|
},
|
|
body: JSON.stringify({
|
|
jobId: cfg.jobId,
|
|
inventoryItemId: _selectedItemId,
|
|
quantityUsed: quantityUsed,
|
|
transactionType: document.getElementById('lmTransactionType').value,
|
|
notes: document.getElementById('lmNotes').value.trim() || null
|
|
})
|
|
});
|
|
const data = await resp.json();
|
|
if (data.success) {
|
|
if (_modal) _modal.hide();
|
|
window.location.reload();
|
|
} else {
|
|
showError(data.message || 'An error occurred.');
|
|
btn.disabled = false;
|
|
}
|
|
} catch {
|
|
showError('Network error. Please try again.');
|
|
btn.disabled = false;
|
|
}
|
|
};
|
|
|
|
// ── Init ──────────────────────────────────────────────────────────────────
|
|
|
|
function init() {
|
|
const cfg = window.__logMaterial;
|
|
if (!cfg) return;
|
|
|
|
_items = cfg.inventoryItems || [];
|
|
_jobPowderIds = new Set(cfg.jobPowderIds || []);
|
|
_modal = new bootstrap.Modal(document.getElementById('logMaterialModal'));
|
|
|
|
document.getElementById('lmQuantity').addEventListener('input', lmUpdatePreview);
|
|
|
|
document.addEventListener('click', function (e) {
|
|
if (!e.target.closest('#lmItemSearch') &&
|
|
!e.target.closest('#lmItemDropdown') &&
|
|
!e.target.closest('#lmItemDropdownToggle')) {
|
|
lmComboClose();
|
|
}
|
|
});
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
})();
|