7fa385aeb8
Allow description, quantity, and price to be edited inline on Quote, Job, and Invoice details pages without re-opening the wizard. Coating and prep service rows remain read-only by design. Invoice editing is gated to Draft/Sent/Overdue statuses; totals update live in the DOM. Remove receipt_email from Stripe PaymentIntent creation so customers can use any email they choose at checkout — Stripe validates format and sends the receipt to whatever the customer enters in the Payment Element, eliminating the risk of a stored email mismatch blocking a payment from processing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
168 lines
6.3 KiB
JavaScript
168 lines
6.3 KiB
JavaScript
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
(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 = `<strong>${newVal}</strong>`; }
|
|
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);
|
|
});
|
|
})();
|