Files
PowderCoatingLogix/publish-output/wwwroot/js/purchase-orders.js
T
2026-04-23 21:38:24 -04:00

310 lines
11 KiB
JavaScript

/**
* purchase-orders.js
* Handles dynamic line-item management for Purchase Order Create/Edit forms.
* A single searchable text input per row: type to filter inventory items or
* enter a free-text description. Selecting an inventory item stores its ID in
* a hidden field; typing something custom leaves the hidden field empty.
*/
'use strict';
let inventoryData = [];
let itemIndex = 0;
document.addEventListener('DOMContentLoaded', () => {
const dataEl = document.getElementById('inventoryItemsData');
if (dataEl) {
try { inventoryData = JSON.parse(dataEl.textContent); } catch (e) { inventoryData = []; }
}
const tbody = document.getElementById('lineItemsBody');
if (tbody) {
itemIndex = tbody.querySelectorAll('tr[data-index]').length;
}
updateTotals();
});
// ─── Add / Remove rows ────────────────────────────────────────────────────────
function addItem() {
const tbody = document.getElementById('lineItemsBody');
if (!tbody) return;
const i = itemIndex++;
const tr = document.createElement('tr');
tr.setAttribute('data-index', i);
tr.innerHTML = buildRowHtml(i, {});
tbody.appendChild(tr);
toggleEmptyMessage();
updateTotals();
tr.querySelector('.item-search-input')?.focus();
}
function removeItem(btn) {
const tr = btn.closest('tr');
if (tr) {
tr.remove();
reindexRows();
updateTotals();
toggleEmptyMessage();
}
}
function reindexRows() {
const rows = document.querySelectorAll('#lineItemsBody tr[data-index]');
rows.forEach((tr, newIndex) => {
tr.setAttribute('data-index', newIndex);
tr.querySelectorAll('[name]').forEach(el => {
el.name = el.name.replace(/Items\[\d+\]/, `Items[${newIndex}]`);
});
const searchInput = tr.querySelector('.item-search-input');
if (searchInput) {
searchInput.setAttribute('data-row-index', newIndex);
searchInput.setAttribute('oninput', `onItemSearch(this, ${newIndex})`);
}
});
itemIndex = rows.length;
}
// ─── Row HTML builder ─────────────────────────────────────────────────────────
/**
* opts: { type: 'inventory'|'custom', selectedId, description, qty, cost, notes }
*/
function buildRowHtml(i, opts = {}) {
const isInventory = opts.type === 'inventory' && opts.selectedId;
const qty = opts.qty ?? 1;
const cost = opts.cost ?? 0;
const notes = opts.notes ?? '';
const lineTotal = (qty * cost).toFixed(2);
let displayText = '';
if (isInventory) {
const inv = inventoryData.find(x => String(x.value) === String(opts.selectedId));
displayText = inv ? inv.text : (opts.description || '');
} else {
displayText = opts.description || '';
}
return `
<td style="min-width:240px">
<input type="text"
class="form-control form-control-sm item-search-input"
name="Items[${i}].Description"
value="${escHtml(displayText)}"
placeholder="Search inventory or enter description…"
autocomplete="off"
oninput="onItemSearch(this, ${i})"
onblur="hideAutocomplete()"
data-row-index="${i}" />
<input type="hidden"
name="Items[${i}].InventoryItemId"
class="inventory-id-field"
value="${isInventory ? (opts.selectedId || '') : ''}" />
</td>
<td>
<input type="number" name="Items[${i}].QuantityOrdered"
class="form-control form-control-sm"
value="${qty}" min="0.001" step="0.001" style="width:85px"
oninput="updateLineTotals()" required />
</td>
<td>
<input type="number" name="Items[${i}].UnitCost"
class="form-control form-control-sm"
value="${cost}" min="0" step="0.01" style="width:105px"
oninput="updateLineTotals()" />
</td>
<td class="item-line-total text-end fw-semibold align-middle">$${lineTotal}</td>
<td>
<input type="text" name="Items[${i}].Notes"
class="form-control form-control-sm"
value="${escHtml(notes)}" placeholder="Optional" />
</td>
<td class="text-center">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeItem(this)" title="Remove">
<i class="bi bi-trash"></i>
</button>
</td>`;
}
// ─── Autocomplete ─────────────────────────────────────────────────────────────
// Uses a single body-level dropdown so it's never clipped by table overflow.
let _activeSearchInput = null;
function getAutocompleteDropdown() {
let el = document.getElementById('po-autocomplete');
if (!el) {
el = document.createElement('ul');
el.id = 'po-autocomplete';
el.className = 'list-group shadow';
el.style.cssText = 'position:fixed;z-index:9999;max-height:220px;overflow-y:auto;'
+ 'margin:0;padding:0;border-radius:.35rem;min-width:180px;display:none;';
document.body.appendChild(el);
}
return el;
}
function onItemSearch(input, rowIndex) {
_activeSearchInput = input;
// Clear previously selected inventory item when user edits the field
const tr = input.closest('tr');
const idField = tr?.querySelector('.inventory-id-field');
if (idField) idField.value = '';
const query = input.value.trim().toLowerCase();
const dropdown = getAutocompleteDropdown();
if (!query) {
dropdown.style.display = 'none';
return;
}
const matches = inventoryData
.filter(item => item.text.toLowerCase().includes(query))
.slice(0, 12);
if (matches.length === 0) {
dropdown.style.display = 'none';
return;
}
dropdown.innerHTML = matches.map(item => `
<li class="list-group-item list-group-item-action py-1 px-2 small"
style="cursor:pointer"
data-value="${item.value}"
data-cost="${item.cost}"
data-text="${escHtml(item.text)}"
onmousedown="selectInventoryItem(this, ${rowIndex})">
${escHtml(item.text)}
</li>`).join('');
// Position below the input
const rect = input.getBoundingClientRect();
dropdown.style.top = (rect.bottom + 2) + 'px';
dropdown.style.left = rect.left + 'px';
dropdown.style.width = rect.width + 'px';
dropdown.style.display = '';
}
function selectInventoryItem(li, rowIndex) {
const tr = document.querySelector(`#lineItemsBody tr[data-index="${rowIndex}"]`);
if (!tr) return;
const searchInput = tr.querySelector('.item-search-input');
const idField = tr.querySelector('.inventory-id-field');
const costInput = tr.querySelector(`[name="Items[${rowIndex}].UnitCost"]`);
if (searchInput) searchInput.value = li.dataset.text;
if (idField) idField.value = li.dataset.value;
if (costInput) {
const cost = parseFloat(li.dataset.cost);
if (cost > 0) costInput.value = cost.toFixed(2);
}
getAutocompleteDropdown().style.display = 'none';
_activeSearchInput = null;
updateLineTotals();
}
function hideAutocomplete() {
// Delay so mousedown on a list item fires before blur hides the dropdown
setTimeout(() => {
getAutocompleteDropdown().style.display = 'none';
}, 150);
}
// Reposition on scroll/resize so the dropdown tracks the input
window.addEventListener('scroll', () => {
if (!_activeSearchInput) return;
const rect = _activeSearchInput.getBoundingClientRect();
const dd = document.getElementById('po-autocomplete');
if (dd && dd.style.display !== 'none') {
dd.style.top = (rect.bottom + 2) + 'px';
dd.style.left = rect.left + 'px';
}
}, true);
// ─── Totals ───────────────────────────────────────────────────────────────────
function updateLineTotals() {
const rows = document.querySelectorAll('#lineItemsBody tr[data-index]');
let subTotal = 0;
rows.forEach(tr => {
const i = tr.getAttribute('data-index');
const qty = parseFloat(tr.querySelector(`[name="Items[${i}].QuantityOrdered"]`)?.value) || 0;
const cost = parseFloat(tr.querySelector(`[name="Items[${i}].UnitCost"]`)?.value) || 0;
const lineTotal = qty * cost;
subTotal += lineTotal;
const cell = tr.querySelector('.item-line-total');
if (cell) cell.textContent = '$' + lineTotal.toFixed(2);
});
updateTotals(subTotal);
}
function updateTotals(subTotal) {
if (subTotal === undefined) {
subTotal = 0;
document.querySelectorAll('#lineItemsBody tr[data-index]').forEach(tr => {
const i = tr.getAttribute('data-index');
const qty = parseFloat(tr.querySelector(`[name="Items[${i}].QuantityOrdered"]`)?.value) || 0;
const cost = parseFloat(tr.querySelector(`[name="Items[${i}].UnitCost"]`)?.value) || 0;
subTotal += qty * cost;
});
}
const shipping = parseFloat(document.getElementById('shippingCostInput')?.value) || 0;
const grandTotal = subTotal + shipping;
setText('subTotalDisplay', '$' + subTotal.toFixed(2));
setText('grandTotalDisplay', '$' + grandTotal.toFixed(2));
}
function setText(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
function toggleEmptyMessage() {
const tbody = document.getElementById('lineItemsBody');
const msg = document.getElementById('emptyItemsMessage');
const header = document.getElementById('lineItemsHeader');
if (!tbody || !msg) return;
const hasRows = tbody.querySelectorAll('tr[data-index]').length > 0;
msg.style.display = hasRows ? 'none' : 'block';
if (header) header.style.display = hasRows ? '' : 'none';
}
// ─── Receive form helpers ─────────────────────────────────────────────────────
function receiveAll() {
document.querySelectorAll('.receive-qty-input').forEach(input => {
input.value = parseFloat(input.dataset.remaining) || 0;
});
}
function clearAll() {
document.querySelectorAll('.receive-qty-input').forEach(input => {
input.value = 0;
});
}
// ─── Utilities ────────────────────────────────────────────────────────────────
function escHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}