310 lines
11 KiB
JavaScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|