/// /// Shared inline-edit behaviour for quote, job, and invoice item rows. /// Activated when the page sets window.inlineItemEdit = { patchUrl, canEdit, totals }. /// totals: { subtotal, tax, total, finalPrice, balance } — CSS selectors, any subset. /// (function () { 'use strict'; const cfg = window.inlineItemEdit; if (!cfg || !cfg.canEdit) return; function fmt(val) { return val.toLocaleString('en-US', { style: 'currency', currency: 'USD' }); } function csrfToken() { return document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? ''; } function showError(msg) { const el = document.createElement('div'); el.className = 'alert alert-danger alert-permanent position-fixed bottom-0 end-0 m-3 shadow'; el.style.zIndex = '9999'; el.textContent = msg; document.body.appendChild(el); setTimeout(() => el.remove(), 4000); } function updateTotals(data) { const t = cfg.totals || {}; [ [t.subtotal, data.subtotal], [t.tax, data.taxAmount], [t.total, data.total], [t.finalPrice, data.finalPrice], [t.balance, data.balanceDue], ].forEach(([sel, val]) => { if (sel && val !== undefined && val !== null) { document.querySelectorAll(sel).forEach(el => { el.textContent = fmt(val); }); } }); } function makeEditable(span) { const field = span.dataset.inlineField; const row = span.closest('tr[data-item-id]'); if (!row) return; const itemId = row.dataset.itemId; const rawVal = span.dataset.rawValue ?? span.textContent.trim(); const input = document.createElement('input'); input.className = 'form-control form-control-sm inline-edit-input'; if (field === 'description') { input.type = 'text'; input.style.minWidth = '140px'; } else { input.type = 'number'; input.step = '0.01'; input.min = '0'; input.style.width = '80px'; } input.value = rawVal; // Stash current rendered markup so we can revert const savedHTML = span.innerHTML; span.innerHTML = ''; span.appendChild(input); input.focus(); input.select(); let committed = false; function revert() { span.innerHTML = savedHTML; attachListeners(span); } async function commit() { if (committed) return; committed = true; const newVal = input.value.trim(); if (newVal === '' || (field !== 'description' && isNaN(parseFloat(newVal)))) { revert(); return; } // Read sibling raw values from other editable cells in the same row function siblingRaw(f) { const s = row.querySelector(`[data-inline-field="${f}"]`); if (!s) return null; // If that sibling is currently showing an input (concurrent edit, unlikely), fall back const inp = s.querySelector('input.inline-edit-input'); if (inp) return inp.value; return s.dataset.rawValue ?? s.textContent.trim(); } const description = field === 'description' ? newVal : (siblingRaw('description') ?? ''); const quantity = parseFloat(field === 'quantity' ? newVal : (siblingRaw('quantity') ?? '1')); const unitPrice = parseFloat(field === 'unitPrice' ? newVal : (siblingRaw('unitPrice') ?? '0')); try { const resp = await fetch(cfg.patchUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': csrfToken() }, body: JSON.stringify({ itemId: parseInt(itemId, 10), description, quantity, unitPrice }) }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); showError(err.error ?? 'Could not save — try again.'); revert(); return; } const data = await resp.json(); // Update this span's display and stored raw value if (field === 'description') { const strong = span.querySelector('strong'); if (strong) { strong.textContent = newVal; } else { span.innerHTML = `${newVal}`; } span.dataset.rawValue = newVal; } else if (field === 'quantity') { span.dataset.rawValue = quantity; span.textContent = quantity % 1 === 0 ? quantity.toFixed(0) : quantity.toString(); } else if (field === 'unitPrice') { span.dataset.rawValue = unitPrice; span.textContent = fmt(unitPrice); } // Update line total cell const totalCell = row.querySelector('[data-line-total]'); if (totalCell) totalCell.textContent = fmt(data.lineTotal); // Update document-level totals updateTotals(data); // Re-attach click listener for next edit attachListeners(span); } catch { showError('Could not save — check your connection and try again.'); revert(); } } input.addEventListener('blur', commit); input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } if (e.key === 'Escape') { committed = true; revert(); } }); } function attachListeners(span) { span.style.cursor = 'text'; span.title = 'Click to edit'; span.classList.add('inline-editable'); span.addEventListener('click', () => makeEditable(span), { once: true }); } document.addEventListener('DOMContentLoaded', function () { document.querySelectorAll('[data-inline-field]').forEach(attachListeners); }); })();