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:
@@ -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 — 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);
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user