Add manual Log Material modal to job details page

PC users were blocked to QR scan only for logging material usage. Now a
"Log Material" button opens an inline modal with:
- Inventory item dropdown (name + unit of measure, current stock shown on select)
- Entry method toggle: "Amount Used" or "Amount Remaining" (computes used = onHand - remaining)
- Reason: Job Usage or Waste/Spillage
- Notes field
Submits via AJAX to Jobs/LogMaterial (new POST action) which mirrors the
InventoryController.LogUsage flow — updates QuantityOnHand, creates InventoryTransaction,
posts GL entries (DR COGS / CR Inventory). QR scan button retained as icon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 12:10:54 -04:00
parent 27aa4e0ea6
commit 36680eced9
3 changed files with 313 additions and 4 deletions
@@ -0,0 +1,150 @@
/**
* Log Material Usage modal — job details page.
* Reads config from window.__logMaterial injected inline by the view.
*/
(function () {
let _items = [];
let _modal = null;
function init() {
const cfg = window.__logMaterial;
if (!cfg) return;
_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');
}
lmOnQtyInput();
}
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];
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');
}
}
window.lmUpdateQuantityLabel = function () {
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
document.getElementById('lmQtyLabel').innerHTML =
(method === 'remaining' ? 'Quantity Remaining' : 'Quantity Used') +
' <span class="text-danger">*</span>';
lmOnQtyInput();
};
window.openLogMaterialModal = function () {
// Reset form
document.getElementById('lmInventoryItem').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;
document.getElementById('lmMethodUsed').checked = true;
window.lmUpdateQuantityLabel();
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) {
alertEl.className = 'alert alert-danger alert-permanent';
alertEl.textContent = msg;
alertEl.classList.remove('d-none');
}
if (!itemId) { showError('Please select an inventory item.'); return; }
if (qtyInput <= 0) { showError('Please enter a quantity greater than zero.'); return; }
let quantityUsed = qtyInput;
if (method === 'remaining') {
const sel = document.getElementById('lmInventoryItem');
const onHand = parseFloat(sel.options[sel.selectedIndex]?.dataset.qty) || 0;
quantityUsed = onHand - qtyInput;
if (quantityUsed <= 0) {
showError('Remaining quantity 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: itemId,
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();
// Reload page so the materials table refreshes
window.location.reload();
} else {
showError(data.message || 'An error occurred.');
btn.disabled = false;
}
} catch {
showError('Network error. Please try again.');
btn.disabled = false;
}
};
document.addEventListener('DOMContentLoaded', init);
})();