Add customer support notes timeline

This commit is contained in:
mfwadejr
2026-08-29 16:01:47 -04:00
committed by GitHub
parent 8fff874c25
commit 0be4d8377d
2 changed files with 7 additions and 3 deletions
+6 -3
View File
@@ -4,6 +4,7 @@ const PAGE_SIZE = 10;
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }); const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const today = () => new Date().toISOString().slice(0, 10); const today = () => new Date().toISOString().slice(0, 10);
const fmtDate = (v) => v ? new Date(`${v}T12:00:00`).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }) : "—"; const fmtDate = (v) => v ? new Date(`${v}T12:00:00`).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }) : "—";
const fmtDateTime = (v) => v ? new Date(`${v.replace(" ","T")}Z`).toLocaleString("en-US", { year:"numeric", month:"short", day:"numeric", hour:"numeric", minute:"2-digit" }) : "—";
const esc = (v = "") => String(v).replace(/[&<>'"]/g, c => ({ "&":"&amp;", "<":"&lt;", ">":"&gt;", "'":"&#39;", '"':"&quot;" })[c]); const esc = (v = "") => String(v).replace(/[&<>'"]/g, c => ({ "&":"&amp;", "<":"&lt;", ">":"&gt;", "'":"&#39;", '"':"&quot;" })[c]);
const ids = p => [["UID",p.uid],["SN",p.sn],["MAC",p.mac]].filter(([,v]) => v); const ids = p => [["UID",p.uid],["SN",p.sn],["MAC",p.mac]].filter(([,v]) => v);
const primaryId = p => ids(p)[0]?.[1] || "No identifier"; const primaryId = p => ids(p)[0]?.[1] || "No identifier";
@@ -59,20 +60,22 @@ function receiveForm() {
} }
function sellForm(id="") { function sellForm(id="") {
const available=state.products.filter(p=>p.status==="available"); const available=state.products.filter(p=>p.status==="available");
openModal(`<h2>Record a sale</h2><p>Enter the customer, payment, and shipped-to details.</p><form id="sellForm"><label>Product<select name="productId" required>${available.map(p=>`<option value="${p.id}" ${p.id===id?"selected":""}>${esc(p.model)}${esc(primaryId(p))}</option>`).join("")}</select></label><div class="form-row"><label>Customer name<input name="customerName" required></label><label>Cell phone<input name="phone" type="tel"></label></div><div class="form-row"><label>Payment method<select name="paymentMethod" required><option value="">Choose a method</option><option>Cash</option><option>Venmo</option><option>PayPal</option></select></label><label>Payment reference (optional)<input name="paymentReference" placeholder="Transaction ID or note"></label></div><h3>Shipped to</h3><label>Street address<input name="shipAddress1" autocomplete="shipping address-line1"></label><label>Apartment, suite, or unit<input name="shipAddress2" autocomplete="shipping address-line2"></label><div class="address-grid"><label>City<input name="shipCity" autocomplete="shipping address-level2"></label><label>State<input name="shipState" autocomplete="shipping address-level1"></label><label>ZIP code<input name="shipZip" autocomplete="shipping postal-code"></label></div><label>Shipping notes<textarea name="shippingNotes" rows="2"></textarea></label><div class="form-row"><label>Sale price<input name="salePrice" type="number" min="0" step=".01"></label><label>Date sold<input name="soldAt" type="date" value="${today()}" required></label></div><label>General sale notes<textarea name="saleNotes" rows="3" placeholder="Anything else to remember about this sale"></textarea></label><div class="form-actions"><button type="button" class="secondary" data-cancel>Cancel</button><button class="primary">Complete sale</button></div></form>`); openModal(`<h2>Record a sale</h2><p>Enter the customer, payment, and shipped-to details.</p><form id="sellForm"><label>Product<select name="productId" required>${available.map(p=>`<option value="${p.id}" ${p.id===id?"selected":""}>${esc(p.model)}${esc(primaryId(p))}</option>`).join("")}</select></label><div class="form-row"><label>Customer name<input name="customerName" required></label><label>Cell phone<input name="phone" type="tel"></label></div><div class="form-row"><label>Payment method<select name="paymentMethod" required><option value="">Choose a method</option><option>Cash</option><option>Venmo</option><option>PayPal</option></select></label><label>Payment reference (optional)<input name="paymentReference" placeholder="Transaction ID or note"></label></div><h3>Shipped to</h3><label>Street address<input name="shipAddress1" autocomplete="shipping address-line1"></label><label>Apartment, suite, or unit<input name="shipAddress2" autocomplete="shipping address-line2"></label><div class="address-grid"><label>City<input name="shipCity" autocomplete="shipping address-level2"></label><label>State<input name="shipState" autocomplete="shipping address-level1"></label><label>ZIP code<input name="shipZip" autocomplete="shipping postal-code"></label></div><label>Shipping notes<textarea name="shippingNotes" rows="2"></textarea></label><div class="form-row"><label>Sale price<input name="salePrice" type="number" min="0" step=".01"></label><label>Date sold<input name="soldAt" type="date" value="${today()}" required></label></div><div class="form-actions"><button type="button" class="secondary" data-cancel>Cancel</button><button class="primary">Complete sale</button></div></form>`);
$("#sellForm").onsubmit=async e=>{ e.preventDefault(); const data=Object.fromEntries(new FormData(e.currentTarget)), productId=data.productId; delete data.productId; await change(`/api/products/${encodeURIComponent(productId)}/sell`,"POST",data,"Sale recorded."); }; $("[data-cancel]").onclick=closeModal; $("#sellForm").onsubmit=async e=>{ e.preventDefault(); const data=Object.fromEntries(new FormData(e.currentTarget)), productId=data.productId; delete data.productId; await change(`/api/products/${encodeURIComponent(productId)}/sell`,"POST",data,"Sale recorded."); }; $("[data-cancel]").onclick=closeModal;
} }
async function submitForm(e,path,message,method="POST"){ e.preventDefault(); await change(path,method,Object.fromEntries(new FormData(e.currentTarget)),message); } async function submitForm(e,path,message,method="POST"){ e.preventDefault(); await change(path,method,Object.fromEntries(new FormData(e.currentTarget)),message); }
async function change(path,method,body,message){ try{ await api(path,{method,body:body?JSON.stringify(body):undefined}); closeModal(); await load(); toast(message); }catch(e){ toast(e.message); } } async function change(path,method,body,message){ try{ await api(path,{method,body:body?JSON.stringify(body):undefined}); closeModal(); await load(); toast(message); }catch(e){ toast(e.message); } }
function viewSale(p) { function viewSale(p) {
openModal(`<h2>Sale record</h2><p>${esc(p.model)} · ${fmtDate(p.soldAt)}</p><section class="detail-card"><h3>Customer</h3><p><strong>${esc(p.customerName||"Unknown")}</strong></p><p>${esc(p.phone||"No phone recorded")}</p></section><section class="detail-card"><h3>Payment</h3><p><strong>${esc(p.paymentMethod||"Not recorded")}</strong></p>${p.paymentReference?`<p>Reference: ${esc(p.paymentReference)}</p>`:""}</section><section class="detail-card"><h3>Shipped to</h3><p>${address(p)?esc(address(p)):"No shipping address recorded"}</p>${p.shippingNotes?`<p><small>${esc(p.shippingNotes)}</small></p>`:""}</section><section class="detail-card"><h3>Product and sale</h3><p>${esc(p.manufacturer)} ${esc(p.model)} · ${esc(p.condition)}</p><div class="ids">${idCell(p)}</div><p>Received: ${fmtDate(p.receivedAt)} · Sold: ${fmtDate(p.soldAt)}</p><p>Cost: ${p.cost?money.format(p.cost):"—"} · Sale price: ${p.salePrice?money.format(p.salePrice):"—"}</p>${p.notes?`<p>Inventory notes: ${esc(p.notes)}</p>`:""}</section><form id="saleNotesForm"><label>General sale notes<textarea name="saleNotes" rows="4" placeholder="Add notes about this sale">${esc(p.saleNotes||"")}</textarea></label><div class="form-actions"><button type="button" class="secondary" data-cancel>Close</button><button class="primary">Save notes</button></div></form>`); $("[data-cancel]").onclick=closeModal; $("#saleNotesForm").onsubmit=e=>submitForm(e,`/api/products/${encodeURIComponent(p.id)}`,"Sale notes saved.","PATCH"); openModal(`<h2>Sale record</h2><p>${esc(p.model)} · ${fmtDate(p.soldAt)}</p><section class="detail-card"><h3>Customer</h3><p><strong>${esc(p.customerName||"Unknown")}</strong></p><p>${esc(p.phone||"No phone recorded")}</p></section><section class="detail-card"><h3>Payment</h3><p><strong>${esc(p.paymentMethod||"Not recorded")}</strong></p>${p.paymentReference?`<p>Reference: ${esc(p.paymentReference)}</p>`:""}</section><section class="detail-card"><h3>Shipped to</h3><p>${address(p)?esc(address(p)):"No shipping address recorded"}</p>${p.shippingNotes?`<p><small>${esc(p.shippingNotes)}</small></p>`:""}</section><section class="detail-card"><h3>Product and sale</h3><p>${esc(p.manufacturer)} ${esc(p.model)} · ${esc(p.condition)}</p><div class="ids">${idCell(p)}</div><p>Received: ${fmtDate(p.receivedAt)} · Sold: ${fmtDate(p.soldAt)}</p><p>Cost: ${p.cost?money.format(p.cost):"—"} · Sale price: ${p.salePrice?money.format(p.salePrice):"—"}</p>${p.notes?`<p>Inventory notes: ${esc(p.notes)}</p>`:""}</section><div class="form-actions"><button class="primary" data-cancel>Close</button></div>`); $("[data-cancel]").onclick=closeModal;
} }
async function viewCustomer(p) { async function viewCustomer(p) {
if(!p.customerId) return viewSale(p); if(!p.customerId) return viewSale(p);
try { const c=await api(`/api/customers/${encodeURIComponent(p.customerId)}`), addr=[c.address1,c.address2,[c.city,c.state].filter(Boolean).join(", "),c.zip].filter(Boolean).join(" · "); try { const c=await api(`/api/customers/${encodeURIComponent(p.customerId)}`), addr=[c.address1,c.address2,[c.city,c.state].filter(Boolean).join(", "),c.zip].filter(Boolean).join(" · ");
openModal(`<h2>${esc(c.name)}</h2><p>Customer record and complete purchase history.</p><section class="detail-card"><p>${esc(c.phone||"No phone recorded")}</p><p>${addr?esc(addr):"No shipping address recorded"}</p>${c.shippingNotes?`<p><small>${esc(c.shippingNotes)}</small></p>`:""}</section><section class="customer-purchases"><h3>Purchases (${c.purchases.length})</h3>${c.purchases.map(s=>`<article><strong>${esc(s.model)}</strong> · ${esc(primaryId(s))}<p>${fmtDate(s.soldAt)} · ${s.salePrice?money.format(s.salePrice):"Price not recorded"}</p><button data-customer-sale="${s.id}">View sale</button></article>`).join("")}</section><div class="form-actions"><button class="primary" data-cancel>Close</button></div>`); openModal(`<h2>${esc(c.name)}</h2><p>Customer record, support notes, and purchase history.</p><section class="detail-card"><p>${esc(c.phone||"No phone recorded")}</p><p>${addr?esc(addr):"No shipping address recorded"}</p>${c.shippingNotes?`<p><small>${esc(c.shippingNotes)}</small></p>`:""}</section><section class="customer-notes"><h3>Customer notes</h3><form id="customerNoteForm"><div class="form-row"><label>Category<select name="category"><option>General</option><option>Support</option><option>Follow-up</option></select></label><label>New note<textarea name="note" rows="3" required placeholder="Issue, support contact, or follow-up"></textarea></label></div><div class="form-actions"><button class="primary">Add note</button></div></form>${c.notes.length?c.notes.map(n=>`<article><div><span class="note-category">${esc(n.category)}</span><small>${fmtDateTime(n.createdAt)}</small></div><p>${esc(n.note)}</p><button class="danger" data-delete-note="${n.id}">Delete</button></article>`).join(""):"<p class=\"muted\">No customer notes yet.</p>"}</section><section class="customer-purchases"><h3>Purchases (${c.purchases.length})</h3>${c.purchases.map(s=>`<article><strong>${esc(s.model)}</strong> · ${esc(primaryId(s))}<p>${fmtDate(s.soldAt)} · ${s.salePrice?money.format(s.salePrice):"Price not recorded"}</p><button data-customer-sale="${s.id}">View sale</button></article>`).join("")}</section><div class="form-actions"><button class="primary" data-cancel>Close</button></div>`);
$("[data-cancel]").onclick=closeModal; document.querySelectorAll("[data-customer-sale]").forEach(b=>b.onclick=()=>viewSale(c.purchases.find(s=>s.id===b.dataset.customerSale))); $("[data-cancel]").onclick=closeModal; document.querySelectorAll("[data-customer-sale]").forEach(b=>b.onclick=()=>viewSale(c.purchases.find(s=>s.id===b.dataset.customerSale)));
$("#customerNoteForm").onsubmit=async e=>{e.preventDefault();try{await api(`/api/customers/${encodeURIComponent(c.id)}/notes`,{method:"POST",body:JSON.stringify(Object.fromEntries(new FormData(e.currentTarget)))});await viewCustomer(p);toast("Customer note added.");}catch(error){toast(error.message);}};
document.querySelectorAll("[data-delete-note]").forEach(b=>b.onclick=async()=>{if(!confirm("Delete this customer note?"))return;try{await api(`/api/customers/${encodeURIComponent(c.id)}/notes/${encodeURIComponent(b.dataset.deleteNote)}`,{method:"DELETE"});await viewCustomer(p);toast("Customer note deleted.");}catch(error){toast(error.message);}});
} catch(e){ toast(e.message); } } catch(e){ toast(e.message); }
} }
function restockForm(p){ openModal(`<h2>Void sale & restock</h2><p>Return ${esc(p.model)} to inventory.</p><form id="restockForm"><label>Condition<select name="condition"><option>Used</option><option>Refurbished</option><option>New</option></select></label><label>Return date<input name="receivedAt" type="date" value="${today()}" required></label><div class="form-actions"><button type="button" class="secondary" data-cancel>Cancel</button><button class="primary">Restock product</button></div></form>`); $("[data-cancel]").onclick=closeModal; $("#restockForm").onsubmit=e=>submitForm(e,`/api/products/${encodeURIComponent(p.id)}/restock`,"Product restocked."); } function restockForm(p){ openModal(`<h2>Void sale & restock</h2><p>Return ${esc(p.model)} to inventory.</p><form id="restockForm"><label>Condition<select name="condition"><option>Used</option><option>Refurbished</option><option>New</option></select></label><label>Return date<input name="receivedAt" type="date" value="${today()}" required></label><div class="form-actions"><button type="button" class="secondary" data-cancel>Cancel</button><button class="primary">Restock product</button></div></form>`); $("[data-cancel]").onclick=closeModal; $("#restockForm").onsubmit=e=>submitForm(e,`/api/products/${encodeURIComponent(p.id)}/restock`,"Product restocked."); }
+1
View File
@@ -1,3 +1,4 @@
.customer-link{background:transparent!important;color:#1d4ed8!important;padding:0!important;text-align:left}.customer-link:hover{text-decoration:underline} .customer-link{background:transparent!important;color:#1d4ed8!important;padding:0!important;text-align:left}.customer-link:hover{text-decoration:underline}
#pagination{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;color:#68758b;font-size:13px;border-top:1px solid #edf0f5}#pagination div{display:flex;gap:8px}#pagination button{background:#fff;border:1px solid #d7deea;padding:7px 11px}#pagination button:disabled{opacity:.4;cursor:default} #pagination{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;color:#68758b;font-size:13px;border-top:1px solid #edf0f5}#pagination div{display:flex;gap:8px}#pagination button{background:#fff;border:1px solid #d7deea;padding:7px 11px}#pagination button:disabled{opacity:.4;cursor:default}
.address-grid{display:grid;grid-template-columns:2fr 1fr 1fr;gap:10px}.detail-card{background:#f7f9fc;border:1px solid #e2e7ef;border-radius:10px;padding:14px;margin:14px 0}.detail-card h3,.customer-purchases h3{margin:0 0 10px}.detail-card p{margin:4px 0!important}.customer-purchases{max-height:330px;overflow:auto}.customer-purchases article{border-top:1px solid #e5e9f0;padding:12px 0}.customer-purchases article:first-child{border-top:0}.customer-purchases button{padding:6px 9px;background:#edf3ff;color:#1d4ed8}@media(max-width:760px){.address-grid{grid-template-columns:1fr}#pagination{align-items:flex-start;flex-direction:column}#pagination>div{width:100%;align-items:center;justify-content:space-between}} .address-grid{display:grid;grid-template-columns:2fr 1fr 1fr;gap:10px}.detail-card{background:#f7f9fc;border:1px solid #e2e7ef;border-radius:10px;padding:14px;margin:14px 0}.detail-card h3,.customer-purchases h3{margin:0 0 10px}.detail-card p{margin:4px 0!important}.customer-purchases{max-height:330px;overflow:auto}.customer-purchases article{border-top:1px solid #e5e9f0;padding:12px 0}.customer-purchases article:first-child{border-top:0}.customer-purchases button{padding:6px 9px;background:#edf3ff;color:#1d4ed8}@media(max-width:760px){.address-grid{grid-template-columns:1fr}#pagination{align-items:flex-start;flex-direction:column}#pagination>div{width:100%;align-items:center;justify-content:space-between}}
.customer-notes{margin:18px 0}.customer-notes h3{margin-bottom:8px}.customer-notes article{position:relative;border:1px solid #e2e7ef;border-radius:9px;padding:12px;margin:8px 0}.customer-notes article>div{display:flex;align-items:center;gap:8px;color:#667085}.customer-notes article p{white-space:pre-wrap;margin:10px 0}.customer-notes article>button{position:absolute;right:8px;top:8px;padding:5px 8px;background:#fff0f0;color:#c62828}.note-category{background:#edf3ff;color:#1d4ed8;border-radius:99px;padding:3px 8px;font-size:12px;font-weight:700}