Compare commits

..

5 Commits

Author SHA1 Message Date
spouliot 485f0b69c8 Format Log Material dropdown as 'Manufacturer - Name (UoM)'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:51:32 -04:00
spouliot f380c152ca Promote job powders to top of Log Material dropdown
Powders already assigned to this job's coats appear under a 'This Job'
section header, then a divider, then 'All Inventory' — so the most
relevant choices are always one click away.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:49:46 -04:00
spouliot 79c8c7e6a4 Add manufacturer to Log Material item combobox
Shows manufacturer name as muted secondary text in each dropdown row
and includes it in the search filter, so users can find a powder by
brand when multiple items share a similar name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:46:48 -04:00
spouliot 6cf355071b 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>
2026-05-16 21:41:14 -04:00
spouliot ebd474ae81 Fix log material dropdown showing undefined - camelCase JSON serialization
System.Text.Json defaults to PascalCase; JS reads camelCase. Add
JsonNamingPolicy.CamelCase to the InventoryItemsForModal serialization.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:15:23 -04:00
3 changed files with 216 additions and 57 deletions
@@ -501,9 +501,19 @@ public class JobsController : Controller
// Inventory items for the manual log-material modal // Inventory items for the manual log-material modal
var inventoryItemsForModal = (await _unitOfWork.InventoryItems.GetAllAsync()) var inventoryItemsForModal = (await _unitOfWork.InventoryItems.GetAllAsync())
.OrderBy(i => i.Name) .OrderBy(i => i.Name)
.Select(i => new { i.Id, i.Name, i.UnitOfMeasure, i.QuantityOnHand }) .Select(i => new { i.Id, i.Name, i.Manufacturer, i.UnitOfMeasure, i.QuantityOnHand })
.ToList(); .ToList();
ViewBag.InventoryItemsForModal = System.Text.Json.JsonSerializer.Serialize(inventoryItemsForModal); var jsonOpts = new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase };
ViewBag.InventoryItemsForModal = System.Text.Json.JsonSerializer.Serialize(inventoryItemsForModal, jsonOpts);
// IDs of powders already assigned to this job's coats — shown at top of log-material dropdown
var jobPowderIds = (jobDto.Items ?? new List<PowderCoating.Application.DTOs.Job.JobItemDto>())
.SelectMany(i => i.Coats ?? new List<PowderCoating.Application.DTOs.Job.JobItemCoatDto>())
.Where(c => c.InventoryItemId.HasValue)
.Select(c => c.InventoryItemId!.Value)
.Distinct()
.ToList();
ViewBag.JobPowderIds = System.Text.Json.JsonSerializer.Serialize(jobPowderIds, jsonOpts);
// Pre-logged powder grouped by InventoryItemId (for Complete Job modal pre-fill) // Pre-logged powder grouped by InventoryItemId (for Complete Job modal pre-fill)
ViewBag.PreLoggedPowder = allJobTransactions ViewBag.PreLoggedPowder = allJobTransactions
@@ -1105,9 +1105,22 @@
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold">Inventory Item <span class="text-danger">*</span></label> <label class="form-label fw-semibold">Inventory Item <span class="text-danger">*</span></label>
<select id="lmInventoryItem" class="form-select"> <div class="position-relative">
<option value="">-- Select item --</option> <div class="input-group">
</select> <input type="text" class="form-control" id="lmItemSearch"
placeholder="Search by name or manufacturer&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 id="lmItemBalance" class="form-text text-muted d-none"></div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
@@ -3149,10 +3162,11 @@
<script> <script>
(function () { (function () {
const inventoryItems = @Html.Raw(ViewBag.InventoryItemsForModal ?? "[]"); const inventoryItems = @Html.Raw(ViewBag.InventoryItemsForModal ?? "[]");
const jobPowderIds = @Html.Raw(ViewBag.JobPowderIds ?? "[]");
const jobId = @Model.Id; const jobId = @Model.Id;
const logUrl = '@Url.Action("LogMaterial", "Jobs")'; const logUrl = '@Url.Action("LogMaterial", "Jobs")';
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? ''; const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
window.__logMaterial = { inventoryItems, jobId, logUrl, token }; window.__logMaterial = { inventoryItems, jobPowderIds, jobId, logUrl, token };
})(); })();
</script> </script>
+186 -51
View File
@@ -4,62 +4,173 @@
*/ */
(function () { (function () {
let _items = []; let _items = [];
let _jobPowderIds = new Set();
let _modal = null; let _modal = null;
function init() { // ── Combobox state ────────────────────────────────────────────────────────
const cfg = window.__logMaterial; let _selectedItemId = 0;
if (!cfg) return;
_items = cfg.inventoryItems || []; function lmComboInput() {
_modal = new bootstrap.Modal(document.getElementById('logMaterialModal')); const q = document.getElementById('lmItemSearch')?.value?.toLowerCase() || '';
lmComboRender(q);
const sel = document.getElementById('lmInventoryItem'); lmComboShow();
_items.forEach(function (item) { _selectedItemId = 0;
const opt = document.createElement('option'); document.getElementById('lmItemBalance').classList.add('d-none');
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(); 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 lmMakeRow(it) {
const display = (it.manufacturer ? escLm(it.manufacturer) + ' &ndash; ' : '') +
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='#f0f4ff'"
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:#f8f9fa;border-bottom:1px solid #dee2e6;">This Job</div>';
html += jobItems.map(lmMakeRow).join('');
if (otherItems.length > 0) {
html += '<div style="height:1px;background:#dee2e6;margin:.25rem 0;"></div>';
html += '<div class="px-3 py-1 text-muted" style="font-size:.72rem;letter-spacing:.04em;text-transform:uppercase;background:#f8f9fa;border-bottom:1px solid #dee2e6;">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');
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() { function lmOnQtyInput() {
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value; const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
if (method !== 'remaining') { if (method !== 'remaining') {
document.getElementById('lmComputedUsed').classList.add('d-none'); document.getElementById('lmComputedUsed').classList.add('d-none');
return; return;
} }
const sel = document.getElementById('lmInventoryItem'); if (!_selectedItemId) {
const opt = sel.options[sel.selectedIndex]; 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 remaining = parseFloat(document.getElementById('lmQuantity').value) || 0;
const onHand = parseFloat(opt?.dataset.qty) || 0;
const used = onHand - remaining; const used = onHand - remaining;
const computedDiv = document.getElementById('lmComputedUsed'); const computedDiv = document.getElementById('lmComputedUsed');
if (sel.value) { computedDiv.textContent = 'Usage = ' + onHand.toFixed(2) + ' ' + remaining.toFixed(2) + ' = ' + used.toFixed(2) + (item?.unitOfMeasure ? ' ' + item.unitOfMeasure : '');
computedDiv.textContent = 'Usage = ' + onHand.toFixed(2) + ' ' + remaining.toFixed(2) + ' = ' + used.toFixed(2) + ' ' + (opt?.dataset.uom || ''); computedDiv.classList.remove('d-none');
computedDiv.classList.remove('d-none');
} else {
computedDiv.classList.add('d-none');
}
} }
window.lmUpdateQuantityLabel = function () { window.lmUpdateQuantityLabel = function () {
@@ -70,9 +181,11 @@
lmOnQtyInput(); lmOnQtyInput();
}; };
// ── Modal open / save ─────────────────────────────────────────────────────
window.openLogMaterialModal = function () { window.openLogMaterialModal = function () {
// Reset form _selectedItemId = 0;
document.getElementById('lmInventoryItem').value = ''; document.getElementById('lmItemSearch').value = '';
document.getElementById('lmItemBalance').classList.add('d-none'); document.getElementById('lmItemBalance').classList.add('d-none');
document.getElementById('lmQuantity').value = ''; document.getElementById('lmQuantity').value = '';
document.getElementById('lmComputedUsed').classList.add('d-none'); document.getElementById('lmComputedUsed').classList.add('d-none');
@@ -82,14 +195,12 @@
document.getElementById('lmSaveBtn').disabled = false; document.getElementById('lmSaveBtn').disabled = false;
document.getElementById('lmMethodUsed').checked = true; document.getElementById('lmMethodUsed').checked = true;
window.lmUpdateQuantityLabel(); window.lmUpdateQuantityLabel();
lmComboClose();
if (_modal) _modal.show(); if (_modal) _modal.show();
}; };
window.lmSave = async function () { window.lmSave = async function () {
const cfg = window.__logMaterial; 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'); const alertEl = document.getElementById('lmAlert');
function showError(msg) { function showError(msg) {
@@ -98,13 +209,16 @@
alertEl.classList.remove('d-none'); 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; } if (qtyInput <= 0) { showError('Please enter a quantity greater than zero.'); return; }
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
let quantityUsed = qtyInput; let quantityUsed = qtyInput;
if (method === 'remaining') { if (method === 'remaining') {
const sel = document.getElementById('lmInventoryItem'); const item = _items.find(it => it.id === _selectedItemId);
const onHand = parseFloat(sel.options[sel.selectedIndex]?.dataset.qty) || 0; const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
quantityUsed = onHand - qtyInput; quantityUsed = onHand - qtyInput;
if (quantityUsed <= 0) { if (quantityUsed <= 0) {
showError('Remaining quantity cannot be equal to or greater than the current stock (' + onHand.toFixed(2) + ').'); showError('Remaining quantity cannot be equal to or greater than the current stock (' + onHand.toFixed(2) + ').');
@@ -125,7 +239,7 @@
}, },
body: JSON.stringify({ body: JSON.stringify({
jobId: cfg.jobId, jobId: cfg.jobId,
inventoryItemId: itemId, inventoryItemId: _selectedItemId,
quantityUsed: quantityUsed, quantityUsed: quantityUsed,
transactionType: document.getElementById('lmTransactionType').value, transactionType: document.getElementById('lmTransactionType').value,
notes: document.getElementById('lmNotes').value.trim() || null notes: document.getElementById('lmNotes').value.trim() || null
@@ -134,7 +248,6 @@
const data = await resp.json(); const data = await resp.json();
if (data.success) { if (data.success) {
if (_modal) _modal.hide(); if (_modal) _modal.hide();
// Reload page so the materials table refreshes
window.location.reload(); window.location.reload();
} else { } else {
showError(data.message || 'An error occurred.'); showError(data.message || 'An error occurred.');
@@ -146,5 +259,27 @@
} }
}; };
// ── 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', 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); document.addEventListener('DOMContentLoaded', init);
})(); })();