From 27f1b042dc346e9aa47821e35db8ce86650dfa11 Mon Sep 17 00:00:00 2001 From: mfwadejr <125498560+mfwadejr@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:41:30 -0400 Subject: [PATCH] Add Stockroom web interface --- public/app.js | 77 +++++++++++++++++++++++++++++++++++++++++++++++ public/extras.css | 3 ++ public/index.html | 5 +++ public/style.css | 1 + 4 files changed, 86 insertions(+) create mode 100644 public/app.js create mode 100644 public/extras.css create mode 100644 public/index.html create mode 100644 public/style.css diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..d9a380b --- /dev/null +++ b/public/app.js @@ -0,0 +1,77 @@ +const $ = (s) => document.querySelector(s); +const state = { products: [], tab: "available", query: "", page: 1 }; +const PAGE_SIZE = 10; +const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }); +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 esc = (v = "") => String(v).replace(/[&<>'"]/g, c => ({ "&":"&", "<":"<", ">":">", "'":"'", '"':""" })[c]); +const ids = p => [["UID",p.uid],["SN",p.sn],["MAC",p.mac]].filter(([,v]) => v); +const primaryId = p => ids(p)[0]?.[1] || "No identifier"; + +async function api(path, options = {}) { + const response = await fetch(path, { headers: { "content-type":"application/json" }, ...options }); + if (response.status === 204) return null; + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Something went wrong."); + return data; +} +function toast(message) { const el=$("#toast"); el.textContent=message; el.hidden=false; clearTimeout(toast.timer); toast.timer=setTimeout(()=>el.hidden=true,2600); } +function openModal(html) { $("#modalBody").innerHTML=html; $("#modal").showModal(); } +function closeModal() { $("#modal").close(); } +function address(p) { return [p.shipAddress1,p.shipAddress2,[p.shipCity,p.shipState].filter(Boolean).join(", "),p.shipZip].filter(Boolean).join(" · "); } +function idCell(p) { return ids(p).length ? ids(p).map(([k,v])=>`${k}: ${esc(v)}`).join("") : "Not entered"; } + +function filtered() { + const status=state.tab==="available"?"available":"sold", q=state.query.toLowerCase().trim(); + return state.products.filter(p=>p.status===status && (!q || Object.values(p).some(v=>String(v??"").toLowerCase().includes(q)))); +} +function render() { + const available=state.products.filter(p=>p.status==="available"), sold=state.products.filter(p=>p.status==="sold"), month=today().slice(0,7), monthSales=sold.filter(p=>p.soldAt?.startsWith(month)); + $("#availableCount").textContent=available.length; $("#soldCount").textContent=monthSales.length; + $("#value").textContent=money.format(available.reduce((n,p)=>n+Number(p.cost||0),0)); + $("#revenue").textContent=monthSales.length?`${money.format(monthSales.reduce((n,p)=>n+Number(p.salePrice||0),0))} in sales`:"No sales recorded"; + $("#availableBadge").textContent=available.length; $("#soldBadge").textContent=sold.length; + document.querySelectorAll("[data-tab]").forEach(b=>b.classList.toggle("active",b.dataset.tab===state.tab)); + const all=filtered(), pages=Math.max(1,Math.ceil(all.length/PAGE_SIZE)); state.page=Math.min(state.page,pages); + const start=(state.page-1)*PAGE_SIZE, rows=all.slice(start,start+PAGE_SIZE); + $("#thead").innerHTML=state.tab==="available"?"ProductUID / SN / MACReceivedCostStatus":"ProductCustomerUID / SN / MACSoldSale price"; + $("#rows").innerHTML=rows.map(p=>state.tab==="available"?inventoryRow(p):saleRow(p)).join(""); + $("#empty").hidden=Boolean(rows.length); $("#empty").textContent=state.query?"No records match your search.":state.tab==="available"?"No products are available.":"No sales have been recorded."; + $("#pagination").innerHTML=all.length?`Showing ${start+1}–${Math.min(start+PAGE_SIZE,all.length)} of ${all.length}
Page ${state.page} of ${pages}
`:""; +} +function inventoryRow(p) { return `${esc(p.model)}${esc(p.manufacturer)} · ${esc(p.condition)}
${idCell(p)}
${fmtDate(p.receivedAt)}${p.cost?money.format(p.cost):"—"}Available
`; } +function saleRow(p) { return `${esc(p.model)}${esc(p.manufacturer)} · ${esc(p.condition)}${p.phone?`${esc(p.phone)}`:""}
${idCell(p)}
${fmtDate(p.soldAt)}${p.salePrice?money.format(p.salePrice):"—"}
`; } + +function receiveForm() { + openModal(`

Receive product

Add a vSeeBox unit to available inventory.

`); + $("#receiveForm").onsubmit=e=>submitForm(e,"/api/products","Product received."); $("[data-cancel]").onclick=closeModal; +} +function sellForm(id="") { + const available=state.products.filter(p=>p.status==="available"); + openModal(`

Record a sale

Enter the customer and shipped-to details.

Shipped to

`); + $("#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){ e.preventDefault(); await change(path,"POST",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); } } + +function viewSale(p) { + openModal(`

Sale record

${esc(p.model)} · ${fmtDate(p.soldAt)}

Customer

${esc(p.customerName||"Unknown")}

${esc(p.phone||"No phone recorded")}

Shipped to

${address(p)?esc(address(p)):"No shipping address recorded"}

${p.shippingNotes?`

${esc(p.shippingNotes)}

`:""}

Product and sale

${esc(p.manufacturer)} ${esc(p.model)} · ${esc(p.condition)}

${idCell(p)}

Received: ${fmtDate(p.receivedAt)} · Sold: ${fmtDate(p.soldAt)}

Cost: ${p.cost?money.format(p.cost):"—"} · Sale price: ${p.salePrice?money.format(p.salePrice):"—"}

${p.notes?`

Notes: ${esc(p.notes)}

`:""}
`); $("[data-cancel]").onclick=closeModal; +} +async function viewCustomer(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(" · "); + openModal(`

${esc(c.name)}

Customer record and complete purchase history.

${esc(c.phone||"No phone recorded")}

${addr?esc(addr):"No shipping address recorded"}

${c.shippingNotes?`

${esc(c.shippingNotes)}

`:""}

Purchases (${c.purchases.length})

${c.purchases.map(s=>`
${esc(s.model)} · ${esc(primaryId(s))}

${fmtDate(s.soldAt)} · ${s.salePrice?money.format(s.salePrice):"Price not recorded"}

`).join("")}
`); + $("[data-cancel]").onclick=closeModal; document.querySelectorAll("[data-customer-sale]").forEach(b=>b.onclick=()=>viewSale(c.purchases.find(s=>s.id===b.dataset.customerSale))); + } catch(e){ toast(e.message); } +} +function restockForm(p){ openModal(`

Void sale & restock

Return ${esc(p.model)} to inventory.

`); $("[data-cancel]").onclick=closeModal; $("#restockForm").onsubmit=e=>submitForm(e,`/api/products/${encodeURIComponent(p.id)}/restock`,"Product restocked."); } + +async function load(){ state.products=await api("/api/products"); render(); } +document.querySelectorAll("[data-tab]").forEach(b=>b.onclick=()=>{state.tab=b.dataset.tab;state.page=1;render();}); +$("#search").oninput=e=>{state.query=e.target.value;state.page=1;render();}; +$("[data-open=receive]").onclick=receiveForm; $("[data-open=sell]").onclick=()=>sellForm(); +$("#pagination").onclick=e=>{if(!e.target.dataset.page)return;state.page+=e.target.dataset.page==="next"?1:-1;render();}; +$("#rows").onclick=async e=>{const b=e.target.closest("button");if(!b)return;const id=b.dataset.sell||b.dataset.view||b.dataset.restock||b.dataset.delete||b.dataset.id,p=state.products.find(x=>x.id===id);if(!p)return;if(b.dataset.sell)sellForm(id);else if(b.dataset.view)viewSale(p);else if(b.dataset.customer!==undefined)viewCustomer(p);else if(b.dataset.restock)restockForm(p);else if(b.dataset.delete&&confirm(`Permanently delete this ${p.status==="sold"?"sale":"product"} record?`))await change(`/api/products/${encodeURIComponent(id)}`,"DELETE",null,"Record deleted.");}; +$("#modal .close").onclick=closeModal; $("#modal").onclick=e=>{if(e.target===$("#modal"))closeModal();}; +$("#date").textContent=new Date().toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"}); +load().catch(e=>toast(e.message)); diff --git a/public/extras.css b/public/extras.css new file mode 100644 index 0000000..ce025e6 --- /dev/null +++ b/public/extras.css @@ -0,0 +1,3 @@ +.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} +.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}} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..81d8f67 --- /dev/null +++ b/public/index.html @@ -0,0 +1,5 @@ +vSeeBox Stockroom +
SStockroom
+

Inventory Overview

Available products0Ready to sell
Sold this month0No sales recorded
Inventory value$0Based on purchase cost
+
+
diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..59d0975 --- /dev/null +++ b/public/style.css @@ -0,0 +1 @@ +:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;color:#172033;background:#f5f7fb}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button{cursor:pointer}header{height:76px;background:white;border-bottom:1px solid #e3e7ef;display:flex;align-items:center;padding:0 4vw;gap:28px;position:sticky;top:0;z-index:2}.brand{display:flex;align-items:center;gap:10px;font-size:20px;font-weight:700}.brand b{display:grid;place-items:center;background:#2563eb;color:white;border-radius:9px;width:36px;height:36px}.brand+input{flex:1;max-width:620px;border:1px solid #d8deea;background:#f8fafc;padding:12px 15px;border-radius:10px}.actions{margin-left:auto;display:flex;gap:10px}button{border:0;border-radius:9px;padding:10px 14px;font-weight:650}.primary{background:#2563eb;color:#fff}.secondary{background:#fff;border:1px solid #d7deea;color:#27344a}main{width:min(1400px,92vw);margin:34px auto}.intro{display:flex;justify-content:space-between;align-items:center}.intro h1{font-size:28px}.intro>span{background:#fff;border:1px solid #e0e5ed;padding:9px 12px;border-radius:9px;color:#64748b}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:18px;margin:20px 0}.stats article{background:#fff;border:1px solid #e3e7ef;border-radius:14px;padding:22px;display:grid;gap:6px}.stats span,.stats small{color:#68758b}.stats strong{font-size:30px}.records{background:#fff;border:1px solid #e3e7ef;border-radius:14px;overflow:hidden}.records nav{height:64px;border-bottom:1px solid #e3e7ef;display:flex;align-items:center;padding:0 20px;gap:12px}.records nav .tab{background:transparent;color:#667085;border-radius:0;height:64px}.records nav .tab.active{color:#2563eb;border-bottom:2px solid #2563eb}.tab i{font-style:normal;background:#edf2f8;padding:2px 7px;border-radius:12px}.records nav>span{margin-left:auto;color:#6b7280;font-size:13px}.table-wrap{overflow:auto}table{border-collapse:collapse;width:100%;min-width:950px}th,td{text-align:left;padding:15px 18px;border-bottom:1px solid #edf0f5;vertical-align:middle}th{font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:#758197;background:#fbfcfe}.product strong,.customer strong{display:block}.muted,td small{display:block;color:#7b8799;margin-top:4px}.ids{display:grid;gap:3px}.ids code{font-family:ui-monospace,monospace;font-size:12px}.pill{display:inline-block;color:#087443;background:#dcfce7;border-radius:99px;padding:4px 9px;font-size:12px}.row-actions{display:flex;gap:6px}.row-actions button{padding:7px 9px;background:#edf3ff;color:#1d4ed8;white-space:nowrap}.row-actions .danger{background:#fff0f0;color:#c62828}#empty{padding:60px;text-align:center;color:#758197}dialog{width:min(620px,94vw);border:0;border-radius:16px;padding:26px;box-shadow:0 24px 80px #17203340}dialog::backdrop{background:#17203380}.close{position:absolute;right:15px;top:13px;background:transparent;font-size:25px;color:#728096}dialog h2{margin:4px 0}dialog>div>p{color:#68758b;margin-top:5px}.scan{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.form-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}label{display:grid;gap:6px;margin:13px 0;font-weight:600;font-size:13px}label input,label select,label textarea{width:100%;padding:11px;border:1px solid #d6dce7;border-radius:8px;background:white}.form-actions{display:flex;justify-content:flex-end;gap:9px;margin-top:20px}#toast{position:fixed;right:24px;bottom:24px;background:#172033;color:#fff;padding:13px 17px;border-radius:10px;z-index:4}@media(max-width:760px){header{height:auto;padding:14px;flex-wrap:wrap;gap:10px}.brand+input{order:3;flex-basis:100%;max-width:none}.actions{margin-left:auto}.actions button{font-size:0}.actions button::first-letter{font-size:18px}.stats{grid-template-columns:1fr}.scan,.form-row{grid-template-columns:1fr}.records nav>span{display:none}main{margin-top:18px}}