Initial commit
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
Reference in New Issue
Block a user