Inline item editing on details pages; fix Stripe receipt_email

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>
This commit is contained in:
2026-05-20 11:49:04 -04:00
parent 8452ea3fcd
commit 7fa385aeb8
12 changed files with 418 additions and 52 deletions
@@ -1152,3 +1152,14 @@ a.tag-index-badge:hover {
}
}
.mw-lg { max-width: 640px; }
/* ── Inline item edit ───────────────────────────────────────── */
.inline-editable:hover {
text-decoration: underline dotted;
text-underline-offset: 3px;
}
.inline-edit-input {
display: inline-block;
padding: 1px 4px;
height: auto;
}
@@ -0,0 +1,167 @@
/// <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 &mdash; 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 &mdash; 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);
});
})();